From 1fbc699879b92fc4b736a484fda81a2a9ae59335 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Mon, 13 Apr 2026 19:09:25 -0700 Subject: [PATCH] Fix server playlist Find & Add not persisting to Plex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues fixed: 1. Plex add-track used delete+recreate (Playlist.create) which was unreliable — switched to addItems() which atomically appends the track without touching existing playlist items. 2. After a successful add, the UI only did an optimistic local update. On reopen the automatic matcher ran fresh and couldn't connect the manually selected track to the source slot, making it look unfixed. Now both add and replace re-fetch the compare view from the server so the matcher sees the actual updated Plex state. 3. Matching algorithm was too strict for common title variants. Added _norm_title() which strips feat./ft., remaster/remastered, and edition qualifiers before comparison — so "Boy 1904" matches "Boy 1904 (2019 Remaster)" and "Float Away" matches "Float Away (feat. Flamingosis & Eric Benny Bloom)". Display titles unchanged. --- web_server.py | 45 +++++++++++++++++++++--------------------- webui/static/script.js | 26 +++--------------------- 2 files changed, 26 insertions(+), 45 deletions(-) diff --git a/web_server.py b/web_server.py index 2c2aa0a7..2d23ade7 100644 --- a/web_server.py +++ b/web_server.py @@ -30700,12 +30700,24 @@ def get_server_playlist_tracks(playlist_id): break # Build combined view with two-pass matching (exact then fuzzy) + import re as _re from difflib import SequenceMatcher + + def _norm_title(t): + """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 1: Exact title match + # Pass 1: Exact title match (normalized — strips feat./ft. qualifiers) for i, src in enumerate(source_tracks): src_name = src.get('name', '') src_artist = src.get('artist', '') @@ -30719,11 +30731,12 @@ def get_server_playlist_tracks(playlist_id): 'duration_ms': src.get('duration_ms', 0), 'position': src.get('position', i), } + src_norm = _norm_title(src_name) best_idx = -1 for j, svr in enumerate(server_tracks): if j in used_server_indices: continue - if svr['title'].lower().strip() == src_name.lower().strip(): + if _norm_title(svr['title']) == src_norm: best_idx = j break @@ -30745,16 +30758,16 @@ def get_server_playlist_tracks(playlist_id): }) unmatched_source.append((idx, src_entry)) - # Pass 2: Fuzzy match on remaining unmatched source tracks + # 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']} {src_entry['name']}".lower().strip() + 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']} {svr['title']}".lower().strip() - score = SequenceMatcher(None, src_key, svr_key).ratio() + 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 @@ -30904,23 +30917,11 @@ def server_playlist_add_track(playlist_id): if not new_item: return jsonify({"success": False, "error": "Track not found on server"}), 404 - logger.info(f"[ServerPlaylist] Adding track: '{new_item.title}' (ratingKey={new_item.ratingKey}) at position={position} to playlist '{playlist_name}'") + logger.info(f"[ServerPlaylist] Adding track: '{new_item.title}' (ratingKey={new_item.ratingKey}) to playlist '{playlist_name}'") - if position is not None: - # Rebuild playlist with track inserted at correct position - current_items = list(raw_playlist.items()) - logger.info(f"[ServerPlaylist] Current playlist has {len(current_items)} tracks, inserting at pos {position}") - pos = max(0, min(int(position), len(current_items))) - current_items.insert(pos, new_item) - # Delete old and recreate directly (avoid update_playlist's backup logic) - raw_playlist.delete() - from plexapi.playlist import Playlist - new_pl = Playlist.create(plex_client.server, playlist_name, items=current_items) - new_id = str(new_pl.ratingKey) - logger.info(f"[ServerPlaylist] Recreated playlist with {len(current_items)} tracks, new ID: {new_id}") - else: - raw_playlist.addItems([new_item]) - new_id = str(raw_playlist.ratingKey) + raw_playlist.addItems([new_item]) + new_id = str(raw_playlist.ratingKey) + logger.info(f"[ServerPlaylist] Added track to playlist, playlist ID: {new_id}") return jsonify({"success": True, "message": "Track added", "new_playlist_id": new_id}) elif active_server == 'jellyfin' and jellyfin_client: diff --git a/webui/static/script.js b/webui/static/script.js index 94558c8f..44522f77 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -72526,29 +72526,9 @@ async function _serverSelectTrack(trackIndex, mode, newTrackId, el) { // Update playlist ID if server recreated it (Plex deletes+recreates) if (data.new_playlist_id) _serverEditorState.playlistId = data.new_playlist_id; - // Update local state directly — don't re-run the matcher which would - // lose the user's explicit assignment if titles don't match exactly - const trackEntry = _serverEditorState.tracks[trackIndex]; - if (trackEntry && mode === 'add') { - // Fill the empty slot with the selected track info - const svrTitle = el.querySelector('.server-search-result-title')?.textContent || ''; - const svrArtist = (el.querySelector('.server-search-result-meta')?.textContent || '').split('·')[0].trim(); - const svrThumb = el.querySelector('.server-search-result-art img')?.src || ''; - trackEntry.server_track = { id: newTrackId, title: svrTitle, artist: svrArtist, thumb: svrThumb }; - trackEntry.match_status = 'matched'; - // Calculate real title similarity so the badge is accurate - const srcName = trackEntry.source_track?.name || ''; - const srcArtist = trackEntry.source_track?.artist || ''; - const srcKey = `${srcArtist} ${srcName}`.trim().toLowerCase(); - const svrKey = `${svrArtist} ${svrTitle}`.trim().toLowerCase(); - trackEntry.confidence = srcKey && svrKey ? calculateStringSimilarity(srcKey, svrKey) : 0; - _renderCompareColumns(_serverEditorState.tracks); - _updateCompareStats(_serverEditorState.tracks); - _setupScrollLinking(); - } else { - // For replace mode, re-fetch to get the updated server state - _openServerCompareView(_serverEditorState.playlistId, _serverEditorState.playlistName, _serverEditorState.mirroredPlaylist); - } + // Re-fetch from server so the compare view reflects the actual server state + // and the matching algorithm can correctly wire up the newly added/replaced track + _openServerCompareView(_serverEditorState.playlistId, _serverEditorState.playlistName, _serverEditorState.mirroredPlaylist); } else { showToast(data.error || 'Failed to update track', 'error'); if (btn) { btn.disabled = false; btn.textContent = 'Select'; }