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).
74 lines
3.1 KiB
Python
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"]
|