diff --git a/src/mixmaster/db.py b/src/mixmaster/db.py index a6dea23..c85390d 100644 --- a/src/mixmaster/db.py +++ b/src/mixmaster/db.py @@ -442,7 +442,13 @@ def tracks_for_sequence(self) -> list[dict]: Numeric axes follow the same rule as classify: FreqBlog first, with Musicae filling in gaps. key_confidence only exists in the FreqBlog raw payload, so raw is passed through as-is (parsed by - the caller — cheaper than a migration to add a column).""" + the caller — cheaper than a migration to add a column). + + track_class is a LEFT JOIN: a track inserted outside sync (e.g. + playlist-only) that hasn't been through classify yet must not + drop out of the pool when its enrichment data is already there — + genre_bucket/dj_slots come back NULL and build_pool treats them + as unclassified.""" rows = self.conn.execute( """SELECT t.spotify_id, t.name, t.duration_ms, t.isrc, a.name AS artist, a.country AS artist_country, @@ -454,7 +460,7 @@ def tracks_for_sequence(self) -> list[dict]: m.scores AS musicae_scores, m.genres AS musicae_genres, m.raw AS musicae_raw FROM tracks t - JOIN track_class tc ON tc.track_id = t.spotify_id + LEFT JOIN track_class tc ON tc.track_id = t.spotify_id LEFT JOIN enrichment e ON e.track_id = t.spotify_id LEFT JOIN musicae m ON m.track_id = t.spotify_id LEFT JOIN track_artists ta diff --git a/src/mixmaster/sequence.py b/src/mixmaster/sequence.py index 360030f..8f80b12 100644 --- a/src/mixmaster/sequence.py +++ b/src/mixmaster/sequence.py @@ -122,14 +122,22 @@ def build_pool( or not _CAMELOT_RE.match(r["camelot"]): if require_axes: continue - track_slots = json.loads(r["dj_slots"]) + # NULL bucket/slots = no track_class row yet (a playlist-only track + # that hasn't been through classify). Treated as unclassified: it + # passes when no slot/bucket filter is active (resequence and the + # draft-track pool run with filters off), but an explicit filter + # still excludes it — unknown never matches a requested value. + track_slots = json.loads(r["dj_slots"]) if r["dj_slots"] else [] if slots and not set(slots) & set(track_slots): continue raw_bucket = r["genre_bucket"] # Multiple buckets are stored as a JSON array; pre-reclassification # rows are still a plain string - track_buckets = (json.loads(raw_bucket) - if raw_bucket.startswith("[") else [raw_bucket]) + if raw_bucket is None: + track_buckets = [] + else: + track_buckets = (json.loads(raw_bucket) + if raw_bucket.startswith("[") else [raw_bucket]) if countries: # Prefer the artist's country (MusicBrainz) — the ISRC # registration country follows the distribution path (e.g. @@ -262,7 +270,12 @@ def transition_cost(a: SeqTrack, b: SeqTrack, rules: dict) -> float | None: aff = rules["bucket_affinity"] a_set, b_set = set(a.buckets), set(b.buckets) - if a_set & b_set: # sharing a parent bucket (both house, etc.) is free + if not a_set or not b_set: + # Unclassified track (no track_class row) — unknown isn't a clash, + # so use the mild adjacent penalty instead of the "other" one + # (same neutral-when-unknown convention as era/genre distance) + bucket_pen = aff["adjacent_penalty"] + elif a_set & b_set: # sharing a parent bucket (both house, etc.) is free bucket_pen = 0.0 elif any(y in aff["groups"].get(x, ()) for x in a_set for y in b_set) \ or any(x in aff["groups"].get(y, ()) for x in a_set for y in b_set): @@ -534,7 +547,8 @@ def render_report( warn = {"boost": " ⚠boost", "bpm": " ⚠BPM"}.get(x["warn"], "") move = (f"{x['camelot']} {x['move']}({x['key_score']:.2f}) " f"Δbpm {x['bpm_pct']:.1f}%{warn}") - genres = ",".join(sorted(t.genres)[:2]) or t.buckets[0] + genres = (",".join(sorted(t.genres)[:2]) + or (t.buckets[0] if t.buckets else "?")) mark = "★" if t.id in require_ids else " " # ★ = draft (required) track lines.append( f"{mark}{pos:>2}. [{t.camelot:>3} {t.bpm:5.1f} E{t.energy:.2f} " diff --git a/tests/test_db.py b/tests/test_db.py index a46cf50..9b36bc4 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -89,3 +89,26 @@ def test_set_track_artists_replaces(db): def test_migration_reaches_latest_version(db): version = db.conn.execute("PRAGMA user_version").fetchone()[0] assert version == LATEST_SCHEMA_VERSION + + +def test_tracks_for_sequence_includes_unclassified_tracks(db): + # Regression (issue #4): a track inserted outside sync (e.g. + # playlist-only) that has enrichment but hasn't been through classify + # must still be in the sequencing pool — bucket/slots come back NULL. + db.upsert_track(**_track("classified")) + db.upsert_track(**_track("unclassified")) + for tid in ("classified", "unclassified"): + db.store_enrichment( + tid, "freqblog", {"bpm": 120.0, "camelot": "8A", "energy": 0.7}, + raw="{}", fetched_at="2026-07-13T00:00:00Z", + ) + db.replace_track_class( + [("classified", '["house"]', '["peak"]')], "2026-07-13T00:00:00Z" + ) + + rows = {r["spotify_id"]: r for r in db.tracks_for_sequence()} + assert set(rows) == {"classified", "unclassified"} + assert rows["classified"]["genre_bucket"] == '["house"]' + assert rows["unclassified"]["genre_bucket"] is None + assert rows["unclassified"]["dj_slots"] is None + assert rows["unclassified"]["bpm"] == 120.0 # enrichment still joined diff --git a/tests/test_sequence.py b/tests/test_sequence.py index 29fe9b2..d85fcaa 100644 --- a/tests/test_sequence.py +++ b/tests/test_sequence.py @@ -637,3 +637,50 @@ def test_repo_default_rules_load_and_are_consistent(): assert rules["constraints"]["max_artist_run"] == 3 # the folder-substitute prefix — an empty value would scatter generated playlists assert rules["set"]["playlist_prefix"] + + +# ---- unclassified tracks (issue #4: no track_class row) ---- + +def test_build_pool_includes_unclassified_when_no_filters(): + # A playlist-only track that never went through classify has NULL + # bucket/slots — with filters off (resequence, draft pool) it must + # still enter the pool as long as its mixing axes are present. + rows = [ + _row("classified"), + _row("unclassified", genre_bucket=None, dj_slots=None), + ] + pool = build_pool(rows, RULES) # RULES pool defaults: slots=[], buckets=[] + assert [t.id for t in pool] == ["classified", "unclassified"] + assert pool[1].buckets == [] + assert pool[1].slots == [] + + +def test_build_pool_unclassified_excluded_by_explicit_filters(): + rows = [_row("unclassified", genre_bucket=None, dj_slots=None)] + assert build_pool(rows, RULES, slots=["peak"]) == [] + assert build_pool(rows, RULES, buckets=["house"]) == [] + + +def test_transition_cost_unknown_bucket_gets_adjacent_penalty(): + a = track("a", buckets=[]) + b = track("b", camelot="8A", buckets=["house"]) + base = transition_cost(track("a2", buckets=["house"]), + track("b2", camelot="8A", buckets=["house"]), RULES) + unknown = transition_cost(a, b, RULES) + aff = RULES["bucket_affinity"] + assert unknown == pytest.approx(base + aff["adjacent_penalty"]) + + +def test_sequence_and_report_handle_unclassified_tracks(): + from mixmaster.sequence import render_report + + pool = [ + track(f"t{i}", camelot="8A", bpm=120 + i, energy=0.7, + artist=f"ar{i}", buckets=[], genres=frozenset()) + for i in range(4) + ] + seq = sequence_tracks(pool, 4, RULES, RULES["curves"]["flat"], + rng=random.Random(1)) + assert len(seq) == 4 + report = render_report(seq, RULES) # buckets=[] must not crash the report + assert "?" in report