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).
This commit is contained in:
parent
174513d351
commit
bba0836324
8 changed files with 761 additions and 128 deletions
74
core/sync/playlist_edit.py
Normal file
74
core/sync/playlist_edit.py
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
"""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"]
|
||||||
186
core/sync/playlist_reconcile.py
Normal file
186
core/sync/playlist_reconcile.py
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
"""Reconcile a source playlist against a media-server playlist (pure).
|
||||||
|
|
||||||
|
Lifted verbatim from the inline three-pass matcher in
|
||||||
|
``web_server.get_server_playlist_tracks`` so it can be unit-tested and so the
|
||||||
|
two #768 fixes live in importable, covered code:
|
||||||
|
|
||||||
|
Pass 0 user-confirmed overrides (``sync_match_cache``), applied first.
|
||||||
|
Pass 1 exact normalized-title match.
|
||||||
|
Pass 2 fuzzy match on ``"artist title"`` (SequenceMatcher >= 0.75).
|
||||||
|
Extra server tracks no source claimed -> ``match_status='extra'``.
|
||||||
|
|
||||||
|
Two bug fixes over the original inline version:
|
||||||
|
|
||||||
|
* #768 Bug A — the source side is YouTube/streaming-shaped (title
|
||||||
|
``"Artist - Song"``, artist ``"Official Artist"``). The original passes
|
||||||
|
compared the raw title, so ``"Arctic Monkeys - Do I Wanna Know?"`` never
|
||||||
|
matched the library's ``"Do I Wanna Know?"`` and the track showed as
|
||||||
|
unmatched while its server copy showed as an orphan "extra". We now also try
|
||||||
|
the canonicalized source title/artist (see ``core.text.source_title``).
|
||||||
|
|
||||||
|
* #768 Bug B — the original built the per-source ``src_entry`` WITHOUT
|
||||||
|
``source_track_id``, so the editor UI never received it; "Find & add" then
|
||||||
|
posted an empty id and the manual match was never persisted (it reverted to
|
||||||
|
"extra" on reload, and re-adding duplicated the track). ``src_entry`` now
|
||||||
|
carries ``source_track_id``.
|
||||||
|
|
||||||
|
Pure, no I/O. ``override_pairs`` (``{source_idx: server_idx}``) is computed by
|
||||||
|
the caller via ``core.sync.match_overrides.resolve_match_overrides`` so the DB
|
||||||
|
lookup stays out of this module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from core.text.source_title import canonical_source_track
|
||||||
|
|
||||||
|
_FUZZY_THRESHOLD = 0.75
|
||||||
|
|
||||||
|
_FEAT_RE = re.compile(r'\s*[\(\[](?:feat|ft)\.?[^\)\]]*[\)\]]', re.IGNORECASE)
|
||||||
|
_REMASTER_RE = re.compile(
|
||||||
|
r'\s*[\(\[](?:\d{4}\s+)?remaster(?:ed)?(?:\s+version)?\s*[\)\]]', re.IGNORECASE)
|
||||||
|
_EDITION_RE = re.compile(
|
||||||
|
r'\s*[\(\[](?:deluxe|special|anniversary|legacy|expanded|limited)(?:\s+edition)?\s*[\)\]]',
|
||||||
|
re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def norm_title(t: str) -> str:
|
||||||
|
"""Strip feat./ft., remaster, and edition qualifiers for comparison only.
|
||||||
|
Byte-faithful to web_server's inline ``_norm_title``."""
|
||||||
|
t = _FEAT_RE.sub('', t or '')
|
||||||
|
t = _REMASTER_RE.sub('', t)
|
||||||
|
t = _EDITION_RE.sub('', t)
|
||||||
|
return t.lower().strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _src_entry(src: Dict[str, Any], position_fallback: int) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
'name': src.get('name', ''),
|
||||||
|
'artist': src.get('artist', ''),
|
||||||
|
'album': src.get('album', ''),
|
||||||
|
'image_url': src.get('image_url', ''),
|
||||||
|
'duration_ms': src.get('duration_ms', 0),
|
||||||
|
'position': src.get('position', position_fallback),
|
||||||
|
# #768 Bug B: echo the source id back so the editor can persist a
|
||||||
|
# manual "Find & add" override against it.
|
||||||
|
'source_track_id': src.get('source_track_id', '') or '',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolved_artist(src: Dict[str, Any]) -> str:
|
||||||
|
"""Artist string, falling back to the first of an ``artists`` list."""
|
||||||
|
artist = src.get('artist', '')
|
||||||
|
if not artist and src.get('artists'):
|
||||||
|
a = src['artists'][0] if src['artists'] else ''
|
||||||
|
artist = a.get('name', a) if isinstance(a, dict) else str(a)
|
||||||
|
return artist or ''
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile_playlist(
|
||||||
|
source_tracks: List[Dict[str, Any]],
|
||||||
|
server_tracks: List[Dict[str, Any]],
|
||||||
|
override_pairs: Optional[Dict[int, int]] = None,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Return the combined matched/missing/extra view (list of dicts).
|
||||||
|
|
||||||
|
Each combined entry has ``source_track``, ``server_track``,
|
||||||
|
``match_status`` ('matched'|'missing'|'extra'), ``confidence``, and
|
||||||
|
``override: True`` on override hits."""
|
||||||
|
override_pairs = override_pairs or {}
|
||||||
|
combined: List[Dict[str, Any]] = []
|
||||||
|
used_server_indices: set[int] = set()
|
||||||
|
unmatched_source: List[tuple[int, Dict[str, Any], str]] = []
|
||||||
|
|
||||||
|
# Precompute normalized server titles once.
|
||||||
|
server_norm = [norm_title(svr.get('title', '')) for svr in server_tracks]
|
||||||
|
|
||||||
|
for i, src in enumerate(source_tracks):
|
||||||
|
src_artist = _resolved_artist(src)
|
||||||
|
src_name = src.get('name', '')
|
||||||
|
src_entry = _src_entry({**src, 'artist': src_artist}, i)
|
||||||
|
|
||||||
|
# Pass 0: user-confirmed override.
|
||||||
|
if i in override_pairs:
|
||||||
|
j = override_pairs[i]
|
||||||
|
used_server_indices.add(j)
|
||||||
|
combined.append({
|
||||||
|
'source_track': src_entry,
|
||||||
|
'server_track': server_tracks[j],
|
||||||
|
'match_status': 'matched',
|
||||||
|
'confidence': 1.0,
|
||||||
|
'override': True,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Pass 1: exact normalized-title match — try the raw source title AND
|
||||||
|
# the canonicalized one (strips "Artist - " prefix / channel artist).
|
||||||
|
canon_title, _canon_artist = canonical_source_track(src_name, src_artist)
|
||||||
|
candidates = {norm_title(src_name), norm_title(canon_title)}
|
||||||
|
best_idx = -1
|
||||||
|
for j, svr_norm in enumerate(server_norm):
|
||||||
|
if j in used_server_indices:
|
||||||
|
continue
|
||||||
|
if svr_norm in candidates:
|
||||||
|
best_idx = j
|
||||||
|
break
|
||||||
|
|
||||||
|
if best_idx >= 0:
|
||||||
|
used_server_indices.add(best_idx)
|
||||||
|
combined.append({
|
||||||
|
'source_track': src_entry,
|
||||||
|
'server_track': server_tracks[best_idx],
|
||||||
|
'match_status': 'matched',
|
||||||
|
'confidence': 1.0,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
idx = len(combined)
|
||||||
|
combined.append({
|
||||||
|
'source_track': src_entry,
|
||||||
|
'server_track': None,
|
||||||
|
'match_status': 'missing',
|
||||||
|
'confidence': 0.0,
|
||||||
|
})
|
||||||
|
# Carry the canonical artist for the fuzzy pass.
|
||||||
|
unmatched_source.append((idx, src_entry, _canon_artist or src_artist))
|
||||||
|
|
||||||
|
# Pass 2: fuzzy match on remaining unmatched source tracks. Build the key
|
||||||
|
# from the canonicalized title/artist so YouTube-shaped sources can pair.
|
||||||
|
for combo_idx, src_entry, canon_artist in unmatched_source:
|
||||||
|
canon_title, _ = canonical_source_track(src_entry['name'], src_entry['artist'])
|
||||||
|
src_key = f"{canon_artist} {norm_title(canon_title)}".strip().lower()
|
||||||
|
best_score = 0.0
|
||||||
|
best_j = -1
|
||||||
|
for j, svr in enumerate(server_tracks):
|
||||||
|
if j in used_server_indices:
|
||||||
|
continue
|
||||||
|
svr_key = f"{svr.get('artist', '')} {norm_title(svr.get('title', ''))}".strip().lower()
|
||||||
|
score = SequenceMatcher(None, src_key, svr_key).ratio()
|
||||||
|
if score > best_score and score >= _FUZZY_THRESHOLD:
|
||||||
|
best_score = score
|
||||||
|
best_j = j
|
||||||
|
if best_j >= 0:
|
||||||
|
used_server_indices.add(best_j)
|
||||||
|
combined[combo_idx] = {
|
||||||
|
'source_track': src_entry,
|
||||||
|
'server_track': server_tracks[best_j],
|
||||||
|
'match_status': 'matched',
|
||||||
|
'confidence': round(best_score, 3),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extra: server tracks no source claimed.
|
||||||
|
for j, svr in enumerate(server_tracks):
|
||||||
|
if j not in used_server_indices:
|
||||||
|
combined.append({
|
||||||
|
'source_track': None,
|
||||||
|
'server_track': svr,
|
||||||
|
'match_status': 'extra',
|
||||||
|
'confidence': 0.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
return combined
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["reconcile_playlist", "norm_title"]
|
||||||
105
core/text/source_title.py
Normal file
105
core/text/source_title.py
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
"""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"]
|
||||||
|
|
@ -586,17 +586,35 @@ class PlaylistSyncService:
|
||||||
|
|
||||||
artist_name = _artist_name(artist)
|
artist_name = _artist_name(artist)
|
||||||
|
|
||||||
|
# YouTube/streaming sources arrive video-shaped: title
|
||||||
|
# "Artist - Song", artist "Official Artist"/"Artist - Topic"/
|
||||||
|
# "ArtistVEVO". Try the raw (title, artist) first, then a
|
||||||
|
# canonicalized variant so those still match the clean library
|
||||||
|
# metadata instead of being reported missing (#768).
|
||||||
|
from core.text.source_title import canonical_source_track
|
||||||
|
_attempts = [(original_title, artist_name)]
|
||||||
|
_canon_title, _canon_artist = canonical_source_track(original_title, artist_name)
|
||||||
|
if (_canon_title, _canon_artist) != (original_title, artist_name):
|
||||||
|
_attempts.append((_canon_title, _canon_artist))
|
||||||
|
|
||||||
# Use the improved database check_track_exists method with server awareness
|
# Use the improved database check_track_exists method with server awareness
|
||||||
try:
|
try:
|
||||||
db = MusicDatabase()
|
db = MusicDatabase()
|
||||||
artist_candidates = self._get_or_fetch_artist_candidates(
|
# Try every candidate form and keep the BEST — don't stop at
|
||||||
candidate_pool, db, artist_name, active_server,
|
# the first that clears the threshold, or a marginal raw
|
||||||
)
|
# match could mask a stronger (correct) canonical one.
|
||||||
db_track, confidence = db.check_track_exists(
|
db_track, confidence = None, 0.0
|
||||||
original_title, artist_name,
|
for _try_title, _try_artist in _attempts:
|
||||||
confidence_threshold=0.7, server_source=active_server,
|
artist_candidates = self._get_or_fetch_artist_candidates(
|
||||||
candidate_tracks=artist_candidates,
|
candidate_pool, db, _try_artist, active_server,
|
||||||
)
|
)
|
||||||
|
_cand_track, _cand_conf = db.check_track_exists(
|
||||||
|
_try_title, _try_artist,
|
||||||
|
confidence_threshold=0.7, server_source=active_server,
|
||||||
|
candidate_tracks=artist_candidates,
|
||||||
|
)
|
||||||
|
if _cand_conf > confidence:
|
||||||
|
db_track, confidence = _cand_track, _cand_conf
|
||||||
|
|
||||||
if db_track and confidence >= 0.7:
|
if db_track and confidence >= 0.7:
|
||||||
logger.debug(f"Database match found for '{original_title}' by '{artist_name}': '{db_track.title}' with confidence {confidence:.2f}")
|
logger.debug(f"Database match found for '{original_title}' by '{artist_name}': '{db_track.title}' with confidence {confidence:.2f}")
|
||||||
|
|
|
||||||
80
tests/test_playlist_edit.py
Normal file
80
tests/test_playlist_edit.py
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
"""Extreme battery for sync-editor add/remove planners (#768 Bug C)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from core.sync.playlist_edit import plan_playlist_add, remove_one_occurrence
|
||||||
|
|
||||||
|
|
||||||
|
# ── plan_playlist_add: link must not duplicate ────────────────────────────
|
||||||
|
|
||||||
|
def test_link_to_existing_track_does_not_insert():
|
||||||
|
# The reported loop: matching an unmatched source to a track already in
|
||||||
|
# the playlist (an "extra") must NOT add a second copy.
|
||||||
|
plan = plan_playlist_add(["a", "b", "nv72"], "nv72", is_link=True)
|
||||||
|
assert plan["should_insert"] is False
|
||||||
|
assert plan["new_ids"] == ["a", "b", "nv72"] # unchanged
|
||||||
|
|
||||||
|
|
||||||
|
def test_link_to_absent_track_inserts():
|
||||||
|
plan = plan_playlist_add(["a", "b"], "nv99", is_link=True, position=1)
|
||||||
|
assert plan["should_insert"] is True
|
||||||
|
assert plan["new_ids"] == ["a", "nv99", "b"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_link_add_always_inserts_even_if_present():
|
||||||
|
# A plain add (no source link) may legitimately duplicate.
|
||||||
|
plan = plan_playlist_add(["a", "b"], "a", is_link=False)
|
||||||
|
assert plan["should_insert"] is True
|
||||||
|
assert plan["new_ids"].count("a") == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_appends_when_no_position():
|
||||||
|
plan = plan_playlist_add(["a", "b"], "c", is_link=False)
|
||||||
|
assert plan["new_ids"] == ["a", "b", "c"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_clamps_out_of_range_position():
|
||||||
|
assert plan_playlist_add(["a"], "c", is_link=False, position=99)["new_ids"] == ["a", "c"]
|
||||||
|
assert plan_playlist_add(["a"], "c", is_link=False, position=-5)["new_ids"] == ["c", "a"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_stringifies_ids():
|
||||||
|
plan = plan_playlist_add([1, 2, 72], 72, is_link=True)
|
||||||
|
assert plan["should_insert"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── remove_one_occurrence: remove ONE, not all ────────────────────────────
|
||||||
|
|
||||||
|
def test_removes_only_one_of_duplicates():
|
||||||
|
# The #768 delete bug: two copies (pos 72, 73) — removing must drop ONE.
|
||||||
|
new_ids, removed = remove_one_occurrence(["a", "nv72", "nv72", "b"], "nv72")
|
||||||
|
assert removed is True
|
||||||
|
assert new_ids == ["a", "nv72", "b"] # one copy survives
|
||||||
|
|
||||||
|
|
||||||
|
def test_removes_exact_position_when_given():
|
||||||
|
new_ids, removed = remove_one_occurrence(["x", "x", "x"], "x", position=1)
|
||||||
|
assert removed is True
|
||||||
|
assert new_ids == ["x", "x"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_falls_back_to_first_when_position_mismatches():
|
||||||
|
new_ids, removed = remove_one_occurrence(["a", "b", "c"], "b", position=0)
|
||||||
|
assert removed is True
|
||||||
|
assert new_ids == ["a", "c"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_absent_id_reports_not_removed():
|
||||||
|
new_ids, removed = remove_one_occurrence(["a", "b"], "zzz")
|
||||||
|
assert removed is False
|
||||||
|
assert new_ids == ["a", "b"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_single_occurrence():
|
||||||
|
new_ids, removed = remove_one_occurrence(["a", "b", "c"], "b")
|
||||||
|
assert (new_ids, removed) == (["a", "c"], True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_stringifies():
|
||||||
|
new_ids, removed = remove_one_occurrence([1, 2, 2, 3], 2)
|
||||||
|
assert removed and new_ids == ["1", "2", "3"]
|
||||||
122
tests/test_playlist_reconcile.py
Normal file
122
tests/test_playlist_reconcile.py
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
"""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"
|
||||||
113
tests/test_source_title.py
Normal file
113
tests/test_source_title.py
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
"""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")
|
||||||
175
web_server.py
175
web_server.py
|
|
@ -18185,23 +18185,11 @@ def get_server_playlist_tracks(playlist_id):
|
||||||
pass
|
pass
|
||||||
break
|
break
|
||||||
|
|
||||||
# Build combined view with two-pass matching (exact then fuzzy)
|
# Reconcile source vs server playlist. Three-pass matcher lifted to
|
||||||
import re as _re
|
# core.sync.playlist_reconcile (pure + tested) — fixes #768 (YouTube
|
||||||
from difflib import SequenceMatcher
|
# "Artist - Title" sources now match, and source_track_id is echoed
|
||||||
|
# back so manual "Find & add" overrides persist).
|
||||||
def _norm_title(t):
|
from core.sync.playlist_reconcile import reconcile_playlist
|
||||||
"""Strip feat./ft., remaster, and edition qualifiers for comparison only."""
|
|
||||||
# feat./ft. — e.g. (feat. Artist), [ft. Artist]
|
|
||||||
t = _re.sub(r'\s*[\(\[](?:feat|ft)\.?[^\)\]]*[\)\]]', '', t, flags=_re.IGNORECASE)
|
|
||||||
# Remaster/Remastered — e.g. (2019 Remaster), (Remastered), (2019 Remastered Version)
|
|
||||||
t = _re.sub(r'\s*[\(\[](?:\d{4}\s+)?remaster(?:ed)?(?:\s+version)?\s*[\)\]]', '', t, flags=_re.IGNORECASE)
|
|
||||||
# Edition qualifiers — e.g. (Deluxe Edition), (Special Edition), [Anniversary Edition]
|
|
||||||
t = _re.sub(r'\s*[\(\[](?:deluxe|special|anniversary|legacy|expanded|limited)(?:\s+edition)?\s*[\)\]]', '', t, flags=_re.IGNORECASE)
|
|
||||||
return t.lower().strip()
|
|
||||||
|
|
||||||
combined = []
|
|
||||||
used_server_indices = set()
|
|
||||||
unmatched_source = [] # (index_in_combined, src_dict) for fuzzy second pass
|
|
||||||
|
|
||||||
# Pass 0: User-confirmed match overrides from sync_match_cache.
|
# Pass 0: User-confirmed match overrides from sync_match_cache.
|
||||||
# When a user previously picked a local file via "Find & Add",
|
# When a user previously picked a local file via "Find & Add",
|
||||||
|
|
@ -18218,92 +18206,7 @@ def get_server_playlist_tracks(playlist_id):
|
||||||
lambda src_id: ((_db_for_overrides.read_sync_match_cache(src_id, active_server) or {}).get('server_track_id')),
|
lambda src_id: ((_db_for_overrides.read_sync_match_cache(src_id, active_server) or {}).get('server_track_id')),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Pass 1: Exact title match (normalized — strips feat./ft. qualifiers)
|
combined = reconcile_playlist(source_tracks, server_tracks, _override_pairs)
|
||||||
for i, src in enumerate(source_tracks):
|
|
||||||
src_name = src.get('name', '')
|
|
||||||
src_artist = src.get('artist', '')
|
|
||||||
if not src_artist and src.get('artists'):
|
|
||||||
a = src['artists'][0] if src['artists'] else ''
|
|
||||||
src_artist = a.get('name', a) if isinstance(a, dict) else str(a)
|
|
||||||
|
|
||||||
src_entry = {
|
|
||||||
'name': src_name, 'artist': src_artist,
|
|
||||||
'album': src.get('album', ''), 'image_url': src.get('image_url', ''),
|
|
||||||
'duration_ms': src.get('duration_ms', 0), 'position': src.get('position', i),
|
|
||||||
}
|
|
||||||
|
|
||||||
# Override hit — paired by user, skip exact/fuzzy matching.
|
|
||||||
if i in _override_pairs:
|
|
||||||
j_override = _override_pairs[i]
|
|
||||||
used_server_indices.add(j_override)
|
|
||||||
combined.append({
|
|
||||||
'source_track': src_entry,
|
|
||||||
'server_track': server_tracks[j_override],
|
|
||||||
'match_status': 'matched',
|
|
||||||
'confidence': 1.0,
|
|
||||||
'override': True,
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
|
|
||||||
src_norm = _norm_title(src_name)
|
|
||||||
best_idx = -1
|
|
||||||
for j, svr in enumerate(server_tracks):
|
|
||||||
if j in used_server_indices:
|
|
||||||
continue
|
|
||||||
if _norm_title(svr['title']) == src_norm:
|
|
||||||
best_idx = j
|
|
||||||
break
|
|
||||||
|
|
||||||
if best_idx >= 0:
|
|
||||||
used_server_indices.add(best_idx)
|
|
||||||
combined.append({
|
|
||||||
'source_track': src_entry,
|
|
||||||
'server_track': server_tracks[best_idx],
|
|
||||||
'match_status': 'matched',
|
|
||||||
'confidence': 1.0,
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
idx = len(combined)
|
|
||||||
combined.append({
|
|
||||||
'source_track': src_entry,
|
|
||||||
'server_track': None,
|
|
||||||
'match_status': 'missing',
|
|
||||||
'confidence': 0.0,
|
|
||||||
})
|
|
||||||
unmatched_source.append((idx, src_entry))
|
|
||||||
|
|
||||||
# Pass 2: Fuzzy match on remaining unmatched source tracks (normalized keys)
|
|
||||||
for combo_idx, src_entry in unmatched_source:
|
|
||||||
src_key = f"{src_entry['artist']} {_norm_title(src_entry['name'])}".strip()
|
|
||||||
best_score = 0.0
|
|
||||||
best_j = -1
|
|
||||||
for j, svr in enumerate(server_tracks):
|
|
||||||
if j in used_server_indices:
|
|
||||||
continue
|
|
||||||
svr_key = f"{svr['artist']} {_norm_title(svr['title'])}".strip().lower()
|
|
||||||
score = SequenceMatcher(None, src_key.lower(), svr_key).ratio()
|
|
||||||
if score > best_score and score >= 0.75:
|
|
||||||
best_score = score
|
|
||||||
best_j = j
|
|
||||||
|
|
||||||
if best_j >= 0:
|
|
||||||
used_server_indices.add(best_j)
|
|
||||||
combined[combo_idx] = {
|
|
||||||
'source_track': src_entry,
|
|
||||||
'server_track': server_tracks[best_j],
|
|
||||||
'match_status': 'matched',
|
|
||||||
'confidence': round(best_score, 3),
|
|
||||||
}
|
|
||||||
|
|
||||||
# Add server tracks that aren't in the source (extra tracks on server)
|
|
||||||
for j, svr in enumerate(server_tracks):
|
|
||||||
if j not in used_server_indices:
|
|
||||||
combined.append({
|
|
||||||
'source_track': None,
|
|
||||||
'server_track': svr,
|
|
||||||
'match_status': 'extra',
|
|
||||||
'confidence': 0.0,
|
|
||||||
})
|
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"success": True,
|
"success": True,
|
||||||
|
|
@ -18490,6 +18393,18 @@ def server_playlist_add_track(playlist_id):
|
||||||
if not new_item:
|
if not new_item:
|
||||||
return jsonify({"success": False, "error": "Track not found on server"}), 404
|
return jsonify({"success": False, "error": "Track not found on server"}), 404
|
||||||
|
|
||||||
|
# Link, don't duplicate: matching an unmatched source to a track
|
||||||
|
# already in the playlist should only record the override, never
|
||||||
|
# append a second copy (#768).
|
||||||
|
if source_track_id:
|
||||||
|
try:
|
||||||
|
_existing = {str(it.ratingKey) for it in raw_playlist.items()}
|
||||||
|
except Exception:
|
||||||
|
_existing = set()
|
||||||
|
if str(track_id) in _existing:
|
||||||
|
_persist_find_and_add_match(source_track_id, active_server, track_id, server_track_title or new_item.title, source_title, source_artist)
|
||||||
|
return jsonify({"success": True, "message": "Track linked"})
|
||||||
|
|
||||||
logger.info(f"[ServerPlaylist] Adding track: '{new_item.title}' (ratingKey={new_item.ratingKey}) to playlist '{playlist_name}'")
|
logger.info(f"[ServerPlaylist] Adding track: '{new_item.title}' (ratingKey={new_item.ratingKey}) to playlist '{playlist_name}'")
|
||||||
|
|
||||||
raw_playlist.addItems([new_item])
|
raw_playlist.addItems([new_item])
|
||||||
|
|
@ -18515,24 +18430,30 @@ def server_playlist_add_track(playlist_id):
|
||||||
return jsonify({"success": True, "message": "Track added", "new_playlist_id": new_id})
|
return jsonify({"success": True, "message": "Track added", "new_playlist_id": new_id})
|
||||||
|
|
||||||
elif active_server == 'jellyfin' and media_server_engine.client('jellyfin'):
|
elif active_server == 'jellyfin' and media_server_engine.client('jellyfin'):
|
||||||
|
from core.sync.playlist_edit import plan_playlist_add
|
||||||
current_tracks = media_server_engine.client('jellyfin').get_playlist_tracks(playlist_id) or []
|
current_tracks = media_server_engine.client('jellyfin').get_playlist_tracks(playlist_id) or []
|
||||||
track_ids = [str(t.ratingKey) for t in current_tracks]
|
track_ids = [str(t.ratingKey) for t in current_tracks]
|
||||||
pos = max(0, min(int(position), len(track_ids))) if position is not None else len(track_ids)
|
# Matching an unmatched source to a track already in the playlist
|
||||||
track_ids.insert(pos, track_id)
|
# is a LINK, not a second copy — don't duplicate it (#768).
|
||||||
new_track_objs = [type('T', (), {'ratingKey': tid, 'title': ''})() for tid in track_ids]
|
plan = plan_playlist_add(track_ids, track_id, is_link=bool(source_track_id), position=position)
|
||||||
media_server_engine.client('jellyfin').update_playlist(playlist_name, new_track_objs)
|
if plan['should_insert']:
|
||||||
|
new_track_objs = [type('T', (), {'ratingKey': tid, 'title': ''})() for tid in plan['new_ids']]
|
||||||
|
media_server_engine.client('jellyfin').update_playlist(playlist_name, new_track_objs)
|
||||||
_persist_find_and_add_match(source_track_id, active_server, track_id, server_track_title, source_title, source_artist)
|
_persist_find_and_add_match(source_track_id, active_server, track_id, server_track_title, source_title, source_artist)
|
||||||
return jsonify({"success": True, "message": "Track added"})
|
return jsonify({"success": True, "message": "Track linked" if not plan['should_insert'] else "Track added"})
|
||||||
|
|
||||||
elif active_server == 'navidrome' and media_server_engine.client('navidrome'):
|
elif active_server == 'navidrome' and media_server_engine.client('navidrome'):
|
||||||
|
from core.sync.playlist_edit import plan_playlist_add
|
||||||
current_tracks = media_server_engine.client('navidrome').get_playlist_tracks(playlist_id) or []
|
current_tracks = media_server_engine.client('navidrome').get_playlist_tracks(playlist_id) or []
|
||||||
track_ids = [str(t.ratingKey) for t in current_tracks]
|
track_ids = [str(t.ratingKey) for t in current_tracks]
|
||||||
pos = max(0, min(int(position), len(track_ids))) if position is not None else len(track_ids)
|
# Matching an unmatched source to a track already in the playlist
|
||||||
track_ids.insert(pos, track_id)
|
# is a LINK, not a second copy — don't duplicate it (#768).
|
||||||
new_track_objs = [type('T', (), {'ratingKey': tid, 'title': ''})() for tid in track_ids]
|
plan = plan_playlist_add(track_ids, track_id, is_link=bool(source_track_id), position=position)
|
||||||
media_server_engine.client('navidrome').create_playlist(playlist_name, new_track_objs, playlist_id=playlist_id)
|
if plan['should_insert']:
|
||||||
|
new_track_objs = [type('T', (), {'ratingKey': tid, 'title': ''})() for tid in plan['new_ids']]
|
||||||
|
media_server_engine.client('navidrome').create_playlist(playlist_name, new_track_objs, playlist_id=playlist_id)
|
||||||
_persist_find_and_add_match(source_track_id, active_server, track_id, server_track_title, source_title, source_artist)
|
_persist_find_and_add_match(source_track_id, active_server, track_id, server_track_title, source_title, source_artist)
|
||||||
return jsonify({"success": True, "message": "Track added"})
|
return jsonify({"success": True, "message": "Track linked" if not plan['should_insert'] else "Track added"})
|
||||||
|
|
||||||
return jsonify({"success": False, "error": f"Unsupported server: {active_server}"}), 400
|
return jsonify({"success": False, "error": f"Unsupported server: {active_server}"}), 400
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -18573,10 +18494,17 @@ def server_playlist_remove_track(playlist_id):
|
||||||
logger.warning(f"[ServerPlaylist] remove-track: playlist not found by id={playlist_id} or name='{playlist_name}'")
|
logger.warning(f"[ServerPlaylist] remove-track: playlist not found by id={playlist_id} or name='{playlist_name}'")
|
||||||
return jsonify({"success": False, "error": "Playlist not found"}), 404
|
return jsonify({"success": False, "error": "Playlist not found"}), 404
|
||||||
|
|
||||||
# Rebuild without the target track
|
# Rebuild without ONE copy of the target track — deleting one
|
||||||
|
# duplicate must not wipe every copy (#768).
|
||||||
current_items = list(raw_playlist.items())
|
current_items = list(raw_playlist.items())
|
||||||
new_items = [item for item in current_items if str(item.ratingKey) != str(remove_track_id)]
|
new_items = list(current_items)
|
||||||
if len(new_items) == len(current_items):
|
_removed = False
|
||||||
|
for _i, _it in enumerate(new_items):
|
||||||
|
if str(_it.ratingKey) == str(remove_track_id):
|
||||||
|
del new_items[_i]
|
||||||
|
_removed = True
|
||||||
|
break
|
||||||
|
if not _removed:
|
||||||
return jsonify({"success": False, "error": "Track not found in playlist"}), 404
|
return jsonify({"success": False, "error": "Track not found in playlist"}), 404
|
||||||
raw_playlist.delete()
|
raw_playlist.delete()
|
||||||
if new_items:
|
if new_items:
|
||||||
|
|
@ -18586,18 +18514,25 @@ def server_playlist_remove_track(playlist_id):
|
||||||
return jsonify({"success": True, "message": "Track removed (playlist now empty)"})
|
return jsonify({"success": True, "message": "Track removed (playlist now empty)"})
|
||||||
|
|
||||||
elif active_server == 'jellyfin' and media_server_engine.client('jellyfin'):
|
elif active_server == 'jellyfin' and media_server_engine.client('jellyfin'):
|
||||||
|
from core.sync.playlist_edit import remove_one_occurrence
|
||||||
current_tracks = media_server_engine.client('jellyfin').get_playlist_tracks(playlist_id) or []
|
current_tracks = media_server_engine.client('jellyfin').get_playlist_tracks(playlist_id) or []
|
||||||
new_ids = [str(t.ratingKey) for t in current_tracks if str(t.ratingKey) != str(remove_track_id)]
|
track_ids = [str(t.ratingKey) for t in current_tracks]
|
||||||
if len(new_ids) == len(current_tracks):
|
# Remove ONE occurrence, not every copy — duplicates are the same
|
||||||
|
# track, so deleting one must not wipe them all (#768).
|
||||||
|
new_ids, removed = remove_one_occurrence(track_ids, remove_track_id)
|
||||||
|
if not removed:
|
||||||
return jsonify({"success": False, "error": "Track not found in playlist"}), 404
|
return jsonify({"success": False, "error": "Track not found in playlist"}), 404
|
||||||
new_track_objs = [type('T', (), {'ratingKey': tid, 'title': ''})() for tid in new_ids]
|
new_track_objs = [type('T', (), {'ratingKey': tid, 'title': ''})() for tid in new_ids]
|
||||||
media_server_engine.client('jellyfin').update_playlist(playlist_name, new_track_objs)
|
media_server_engine.client('jellyfin').update_playlist(playlist_name, new_track_objs)
|
||||||
return jsonify({"success": True, "message": "Track removed"})
|
return jsonify({"success": True, "message": "Track removed"})
|
||||||
|
|
||||||
elif active_server == 'navidrome' and media_server_engine.client('navidrome'):
|
elif active_server == 'navidrome' and media_server_engine.client('navidrome'):
|
||||||
|
from core.sync.playlist_edit import remove_one_occurrence
|
||||||
current_tracks = media_server_engine.client('navidrome').get_playlist_tracks(playlist_id) or []
|
current_tracks = media_server_engine.client('navidrome').get_playlist_tracks(playlist_id) or []
|
||||||
new_ids = [str(t.ratingKey) for t in current_tracks if str(t.ratingKey) != str(remove_track_id)]
|
track_ids = [str(t.ratingKey) for t in current_tracks]
|
||||||
if len(new_ids) == len(current_tracks):
|
# Remove ONE occurrence, not every copy (#768).
|
||||||
|
new_ids, removed = remove_one_occurrence(track_ids, remove_track_id)
|
||||||
|
if not removed:
|
||||||
return jsonify({"success": False, "error": "Track not found in playlist"}), 404
|
return jsonify({"success": False, "error": "Track not found in playlist"}), 404
|
||||||
new_track_objs = [type('T', (), {'ratingKey': tid, 'title': ''})() for tid in new_ids]
|
new_track_objs = [type('T', (), {'ratingKey': tid, 'title': ''})() for tid in new_ids]
|
||||||
media_server_engine.client('navidrome').create_playlist(playlist_name, new_track_objs, playlist_id=playlist_id)
|
media_server_engine.client('navidrome').create_playlist(playlist_name, new_track_objs, playlist_id=playlist_id)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue