soulsync/tests/test_playlist_reconcile.py
BoulderBadgeDad bba0836324 Fix #768: playlist sync editor refusing to match certain tracks
Three compounding bugs hit tracks whose source metadata is YouTube/streaming-
shaped — title "Artist - Song", artist "Official Artist"/"Artist - Topic"/
"ArtistVEVO" (reported: "Arctic Monkeys - Do I Wanna Know?" by "Official Arctic
Monkeys"). Server-agnostic — affects Plex/Jellyfin/Navidrome, not just the
reporter's Navidrome.

Bug A — the match fails. The confidence scorer and the editor's reconcile both
compared the raw "Artist - Song" title against the library's clean "Song"; the
length-ratio penalty + floor drove it to ~0.18 (NO-MATCH), so the track showed
unmatched while its server copy showed as an orphan "extra". New pure
core/text/source_title.py (clean_source_artist / strip_artist_prefix /
canonical_source_track) strips the channel/video decoration, applied at BOTH
matching seams: services/sync_service._find_track_in_media_server (tries raw
then canonical, keeps the best) and the editor reconcile. Conservative: a title
prefix is stripped only when it equals the artist, so "Self-Titled", "Jay-Z",
and "Marvin Gaye" (by another artist) are untouched, and the canonical form is
an additional best-of candidate so it can only help.

Bug B — manual matches never persisted. get_server_playlist_tracks built the
per-source entry WITHOUT source_track_id, so "Find & add" posted an empty id
and _persist_find_and_add_match returned early. The match reverted to "extra"
on reload and re-adding looped. The editor's 3-pass matcher is now lifted to a
pure, tested core.sync.playlist_reconcile.reconcile_playlist that includes
source_track_id (the frontend at pages-extra.js:1836 already reads + sends it).

Bug C — manual match duplicated + delete wiped all copies. "Find & add" always
inserted, so linking a source to an already-present server track appended a
duplicate (pos 72, 73...); remove filtered out EVERY entry with the target id.
New pure core.sync.playlist_edit (plan_playlist_add: link-don't-duplicate when
the target is already present; remove_one_occurrence: drop a single copy) wired
into the Plex/Jellyfin/Navidrome add + remove branches.

Tests (extreme): tests/test_source_title.py (35), tests/test_playlist_reconcile.py
(11 — incl. the reported case, parity for override/exact/fuzzy/extra, and
duplicate-server handling), tests/test_playlist_edit.py (12). 286 matching/sync
tests still pass.

Caveats: the sync_service change and the add/remove/editor endpoints are
read-verified, not executed against a live media server (none in CI). The pure
cores they call are exhaustively unit-tested; output-shape parity of the
reconcile lift is covered. Delete removes the first matching copy (duplicates
are identical, so harmless).
2026-06-02 10:16:21 -07:00

122 lines
5.4 KiB
Python

"""Extreme battery for the playlist sync-editor reconcile (#768).
Covers: the reported YouTube failure (Bug A — "Artist - Title" source matching
its clean server copy instead of showing unmatched + orphan extra), the
source_track_id echo (Bug B), and parity with the original three-pass behavior
(override → exact → fuzzy → extra), plus duplicate-server-track handling.
"""
from __future__ import annotations
from core.sync.playlist_reconcile import norm_title, reconcile_playlist
def _src(name, artist, sid="", **kw):
return {"name": name, "artist": artist, "source_track_id": sid, **kw}
def _svr(title, artist, tid):
return {"title": title, "artist": artist, "id": tid, "ratingKey": tid}
def _status(combined):
return [(c["match_status"],
(c["source_track"] or {}).get("name"),
(c["server_track"] or {}).get("title")) for c in combined]
# ── Bug A: the reported YouTube case ──────────────────────────────────────
def test_youtube_artist_title_source_matches_clean_server_track():
source = [_src("Arctic Monkeys - Do I Wanna Know?", "Official Arctic Monkeys", "sp1")]
server = [_svr("Do I Wanna Know?", "Arctic Monkeys", "nv72")]
combined = reconcile_playlist(source, server)
assert len(combined) == 1
assert combined[0]["match_status"] == "matched"
assert combined[0]["server_track"]["id"] == "nv72"
# ...and the server track is NOT left as an orphan extra.
assert not any(c["match_status"] == "extra" for c in combined)
def test_youtube_match_does_not_leave_unmatched_or_extra():
# Before the fix this produced one 'missing' + one 'extra'.
source = [_src("The Killers - Mr. Brightside", "The KillersVEVO", "sp2")]
server = [_svr("Mr. Brightside", "The Killers", "nv5")]
statuses = [c["match_status"] for c in reconcile_playlist(source, server)]
assert statuses == ["matched"]
# ── Bug B: source_track_id is echoed back ─────────────────────────────────
def test_source_track_id_present_on_matched_entry():
source = [_src("Do I Wanna Know?", "Arctic Monkeys", "spotify:track:abc")]
server = [_svr("Do I Wanna Know?", "Arctic Monkeys", "nv1")]
combined = reconcile_playlist(source, server)
assert combined[0]["source_track"]["source_track_id"] == "spotify:track:abc"
def test_source_track_id_present_on_missing_entry():
# A genuinely-missing source must still carry its id so it can be
# manually matched and persisted (the #768 manual-match loop).
source = [_src("Some Obscure B-Side", "Some Artist", "spotify:track:xyz")]
server = [_svr("Completely Different", "Other Artist", "nv9")]
combined = reconcile_playlist(source, server)
missing = [c for c in combined if c["match_status"] == "missing"]
assert missing and missing[0]["source_track"]["source_track_id"] == "spotify:track:xyz"
# ── parity: override / exact / fuzzy / extra ──────────────────────────────
def test_override_pair_wins_first():
source = [_src("Anything", "Whoever", "s1")]
server = [_svr("Totally Different Title", "Nobody", "nvX")]
combined = reconcile_playlist(source, server, override_pairs={0: 0})
assert combined[0]["match_status"] == "matched"
assert combined[0]["confidence"] == 1.0
assert combined[0].get("override") is True
def test_exact_normalized_match_strips_feat():
source = [_src("Stay (feat. Justin Bieber)", "The Kid LAROI", "s1")]
server = [_svr("Stay", "The Kid LAROI", "nv1")]
assert reconcile_playlist(source, server)[0]["match_status"] == "matched"
def test_fuzzy_match_above_threshold():
source = [_src("Mr Brightside", "The Killers", "s1")]
server = [_svr("Mr. Brightside", "The Killers", "nv1")]
c = reconcile_playlist(source, server)[0]
assert c["match_status"] == "matched"
assert c["confidence"] >= 0.75
def test_truly_absent_track_is_missing_and_unrelated_server_is_extra():
source = [_src("Nonexistent Song", "Ghost Artist", "s1")]
server = [_svr("Yellow", "Coldplay", "nv1")]
statuses = sorted(c["match_status"] for c in reconcile_playlist(source, server))
assert statuses == ["extra", "missing"]
def test_each_server_track_claimed_once_no_double_match():
# Two identical source rows must not both claim the single server track.
source = [_src("Yellow", "Coldplay", "s1"), _src("Yellow", "Coldplay", "s2")]
server = [_svr("Yellow", "Coldplay", "nv1")]
combined = reconcile_playlist(source, server)
matched = [c for c in combined if c["match_status"] == "matched"]
missing = [c for c in combined if c["match_status"] == "missing"]
assert len(matched) == 1 and len(missing) == 1
def test_duplicate_server_tracks_one_matched_one_extra():
# The #768 duplicate scenario: two copies of the same track on the server.
source = [_src("Do I Wanna Know?", "Arctic Monkeys", "s1")]
server = [_svr("Do I Wanna Know?", "Arctic Monkeys", "nv72"),
_svr("Do I Wanna Know?", "Arctic Monkeys", "nv73")]
combined = reconcile_playlist(source, server)
assert sorted(c["match_status"] for c in combined) == ["extra", "matched"]
def test_norm_title_helper_parity():
assert norm_title("Stay (feat. X)") == "stay"
assert norm_title("Song (2019 Remaster)") == "song"
assert norm_title("Album (Deluxe Edition)") == "album"