mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-12 23:18:30 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72212e7bfd | ||
|
|
c59883aac9 |
@@ -19,6 +19,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
copied straight to the clipboard for pasting outside the app (shared
|
copied straight to the clipboard for pasting outside the app (shared
|
||||||
`blob-io` helpers replace the download idiom previously duplicated in
|
`blob-io` helpers replace the download idiom previously duplicated in
|
||||||
settings-io and diagnostics-export).
|
settings-io and diagnostics-export).
|
||||||
|
- **Gigs (backend)** — career mode gains its verb: `POST
|
||||||
|
/api/plugins/career/gigs/propose` builds a playable setlist for an
|
||||||
|
instrument+genre (your qualifying songs plus a couple of stakes songs near
|
||||||
|
the bar; a young passport fills from unplayed genre songs — the first gig
|
||||||
|
is how stubs start; re-roll by calling again), naming the room your stars
|
||||||
|
can book. `POST /gigs` logs a **completed** set — per-song accuracies read
|
||||||
|
from the set's own freshly-recorded stats, an encore flag at the
|
||||||
|
data-driven bar (avg ≥ 75%) — into the career state; abandoned sets never
|
||||||
|
log (no fail state: the gig you finished is the gig you played). Passports
|
||||||
|
carry their gig log; instruments their gig count.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- **The full mix is a stem** (#933) — core no longer depends on `original_audio:`, a
|
- **The full mix is a stem** (#933) — core no longer depends on `original_audio:`, a
|
||||||
|
|||||||
@@ -3,6 +3,12 @@
|
|||||||
"songs": 5,
|
"songs": 5,
|
||||||
"min_stars": 2
|
"min_stars": 2
|
||||||
},
|
},
|
||||||
|
"gig": {
|
||||||
|
"min_songs": 3,
|
||||||
|
"max_songs": 5,
|
||||||
|
"stakes_songs": 2,
|
||||||
|
"encore_accuracy": 0.75
|
||||||
|
},
|
||||||
"families": [
|
"families": [
|
||||||
{ "key": "metal", "match": ["metal", "djent", "grindcore", "thrash", "doom"] },
|
{ "key": "metal", "match": ["metal", "djent", "grindcore", "thrash", "doom"] },
|
||||||
{ "key": "blues", "match": ["blues"] },
|
{ "key": "blues", "match": ["blues"] },
|
||||||
|
|||||||
+184
-1
@@ -26,11 +26,14 @@ Endpoints (all under /api/plugins/career/):
|
|||||||
POST /passports/commit commit to an instrument (the wax seal, Stage 0)
|
POST /passports/commit commit to an instrument (the wax seal, Stage 0)
|
||||||
POST /passports/open open a genre passport for an instrument
|
POST /passports/open open a genre passport for an instrument
|
||||||
POST /drill-state relayed virtuoso.progress snapshot (drill intake)
|
POST /drill-state relayed virtuoso.progress snapshot (drill intake)
|
||||||
|
POST /gigs/propose build a playable setlist for a genre gig
|
||||||
|
POST /gigs log a COMPLETED gig (abandoned sets never log)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import random
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -383,6 +386,7 @@ def _passports_view():
|
|||||||
cfg = _state["passports_content"]
|
cfg = _state["passports_content"]
|
||||||
graded = set(cfg.get("graded_instruments") or [])
|
graded = set(cfg.get("graded_instruments") or [])
|
||||||
st = _career_state()
|
st = _career_state()
|
||||||
|
all_gigs = st.get("gigs") if isinstance(st.get("gigs"), list) else []
|
||||||
played, played_seconds = _played_by_instrument_genre()
|
played, played_seconds = _played_by_instrument_genre()
|
||||||
received_at, by_node = _drill_by_node()
|
received_at, by_node = _drill_by_node()
|
||||||
instruments = {}
|
instruments = {}
|
||||||
@@ -439,7 +443,11 @@ def _passports_view():
|
|||||||
"drills": {"required": required, "cleared": cleared},
|
"drills": {"required": required, "cleared": cleared},
|
||||||
"badge": badge,
|
"badge": badge,
|
||||||
})
|
})
|
||||||
instruments[inst] = {"committed_at": committed_at, "passports": passports}
|
inst_gigs = [g for g in all_gigs if g.get("instrument") == inst]
|
||||||
|
for p in passports:
|
||||||
|
p["gigs"] = [g for g in inst_gigs if g.get("genre_key") == p["genre_key"]][-20:][::-1]
|
||||||
|
instruments[inst] = {"committed_at": committed_at, "passports": passports,
|
||||||
|
"gig_count": len(inst_gigs)}
|
||||||
return {
|
return {
|
||||||
"config": {
|
"config": {
|
||||||
"badge_requirement": cfg.get("badge_requirement") or {},
|
"badge_requirement": cfg.get("badge_requirement") or {},
|
||||||
@@ -454,6 +462,60 @@ def _passports_view():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _gig_config():
|
||||||
|
cfg = _state["passports_content"].get("gig")
|
||||||
|
cfg = cfg if isinstance(cfg, dict) else {}
|
||||||
|
|
||||||
|
def _num(key, default, cast):
|
||||||
|
# Tuning data, not code: junk falls back instead of 500ing both gig
|
||||||
|
# endpoints, and a legitimate 0 (stakes_songs: 0) is respected.
|
||||||
|
val = cfg.get(key)
|
||||||
|
if isinstance(val, bool) or not isinstance(val, (int, float)):
|
||||||
|
return default
|
||||||
|
return cast(val)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"min_songs": max(1, _num("min_songs", 3, int)),
|
||||||
|
"max_songs": max(1, _num("max_songs", 5, int)),
|
||||||
|
"stakes_songs": max(0, _num("stakes_songs", 2, int)),
|
||||||
|
"encore_accuracy": _num("encore_accuracy", 0.75, float),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _current_venue():
|
||||||
|
"""Highest unlocked venue (the room you can book today)."""
|
||||||
|
stars_total, _, _ = _stars()
|
||||||
|
best = None
|
||||||
|
for v in _state["content"]["venues"]:
|
||||||
|
if stars_total >= v["star_threshold"]:
|
||||||
|
if best is None or v["star_threshold"] >= best["star_threshold"]:
|
||||||
|
best = v
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def _unplayed_genre_songs(gkey, exclude, limit):
|
||||||
|
"""Library songs of a genre with no stats yet — a young passport's gig
|
||||||
|
still gets a full set (playing them is how stubs start).
|
||||||
|
ponytail: full stat-less scan + python-side genre match (a few ms at 7k
|
||||||
|
songs, single-user); push the match into SQL if propose ever feels slow."""
|
||||||
|
db = _state["meta_db"]
|
||||||
|
if db is None:
|
||||||
|
return []
|
||||||
|
rows = db.conn.execute(
|
||||||
|
f"SELECT filename, title, artist, {_genre_expr(db)} AS g FROM songs "
|
||||||
|
"WHERE filename NOT IN (SELECT filename FROM song_stats)"
|
||||||
|
).fetchall()
|
||||||
|
out = []
|
||||||
|
for filename, title, artist, genre in rows:
|
||||||
|
if _genre_key(genre) != gkey or filename in exclude:
|
||||||
|
continue
|
||||||
|
out.append({"filename": filename, "title": title or filename,
|
||||||
|
"artist": artist or ""})
|
||||||
|
if len(out) >= limit:
|
||||||
|
break
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _validate_pack_dir(pack_dir: Path):
|
def _validate_pack_dir(pack_dir: Path):
|
||||||
"""Raise ValueError unless pack_dir holds a complete venue pack."""
|
"""Raise ValueError unless pack_dir holds a complete venue pack."""
|
||||||
manifest_path = pack_dir / "manifest.json"
|
manifest_path = pack_dir / "manifest.json"
|
||||||
@@ -635,6 +697,127 @@ def setup(app, context):
|
|||||||
"snapshot": snapshot})
|
"snapshot": snapshot})
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
@app.post(f"/api/plugins/{PLUGIN_ID}/gigs/propose")
|
||||||
|
def propose_gig(body: dict = Body(...)):
|
||||||
|
inst = str((body or {}).get("instrument") or "")
|
||||||
|
genre = _genre_display((body or {}).get("genre"))
|
||||||
|
gkey = genre.lower()
|
||||||
|
if inst not in (_state["passports_content"].get("instruments") or []):
|
||||||
|
raise HTTPException(400, "Unknown instrument.")
|
||||||
|
if not gkey or len(genre) > GENRE_MAX_LEN:
|
||||||
|
raise HTTPException(400, "Provide a genre.")
|
||||||
|
cfg = _gig_config()
|
||||||
|
try:
|
||||||
|
size = int((body or {}).get("size") or 4)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise HTTPException(400, "size must be a number.")
|
||||||
|
size = max(cfg["min_songs"], min(cfg["max_songs"], size))
|
||||||
|
played, _seconds = _played_by_instrument_genre()
|
||||||
|
stubs = list(played.get((inst, gkey), {}).values())
|
||||||
|
req = _badge_requirement(gkey, inst)
|
||||||
|
qualifying = [s for s in stubs if s["stars"] >= req["min_stars"]]
|
||||||
|
rest = [s for s in stubs if s["stars"] < req["min_stars"]]
|
||||||
|
# The set: mostly songs you own, plus a couple of stakes songs near
|
||||||
|
# the bar; a young passport fills from unplayed genre songs so the
|
||||||
|
# first gig is how stubs start. random per call = free re-roll.
|
||||||
|
random.shuffle(qualifying)
|
||||||
|
rest.sort(key=lambda s: -s["best_accuracy"])
|
||||||
|
qtaken = max(1, size - cfg["stakes_songs"])
|
||||||
|
picks = qualifying[:qtaken]
|
||||||
|
for s in rest:
|
||||||
|
if len(picks) >= size:
|
||||||
|
break
|
||||||
|
picks.append(s)
|
||||||
|
# Surplus qualifying songs backfill a short set — a mature passport
|
||||||
|
# with no near-bar songs left must still fill the bill. Offset by how
|
||||||
|
# many QUALIFYING songs were taken, not len(picks): rest's stakes
|
||||||
|
# additions would otherwise skip eligible qualifying songs entirely.
|
||||||
|
for s in qualifying[qtaken:]:
|
||||||
|
if len(picks) >= size:
|
||||||
|
break
|
||||||
|
picks.append(s)
|
||||||
|
if len(picks) < size:
|
||||||
|
exclude = {s["filename"] for s in picks}
|
||||||
|
picks.extend(_unplayed_genre_songs(gkey, exclude, size - len(picks)))
|
||||||
|
if not picks:
|
||||||
|
raise HTTPException(404, "No songs of this genre in the library.")
|
||||||
|
venue = _current_venue()
|
||||||
|
return {
|
||||||
|
"instrument": inst,
|
||||||
|
"genre": genre,
|
||||||
|
"genre_key": gkey,
|
||||||
|
"venue_id": venue["id"] if venue else None,
|
||||||
|
"venue_name": venue["name"] if venue else "",
|
||||||
|
"songs": [{"filename": s["filename"], "title": s.get("title") or s["filename"],
|
||||||
|
"artist": s.get("artist") or ""} for s in picks[:size]],
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.post(f"/api/plugins/{PLUGIN_ID}/gigs")
|
||||||
|
def log_gig(body: dict = Body(...)):
|
||||||
|
# Called by the runner ONLY when the set completed — an abandoned set
|
||||||
|
# never logs (no fail state; the gig you finished is the gig you
|
||||||
|
# played). Accuracies come from song_stats, freshly written by the
|
||||||
|
# set's own plays.
|
||||||
|
inst = str((body or {}).get("instrument") or "")
|
||||||
|
genre = _genre_display((body or {}).get("genre"))
|
||||||
|
gkey = genre.lower()
|
||||||
|
venue_id = str((body or {}).get("venue_id") or "")
|
||||||
|
songs = (body or {}).get("songs")
|
||||||
|
if inst not in (_state["passports_content"].get("instruments") or []):
|
||||||
|
raise HTTPException(400, "Unknown instrument.")
|
||||||
|
if not gkey or len(genre) > GENRE_MAX_LEN:
|
||||||
|
raise HTTPException(400, "Provide a genre.")
|
||||||
|
if venue_id and (not VENUE_ID_RE.fullmatch(venue_id) or _venue(venue_id) is None):
|
||||||
|
raise HTTPException(400, "Unknown venue.")
|
||||||
|
if (not isinstance(songs, list) or not songs or len(songs) > 8
|
||||||
|
or not all(isinstance(f, str) and f.strip() for f in songs)):
|
||||||
|
raise HTTPException(400, "songs must be 1-8 filenames.")
|
||||||
|
db = _state["meta_db"]
|
||||||
|
entries = []
|
||||||
|
accuracies = []
|
||||||
|
for filename in songs:
|
||||||
|
title = filename
|
||||||
|
accuracy = None
|
||||||
|
if db is not None:
|
||||||
|
# The NEWEST row is the set's own just-recorded play — a
|
||||||
|
# MAX(last_accuracy) across arrangements would happily log a
|
||||||
|
# stale higher score from another instrument's old session.
|
||||||
|
row = db.conn.execute(
|
||||||
|
"SELECT last_accuracy FROM song_stats WHERE filename = ? "
|
||||||
|
"ORDER BY last_played_at DESC LIMIT 1",
|
||||||
|
(filename,)).fetchone()
|
||||||
|
if row and row[0] is not None:
|
||||||
|
accuracy = round(float(row[0]), 4)
|
||||||
|
accuracies.append(accuracy)
|
||||||
|
trow = db.conn.execute(
|
||||||
|
"SELECT title FROM songs WHERE filename = ?", (filename,)).fetchone()
|
||||||
|
if trow and trow[0]:
|
||||||
|
title = trow[0]
|
||||||
|
entries.append({"filename": filename, "title": title, "accuracy": accuracy})
|
||||||
|
# Encore needs the WHOLE set scored at the bar — one scored song must
|
||||||
|
# not earn an encore for a set that was 4/5 unheard.
|
||||||
|
encore = (len(accuracies) == len(songs) and
|
||||||
|
sum(accuracies) / len(accuracies) >= _gig_config()["encore_accuracy"])
|
||||||
|
gig = {
|
||||||
|
"at": _now_iso(),
|
||||||
|
"venue_id": venue_id or None,
|
||||||
|
"instrument": inst,
|
||||||
|
"genre": genre,
|
||||||
|
"genre_key": gkey,
|
||||||
|
"songs": entries,
|
||||||
|
"encore": encore,
|
||||||
|
}
|
||||||
|
with _lock:
|
||||||
|
st = _career_state()
|
||||||
|
if not isinstance(st.get("gigs"), list):
|
||||||
|
st["gigs"] = []
|
||||||
|
st["gigs"].append(gig)
|
||||||
|
# ponytail: hard cap — nothing reads past the last 20 per
|
||||||
|
# passport; the state file must not grow (and export) forever.
|
||||||
|
st["gigs"] = st["gigs"][-500:]
|
||||||
|
_save_json(_state_file(), st)
|
||||||
|
return {"ok": True, "gig": gig}
|
||||||
|
|
||||||
@app.post(f"/api/plugins/{PLUGIN_ID}/packs/{{venue_id}}/download")
|
@app.post(f"/api/plugins/{PLUGIN_ID}/packs/{{venue_id}}/download")
|
||||||
def start_download(venue_id: str):
|
def start_download(venue_id: str):
|
||||||
venue = _venue(venue_id) if VENUE_ID_RE.fullmatch(venue_id) else None
|
venue = _venue(venue_id) if VENUE_ID_RE.fullmatch(venue_id) else None
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class FakeMetaDb:
|
|||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"""CREATE TABLE song_stats (
|
"""CREATE TABLE song_stats (
|
||||||
filename TEXT, arrangement TEXT, best_accuracy REAL,
|
filename TEXT, arrangement TEXT, best_accuracy REAL,
|
||||||
last_played_at TEXT,
|
last_accuracy REAL, last_played_at TEXT,
|
||||||
seconds_total REAL NOT NULL DEFAULT 0
|
seconds_total REAL NOT NULL DEFAULT 0
|
||||||
)"""
|
)"""
|
||||||
)
|
)
|
||||||
@@ -38,10 +38,12 @@ class FakeMetaDb:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def add(self, filename, arrangement, best_accuracy, in_library=True,
|
def add(self, filename, arrangement, best_accuracy, in_library=True,
|
||||||
genre="", arrangements=None, last_played_at=None, seconds_total=0):
|
genre="", arrangements=None, last_played_at=None, seconds_total=0,
|
||||||
self.conn.execute("INSERT INTO song_stats VALUES (?, ?, ?, ?, ?)",
|
last_accuracy=None):
|
||||||
(filename, arrangement, best_accuracy, last_played_at,
|
self.conn.execute("INSERT INTO song_stats VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
seconds_total))
|
(filename, arrangement, best_accuracy,
|
||||||
|
last_accuracy if last_accuracy is not None else best_accuracy,
|
||||||
|
last_played_at, seconds_total))
|
||||||
if in_library:
|
if in_library:
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"INSERT INTO songs SELECT ?, ?, ?, ?, ? WHERE NOT EXISTS "
|
"INSERT INTO songs SELECT ?, ?, ?, ?, ? WHERE NOT EXISTS "
|
||||||
|
|||||||
@@ -272,3 +272,102 @@ def test_nearest_targets_the_qualifying_bar_not_next_star(client, meta_db):
|
|||||||
p = _passport(client, "guitar", "soul")
|
p = _passport(client, "guitar", "soul")
|
||||||
assert [s["filename"] for s in p["nearest"]] == ["one_star.feedpak", "zero_star.feedpak"]
|
assert [s["filename"] for s in p["nearest"]] == ["one_star.feedpak", "zero_star.feedpak"]
|
||||||
assert all(s["bar_at"] == 0.75 for s in p["nearest"])
|
assert all(s["bar_at"] == 0.75 for s in p["nearest"])
|
||||||
|
# ── Gigs ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_gig_propose_mixes_owned_and_stakes(client, meta_db):
|
||||||
|
for i in range(4):
|
||||||
|
meta_db.add(f"own{i}.feedpak", 0, 0.85, genre="Soul", arrangements=LEAD)
|
||||||
|
meta_db.add("stake.feedpak", 0, 0.70, genre="Soul", arrangements=LEAD)
|
||||||
|
meta_db.add_song_only("fresh.feedpak", genre="Soul")
|
||||||
|
res = client.post("/api/plugins/career/gigs/propose",
|
||||||
|
json={"instrument": "guitar", "genre": "Soul", "size": 4})
|
||||||
|
assert res.status_code == 200
|
||||||
|
gig = res.json()
|
||||||
|
files = [s["filename"] for s in gig["songs"]]
|
||||||
|
assert len(files) == 4
|
||||||
|
assert "stake.feedpak" in files # a near-bar song gives the set stakes
|
||||||
|
assert gig["venue_id"] == "bar" # 9 stars < 50: the dive bar
|
||||||
|
|
||||||
|
# A young passport (nothing played) still gets a playable set from the
|
||||||
|
# library's unplayed genre songs.
|
||||||
|
res2 = client.post("/api/plugins/career/gigs/propose",
|
||||||
|
json={"instrument": "guitar", "genre": "Ska"})
|
||||||
|
assert res2.status_code == 404 # no ska in the library at all
|
||||||
|
meta_db.add_song_only("ska1.feedpak", genre="Ska")
|
||||||
|
res3 = client.post("/api/plugins/career/gigs/propose",
|
||||||
|
json={"instrument": "guitar", "genre": "Ska"})
|
||||||
|
assert [s["filename"] for s in res3.json()["songs"]] == ["ska1.feedpak"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_gig_log_computes_encore_and_surfaces_in_passports(client, meta_db):
|
||||||
|
for i in range(2):
|
||||||
|
meta_db.add(f"s{i}.feedpak", 0, 0.9, genre="Soul", arrangements=LEAD,
|
||||||
|
last_accuracy=0.9)
|
||||||
|
_open(client, "guitar", "Soul")
|
||||||
|
res = client.post("/api/plugins/career/gigs", json={
|
||||||
|
"instrument": "guitar", "genre": "Soul", "venue_id": "bar",
|
||||||
|
"songs": ["s0.feedpak", "s1.feedpak"]})
|
||||||
|
assert res.status_code == 200
|
||||||
|
gig = res.json()["gig"]
|
||||||
|
assert gig["encore"] is True # avg 0.9 ≥ 0.75
|
||||||
|
assert gig["songs"][0]["accuracy"] == 0.9
|
||||||
|
view = client.get("/api/plugins/career/passports").json()
|
||||||
|
assert view["instruments"]["guitar"]["gig_count"] == 1
|
||||||
|
p = _passport(client, "guitar", "soul")
|
||||||
|
assert len(p["gigs"]) == 1 and p["gigs"][0]["encore"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_gig_log_validation_and_no_fail_state(client):
|
||||||
|
# Unknown venue / bad songs shapes are rejected; nothing is ever logged
|
||||||
|
# as a failed gig — the endpoint only appends completed sets.
|
||||||
|
assert client.post("/api/plugins/career/gigs", json={
|
||||||
|
"instrument": "guitar", "genre": "Soul", "venue_id": "nope",
|
||||||
|
"songs": ["x"]}).status_code == 400
|
||||||
|
assert client.post("/api/plugins/career/gigs", json={
|
||||||
|
"instrument": "guitar", "genre": "Soul", "songs": []}).status_code == 400
|
||||||
|
assert client.post("/api/plugins/career/gigs", json={
|
||||||
|
"instrument": "guitar", "genre": "Soul",
|
||||||
|
"songs": ["f"] * 9}).status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_gig_accuracy_reads_newest_row_and_encore_needs_full_set(client, meta_db):
|
||||||
|
# Newest row wins: a stale higher accuracy on another arrangement must
|
||||||
|
# not inflate the gig log.
|
||||||
|
meta_db.add("dual.feedpak", 1, 0.95, genre="Soul", arrangements=BASS,
|
||||||
|
last_accuracy=0.95, last_played_at="2026-06-01T00:00:00")
|
||||||
|
meta_db.add("dual.feedpak", 0, 0.60, genre="Soul", arrangements=LEAD,
|
||||||
|
last_accuracy=0.60, last_played_at="2026-07-14T00:00:00")
|
||||||
|
res = client.post("/api/plugins/career/gigs", json={
|
||||||
|
"instrument": "guitar", "genre": "Soul", "songs": ["dual.feedpak"]})
|
||||||
|
assert res.json()["gig"]["songs"][0]["accuracy"] == 0.6
|
||||||
|
|
||||||
|
# A set with an unscored song never earns the encore off one good song.
|
||||||
|
meta_db.add("scored.feedpak", 0, 0.9, genre="Soul", arrangements=LEAD,
|
||||||
|
last_accuracy=0.9, last_played_at="2026-07-14T00:01:00")
|
||||||
|
res2 = client.post("/api/plugins/career/gigs", json={
|
||||||
|
"instrument": "guitar", "genre": "Soul",
|
||||||
|
"songs": ["scored.feedpak", "ghost.feedpak"]})
|
||||||
|
assert res2.json()["gig"]["encore"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_gig_propose_backfills_from_surplus_qualifying(client, meta_db):
|
||||||
|
# Mature passport: plenty of qualifying songs, nothing near the bar,
|
||||||
|
# nothing unplayed — the set still fills to size.
|
||||||
|
for i in range(8):
|
||||||
|
meta_db.add(f"own{i}.feedpak", 0, 0.9, genre="Ska", arrangements=LEAD)
|
||||||
|
res = client.post("/api/plugins/career/gigs/propose",
|
||||||
|
json={"instrument": "guitar", "genre": "Ska", "size": 5})
|
||||||
|
assert len(res.json()["songs"]) == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_gig_propose_backfill_offset_survives_stakes(client, meta_db):
|
||||||
|
# 4 qualifying + 1 near-bar stake, size 5: the stake must not shift the
|
||||||
|
# qualifying backfill window past eligible songs.
|
||||||
|
for i in range(4):
|
||||||
|
meta_db.add(f"q{i}.feedpak", 0, 0.9, genre="Reggae", arrangements=LEAD)
|
||||||
|
meta_db.add("near.feedpak", 0, 0.7, genre="Reggae", arrangements=LEAD)
|
||||||
|
res = client.post("/api/plugins/career/gigs/propose",
|
||||||
|
json={"instrument": "guitar", "genre": "Reggae", "size": 5})
|
||||||
|
files = [s["filename"] for s in res.json()["songs"]]
|
||||||
|
assert len(files) == 5 and len(set(files)) == 5
|
||||||
|
assert "near.feedpak" in files
|
||||||
|
|||||||
Reference in New Issue
Block a user