Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions src/mixmaster/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
24 changes: 19 additions & 5 deletions src/mixmaster/sequence.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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} "
Expand Down
23 changes: 23 additions & 0 deletions tests/test_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
47 changes: 47 additions & 0 deletions tests/test_sequence.py
Original file line number Diff line number Diff line change
Expand Up @@ -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