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).
113 lines
4.6 KiB
Python
113 lines
4.6 KiB
Python
"""Extreme test battery for source-track normalization (#768).
|
||
|
||
YouTube/streaming sources carry "Artist - Song" titles and "Official Artist"/
|
||
"Artist - Topic"/"ArtistVEVO" artist names; the library has clean metadata, so
|
||
matching fails and tracks are reported missing. These helpers strip the
|
||
decoration. The batteries below pin both the positives (must clean) and the
|
||
negatives (must NOT mangle real titles/artists).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import pytest
|
||
|
||
from core.text.source_title import (
|
||
canonical_source_track,
|
||
clean_source_artist,
|
||
strip_artist_prefix,
|
||
)
|
||
|
||
|
||
# ── clean_source_artist ───────────────────────────────────────────────────
|
||
|
||
@pytest.mark.parametrize("raw,expected", [
|
||
("Official Arctic Monkeys", "Arctic Monkeys"),
|
||
("The Official Weeknd", "Weeknd"),
|
||
("Arctic Monkeys - Topic", "Arctic Monkeys"),
|
||
("Coldplay - Topic", "Coldplay"),
|
||
("ColdplayVEVO", "Coldplay"),
|
||
("Coldplay VEVO", "Coldplay"),
|
||
("EminemVEVO", "Eminem"),
|
||
(" Official Radiohead ", "Radiohead"),
|
||
])
|
||
def test_clean_source_artist_strips_decoration(raw, expected):
|
||
assert clean_source_artist(raw) == expected
|
||
|
||
|
||
@pytest.mark.parametrize("raw", [
|
||
"Arctic Monkeys", # already clean
|
||
"Coldplay",
|
||
"Twenty One Pilots",
|
||
"Death",
|
||
"U2",
|
||
"AJR", # would be emptied by a naive vevo/official strip
|
||
"",
|
||
])
|
||
def test_clean_source_artist_leaves_clean_names(raw):
|
||
assert clean_source_artist(raw) == raw
|
||
|
||
|
||
def test_clean_source_artist_never_empties():
|
||
# Pathological: artist that is ONLY decoration must not become "".
|
||
assert clean_source_artist("VEVO") == "VEVO"
|
||
assert clean_source_artist("Official ") == "Official"
|
||
|
||
|
||
# ── strip_artist_prefix ───────────────────────────────────────────────────
|
||
|
||
@pytest.mark.parametrize("title,artist,expected", [
|
||
("Arctic Monkeys - Do I Wanna Know?", "Arctic Monkeys", "Do I Wanna Know?"),
|
||
("Death - Pull the Plug", "Death", "Pull the Plug"),
|
||
("Coldplay – Yellow", "Coldplay", "Yellow"), # en dash
|
||
("Coldplay — Yellow", "Coldplay", "Yellow"), # em dash
|
||
("Eminem: Lose Yourself", "Eminem", "Lose Yourself"), # colon
|
||
("Daft Punk | Get Lucky", "Daft Punk", "Get Lucky"), # pipe
|
||
("ARCTIC MONKEYS - 505", "arctic monkeys", "505"), # case-fold
|
||
])
|
||
def test_strip_artist_prefix_strips_when_prefix_is_artist(title, artist, expected):
|
||
assert strip_artist_prefix(title, artist) == expected
|
||
|
||
|
||
@pytest.mark.parametrize("title,artist", [
|
||
("Marvin Gaye", "Charlie Puth"), # title is not "artist - ..."
|
||
("Do I Wanna Know?", "Arctic Monkeys"), # already clean
|
||
("Self-Titled", "Whoever"), # hyphen w/o spaces — not a sep
|
||
("Jay-Z Anthem", "Somebody"), # hyphen inside a word
|
||
("Song - Live", "Coldplay"), # prefix "Song" != artist
|
||
("Stay With Me", "Sam Smith"),
|
||
("", "Arctic Monkeys"),
|
||
("Arctic Monkeys -", "Arctic Monkeys"), # nothing after sep -> unchanged
|
||
])
|
||
def test_strip_artist_prefix_leaves_others_untouched(title, artist):
|
||
assert strip_artist_prefix(title, artist) == title
|
||
|
||
|
||
def test_strip_only_first_separator():
|
||
# "Artist - Song - Remix" -> strip only the leading artist segment.
|
||
assert strip_artist_prefix("Gorillaz - Feel Good Inc - Remix", "Gorillaz") == "Feel Good Inc - Remix"
|
||
|
||
|
||
# ── canonical_source_track (combined) ─────────────────────────────────────
|
||
|
||
def test_canonical_handles_youtube_channel_and_prefix():
|
||
# The reported case: channel-name artist + "Artist - Title" title.
|
||
title, artist = canonical_source_track(
|
||
"Arctic Monkeys - Do I Wanna Know?", "Official Arctic Monkeys",
|
||
)
|
||
assert title == "Do I Wanna Know?"
|
||
assert artist == "Arctic Monkeys"
|
||
|
||
|
||
def test_canonical_strips_prefix_using_raw_artist_when_clean_differs():
|
||
# Title prefixed with the channel-style raw artist itself.
|
||
title, artist = canonical_source_track(
|
||
"Official Arctic Monkeys - 505", "Official Arctic Monkeys",
|
||
)
|
||
# cleaned artist is "Arctic Monkeys"; raw prefix "Official Arctic Monkeys"
|
||
# also stripped via the raw-artist fallback.
|
||
assert title == "505"
|
||
assert artist == "Arctic Monkeys"
|
||
|
||
|
||
def test_canonical_noop_on_clean_input():
|
||
assert canonical_source_track("Yellow", "Coldplay") == ("Yellow", "Coldplay")
|