soulsync/core/sync/playlist_edit.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

74 lines
3.1 KiB
Python

"""Pure planners for sync-editor playlist mutations (#768 Bug C).
The sync editor's "Find & add" and remove actions rewrite the whole server
playlist from a flat list of track IDs (Subsonic/Navidrome + Jellyfin have no
position-level ops). Two bugs lived in the inline endpoint logic:
* **Duplicate on manual match.** "Find & add" always *inserted* the chosen
track — but when the user is matching an UNMATCHED source to a server track
that's already in the playlist (an orphan "extra"), the intent is to LINK
them, not add a second copy. Each attempt appended another duplicate
(positions 72, 73, 74…). ``plan_playlist_add`` skips the insert when it's a
link to an already-present track (the caller still persists the override).
* **Delete removes ALL copies.** The inline remove filtered out *every* entry
with the target ID. With duplicates present, deleting one removed them all.
``remove_one_occurrence`` drops a single entry (duplicates are the same
track, so removing any one is correct).
Pure, no I/O — the caller fetches the current track-id list and applies the
returned plan to the media-server client.
"""
from __future__ import annotations
from typing import List, Optional, Tuple
def plan_playlist_add(
current_ids: List[str],
track_id: str,
*,
is_link: bool,
position: Optional[int] = None,
) -> dict:
"""Plan a "Find & add" against a flat track-id playlist.
``is_link`` is True when the add carries a ``source_track_id`` (i.e. the
user is matching an unmatched source to this server track). In that case,
if the track is ALREADY in the playlist, return ``should_insert=False`` so
the caller only records the override and never duplicates it.
Returns ``{'should_insert': bool, 'new_ids': [...]}``. ``new_ids`` equals
the input (stringified) when no insert is needed."""
tid = str(track_id)
current = [str(t) for t in current_ids]
if is_link and tid in current:
return {"should_insert": False, "new_ids": current}
pos = len(current) if position is None else max(0, min(int(position), len(current)))
new_ids = current[:pos] + [tid] + current[pos:]
return {"should_insert": True, "new_ids": new_ids}
def remove_one_occurrence(
track_ids: List[str],
target_id: str,
position: Optional[int] = None,
) -> Tuple[List[str], bool]:
"""Remove a SINGLE occurrence of ``target_id`` from a flat id list.
If ``position`` is given and the id there matches, that exact entry is
removed (so the user removes the row they clicked); otherwise the first
matching id is removed. Returns ``(new_ids, removed)``. ``removed`` is
False when the id isn't present (caller should 404)."""
target = str(target_id)
ids = [str(t) for t in track_ids]
if position is not None and 0 <= position < len(ids) and ids[position] == target:
return ids[:position] + ids[position + 1:], True
for idx, tid in enumerate(ids):
if tid == target:
return ids[:idx] + ids[idx + 1:], True
return ids, False
__all__ = ["plan_playlist_add", "remove_one_occurrence"]