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).
122 lines
5.4 KiB
Python
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"
|