soulsync/core/text/source_title.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

105 lines
4.3 KiB
Python

"""Normalize streaming/YouTube-style source track metadata for matching.
Issue #768: source playlists — especially ones seeded from YouTube — carry
video-style metadata: the title is ``"Artist - Song"`` and the artist is a
channel name like ``"Official Arctic Monkeys"``, ``"Arctic Monkeys - Topic"``,
or ``"ColdplayVEVO"``. The library/media-server side has the clean ``"Song"`` /
``"Arctic Monkeys"``. Both matching paths (the sync confidence scorer and the
playlist-editor reconcile) then fail to pair them — the track is reported
"not matched" / shows up as an orphan "extra" even though it exists.
These helpers strip that channel/video decoration so the cleaned source can be
compared against the clean library metadata. Pure, no I/O.
Conservative by construction:
- ``strip_artist_prefix`` removes a leading ``"<artist><sep>"`` only when the
prefix EQUALS the artist we're matching against. So ``"Death - Pull the
Plug"`` by ``"Death"`` is helped, while ``"Marvin Gaye"`` by Charlie Puth
(title is not ``"Charlie Puth - ..."``) is left untouched, and a hyphenated
word like ``"Self-Titled"`` is never split (a separator needs surrounding
whitespace, or a colon).
- ``clean_source_artist`` only removes well-known channel decorations.
Both are intended to be applied as ADDITIONAL match candidates (best-of), so
an over-eager strip can only add a comparison, never remove the original.
"""
from __future__ import annotations
import re
from core.text.normalize import normalize_for_comparison
# Artist/title separator: a dash/pipe/tilde flanked by whitespace, OR a colon
# (with optional trailing space). Whitespace-flanking keeps "Self-Titled" and
# "Jay-Z" intact while still splitting "Artist - Title".
_SEP_SPLIT = re.compile(r"\s+[-–—|~]\s+|\s*:\s+")
# YouTube auto-generated artist channel: "Arctic Monkeys - Topic".
_TOPIC_SUFFIX = re.compile(r"\s*-\s*topic\s*$", re.IGNORECASE)
# "Official " / "The Official " channel prefix.
_OFFICIAL_PREFIX = re.compile(r"^\s*(?:the\s+)?official\s+", re.IGNORECASE)
# Trailing VEVO, attached ("ColdplayVEVO") or spaced ("Coldplay VEVO").
_VEVO_SUFFIX = re.compile(r"\s*vevo\s*$", re.IGNORECASE)
def clean_source_artist(artist: str) -> str:
"""Strip well-known streaming-channel decoration from an artist name.
``"Official Arctic Monkeys"`` → ``"Arctic Monkeys"``;
``"Arctic Monkeys - Topic"`` → ``"Arctic Monkeys"``;
``"ColdplayVEVO"`` → ``"Coldplay"``. Returns the input unchanged when
nothing matches, and never returns empty for non-empty input."""
if not artist:
return artist
s = artist.strip()
topic = _TOPIC_SUFFIX.sub("", s).strip()
if topic and topic != s:
s = topic
official = _OFFICIAL_PREFIX.sub("", s).strip()
if official:
s = official
# Only strip VEVO if at least 2 chars of name remain (don't empty "VEVO").
vevo = _VEVO_SUFFIX.sub("", s).strip()
if len(vevo) >= 2 and vevo != s:
s = vevo
return s or artist
def strip_artist_prefix(title: str, artist: str) -> str:
"""Remove a leading ``"<artist><separator>"`` from ``title`` when the prefix
equals ``artist`` (accent/case-folded). Otherwise return ``title`` unchanged.
``("Arctic Monkeys - Do I Wanna Know?", "Arctic Monkeys")`` → ``"Do I Wanna
Know?"``. Never returns an empty string."""
if not title or not artist:
return title
na = normalize_for_comparison(artist)
if not na:
return title
parts = _SEP_SPLIT.split(title, maxsplit=1)
if len(parts) == 2:
left, right = parts
right = right.strip()
if right and normalize_for_comparison(left) == na:
return right
return title
def canonical_source_track(title: str, artist: str) -> tuple[str, str]:
"""Best-effort clean (title, artist) for matching a streaming/YouTube source
against clean library metadata. Cleans the artist first, then strips a
leading artist prefix from the title using EITHER the cleaned or the raw
artist (YouTube titles prepend the real artist, not the channel name)."""
cleaned_artist = clean_source_artist(artist)
new_title = strip_artist_prefix(title, cleaned_artist)
if new_title == title and cleaned_artist != artist:
new_title = strip_artist_prefix(title, artist)
return new_title, cleaned_artist
__all__ = ["clean_source_artist", "strip_artist_prefix", "canonical_source_track"]