Add Server Playlist Manager with dual-column compare editor

New "Server Playlists" tab (default on Sync page) lets users compare
mirrored playlists against their media server and fix match issues.

- Dual-column comparison: source tracks (left) vs server tracks (right)
- Smart matching: exact title first, then fuzzy artist+title (≥75%)
- Find & Add: search library to fill missing slots at correct position
- Swap: replace matched tracks with different versions
- Remove: delete tracks from server playlists with confirmation
- Title similarity percentage badge on each match
- Disambiguation modal when multiple mirrored playlists share a name
- Album art on source tracks, server tracks, and search results
- Cross-column click-to-scroll highlighting
- Filter buttons (All/Matched/Missing/Extra) with live counts
- Escape key and backdrop click to close modals
- Mobile responsive (stacked columns under 768px)
- Works with Plex, Jellyfin, and Navidrome
This commit is contained in:
Broque Thomas 2026-03-31 20:58:27 -07:00
parent de3fba3f37
commit 34c8b1bb50
6 changed files with 2504 additions and 7 deletions

View file

@ -4646,7 +4646,7 @@ class MusicDatabase:
params.append(limit)
cursor.execute(f"""
SELECT tracks.*, artists.name as artist_name, albums.title as album_title
SELECT tracks.*, artists.name as artist_name, albums.title as album_title, albums.thumb_url as album_thumb_url
FROM tracks
JOIN artists ON tracks.artist_id = artists.id
JOIN albums ON tracks.album_id = albums.id
@ -4654,7 +4654,7 @@ class MusicDatabase:
ORDER BY tracks.title, artists.name
LIMIT ?
""", params)
return self._rows_to_tracks(cursor.fetchall())
def _search_tracks_fuzzy_fallback(self, cursor, title: str, artist: str, limit: int, server_source: str = None) -> List[DatabaseTrack]:
@ -4695,7 +4695,7 @@ class MusicDatabase:
params.append(limit * 3) # Get more results for scoring
cursor.execute(f"""
SELECT tracks.*, artists.name as artist_name, albums.title as album_title
SELECT tracks.*, artists.name as artist_name, albums.title as album_title, albums.thumb_url as album_thumb_url
FROM tracks
JOIN artists ON tracks.artist_id = artists.id
JOIN albums ON tracks.album_id = albums.id
@ -4703,9 +4703,9 @@ class MusicDatabase:
ORDER BY tracks.title, artists.name
LIMIT ?
""", params)
rows = cursor.fetchall()
# Score and filter results
scored_results = []
for row in rows:
@ -4746,6 +4746,8 @@ class MusicDatabase:
# Add artist and album info for compatibility with Plex responses
track.artist_name = row['artist_name']
track.album_title = row['album_title']
track.album_thumb_url = row['album_thumb_url'] if 'album_thumb_url' in row.keys() else ''
track.server_source = row['server_source'] if 'server_source' in row.keys() else ''
tracks.append(track)
return tracks

View file

@ -19314,6 +19314,24 @@ def get_version_info():
"title": "What's New in SoulSync",
"subtitle": f"Version {SOULSYNC_VERSION} — Latest Changes",
"sections": [
{
"title": "🖥️ Server Playlist Manager — Compare & Fix Matches",
"description": "Review and fix track matches between your source playlists and media server",
"features": [
"• New Server Playlists tab (default on Sync page) — shows server playlists that match your mirrored playlists",
"• Dual-column comparison view — source tracks on the left, server tracks on the right with match status",
"• Click any track to highlight and auto-scroll to its pair in the other column",
"• Find & Add — click empty slots to search your library and add tracks at the correct position",
"• Swap — replace a matched track with a different version from your library",
"• Remove — delete incorrect tracks from server playlists with confirmation",
"• Title similarity percentage shown on each match (exact, high, or fuzzy)",
"• Disambiguation modal when multiple mirrored playlists share the same name",
"• Album art shown for source tracks, server tracks, and search results",
"• Smart matching — exact title match first, then fuzzy artist+title match (≥75% threshold)",
"• Works with Plex, Jellyfin, and Navidrome"
],
"usage_note": "Navigate to Sync → Server Playlists tab. Click any playlist card to open the comparison editor."
},
{
"title": "📊 Sync History Dashboard with Per-Track Details",
"description": "Dashboard shows recent syncs as visual cards with full per-track match data",
@ -27425,6 +27443,579 @@ def _record_sync_history_completion(batch_id, batch):
except Exception as e:
logger.warning(f"Failed to record sync history completion: {e}")
# ===============================
# == SERVER PLAYLIST MANAGER ==
# ===============================
@app.route('/api/server/playlists', methods=['GET'])
def get_server_playlists():
"""Get all playlists from the active media server."""
try:
active_server = config_manager.get_active_media_server()
logger.info(f"[ServerPlaylists] Active server: {active_server}")
if not active_server:
return jsonify({"success": False, "error": "No media server configured"}), 400
playlists_data = []
if active_server == 'plex' and plex_client and plex_client.is_connected():
# Use raw Plex API to get playlist metadata without fetching all tracks
try:
raw_playlists = plex_client.server.playlists()
logger.info(f"[ServerPlaylists] Plex returned {len(raw_playlists)} total playlists")
for playlist in raw_playlists:
if playlist.playlistType == 'audio':
playlists_data.append({
'id': str(playlist.ratingKey),
'name': playlist.title,
'track_count': playlist.leafCount,
})
logger.info(f"[ServerPlaylists] Found {len(playlists_data)} audio playlists")
except Exception as e:
logger.error(f"[ServerPlaylists] Error fetching Plex playlists: {e}", exc_info=True)
return jsonify({"success": False, "error": f"Plex error: {str(e)}"}), 500
elif active_server == 'jellyfin' and jellyfin_client and jellyfin_client.is_connected():
for pl in jellyfin_client.get_all_playlists():
playlists_data.append({
'id': pl.id,
'name': pl.title,
'track_count': pl.leaf_count,
})
elif active_server == 'navidrome' and navidrome_client and navidrome_client.is_connected():
for pl in navidrome_client.get_all_playlists():
playlists_data.append({
'id': pl.id,
'name': pl.title,
'track_count': pl.leaf_count,
})
else:
logger.warning(f"[ServerPlaylists] Server '{active_server}' not connected. plex_client={plex_client is not None}, jellyfin_client={jellyfin_client is not None}, navidrome_client={navidrome_client is not None}")
return jsonify({"success": False, "error": f"{active_server} not connected"}), 400
return jsonify({"success": True, "server_type": active_server, "playlists": playlists_data})
except Exception as e:
logger.error(f"Error getting server playlists: {e}", exc_info=True)
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/server/playlist/<playlist_id>/tracks', methods=['GET'])
def get_server_playlist_tracks(playlist_id):
"""Get tracks from a server playlist with source match info from sync history."""
try:
active_server = config_manager.get_active_media_server()
playlist_name = request.args.get('name', '')
# Get tracks from server
server_tracks = []
if active_server == 'plex' and plex_client:
try:
# Try by ID first, fall back to name lookup (ID changes when playlist is recreated)
raw_playlist = None
try:
raw_playlist = plex_client.server.fetchItem(int(playlist_id))
except Exception:
pass
if not raw_playlist and playlist_name:
try:
raw_playlist = plex_client.server.playlist(playlist_name)
except Exception:
pass
if raw_playlist:
if not playlist_name:
playlist_name = raw_playlist.title
plex_base = getattr(plex_client.server, '_baseurl', '') or ''
plex_token = getattr(plex_client.server, '_token', '') or ''
if not plex_base:
# Fallback: get from config
_pc = config_manager.get_plex_config()
plex_base = (_pc.get('base_url', '') or '').rstrip('/')
plex_token = plex_token or _pc.get('token', '')
logger.debug(f"[ServerPlaylistTracks] Plex base URL: {plex_base}")
for item in raw_playlist.items():
grandparent = getattr(item, 'grandparentTitle', '') or ''
parent = getattr(item, 'parentTitle', '') or ''
# Build full thumb URL from Plex relative path
thumb = ''
raw_thumb = getattr(item, 'thumb', '') or getattr(item, 'parentThumb', '') or ''
if raw_thumb and plex_base and plex_token:
thumb = f"{plex_base}{raw_thumb}?X-Plex-Token={plex_token}"
server_tracks.append({
'id': str(item.ratingKey),
'title': item.title,
'artist': grandparent,
'album': parent,
'duration': item.duration or 0,
'thumb': thumb,
})
except Exception as e:
logger.error(f"[ServerPlaylistTracks] Plex error: {e}", exc_info=True)
elif active_server == 'jellyfin' and jellyfin_client:
tracks = jellyfin_client.get_playlist_tracks(playlist_id)
jf_base = jellyfin_client.base_url or ''
for t in (tracks or []):
raw = t._data if hasattr(t, '_data') else {}
artists = raw.get('Artists', [])
# Jellyfin image: /Items/{Id}/Images/Primary
album_id = raw.get('AlbumId', '')
thumb = f"{jf_base}/Items/{album_id}/Images/Primary?maxHeight=100" if album_id and jf_base else ''
server_tracks.append({
'id': str(t.ratingKey),
'title': t.title,
'artist': artists[0] if artists else raw.get('AlbumArtist', ''),
'album': raw.get('Album', ''),
'duration': t.duration,
'thumb': thumb,
})
elif active_server == 'navidrome' and navidrome_client:
tracks = navidrome_client.get_playlist_tracks(playlist_id)
for t in (tracks or []):
raw = t._data if hasattr(t, '_data') else {}
# Navidrome cover art via Subsonic API
cover_id = raw.get('coverArt', '') or raw.get('albumId', '')
thumb = f"/api/navidrome/cover/{cover_id}" if cover_id else ''
server_tracks.append({
'id': str(t.ratingKey),
'title': t.title,
'artist': raw.get('artist', ''),
'album': raw.get('album', ''),
'duration': t.duration,
'thumb': thumb,
})
# Get source tracks — prefer mirrored playlist, fall back to sync history
source_tracks = []
mirrored_id = request.args.get('mirrored_playlist_id')
if mirrored_id:
db = get_database()
raw_tracks = db.get_mirrored_playlist_tracks(int(mirrored_id))
# Build server art URL prefix for resolving relative thumb paths
_art_prefix = ''
_art_suffix = ''
if active_server == 'plex' and plex_client and plex_client.server:
_ab = getattr(plex_client.server, '_baseurl', '') or ''
_at = getattr(plex_client.server, '_token', '') or ''
if not _ab:
_pc = config_manager.get_plex_config()
_ab = (_pc.get('base_url', '') or '').rstrip('/')
_at = _at or _pc.get('token', '')
_art_prefix = _ab
_art_suffix = f"?X-Plex-Token={_at}" if _at else ''
def _resolve_thumb(url):
"""Make relative server thumb URLs absolute."""
if not url:
return ''
if url.startswith('http'):
return url
if url.startswith('/') and _art_prefix:
return f"{_art_prefix}{url}{_art_suffix}"
return url
# Build art lookup from server tracks we already fetched (no extra DB queries)
_server_art_map = {}
for svr in server_tracks:
if svr.get('thumb'):
key = f"{(svr.get('artist') or '').lower().strip()}|{svr['title'].lower().strip()}"
_server_art_map[key] = svr['thumb']
# Also store by title-only as fallback
_server_art_map[svr['title'].lower().strip()] = svr['thumb']
for t in raw_tracks:
img = t.get('image_url') or ''
if not img:
# Try artist+title first, fall back to title-only
key = f"{(t.get('artist_name') or '').lower().strip()}|{(t.get('track_name') or '').lower().strip()}"
img = _server_art_map.get(key, '') or _server_art_map.get((t.get('track_name') or '').lower().strip(), '')
source_tracks.append({
'name': t.get('track_name', ''),
'artist': t.get('artist_name', ''),
'album': t.get('album_name', ''),
'image_url': img,
'duration_ms': t.get('duration_ms', 0),
'position': t.get('position', 0),
})
elif playlist_name:
# Legacy fallback: cross-reference with sync history
db = get_database()
entries, _ = db.get_sync_history(page=1, limit=50)
for entry in entries:
if entry.get('playlist_name', '').lower() == playlist_name.lower():
full_entry = db.get_sync_history_entry(entry['id'])
if full_entry:
try:
tr = json.loads(full_entry.get('track_results') or '[]')
source_tracks = tr if isinstance(tr, list) else []
except (json.JSONDecodeError, TypeError):
pass
if not source_tracks:
try:
source_tracks = json.loads(full_entry.get('tracks_json') or '[]')
except (json.JSONDecodeError, TypeError):
pass
break
# Build combined view with two-pass matching (exact then fuzzy)
from difflib import SequenceMatcher
combined = []
used_server_indices = set()
unmatched_source = [] # (index_in_combined, src_dict) for fuzzy second pass
# Pass 1: Exact title match
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),
}
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():
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
for combo_idx, src_entry in unmatched_source:
src_key = f"{src_entry['artist']} {src_entry['name']}".lower().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()
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({
"success": True,
"server_type": active_server,
"playlist_name": playlist_name,
"tracks": combined,
"server_track_count": len(server_tracks),
"source_track_count": len(source_tracks),
})
except Exception as e:
logger.error(f"Error getting server playlist tracks: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/server/playlist/<playlist_id>/replace-track', methods=['POST'])
def server_playlist_replace_track(playlist_id):
"""Replace a track in a server playlist. Rebuilds the playlist with the swap."""
try:
data = request.get_json()
old_track_id = data.get('old_track_id')
new_track_id = data.get('new_track_id')
playlist_name = data.get('playlist_name', '')
if not old_track_id or not new_track_id:
return jsonify({"success": False, "error": "old_track_id and new_track_id required"}), 400
if not playlist_name:
return jsonify({"success": False, "error": "playlist_name required"}), 400
active_server = config_manager.get_active_media_server()
if active_server == 'plex' and plex_client:
# Use raw Plex API - fetch playlist directly
try:
raw_playlist = plex_client.server.playlist(playlist_name)
except Exception:
return jsonify({"success": False, "error": "Playlist not found on server"}), 404
# Build new track list with replacement
new_tracks = []
replaced = False
for item in raw_playlist.items():
if str(item.ratingKey) == str(old_track_id) and not replaced:
new_item = plex_client.server.fetchItem(int(new_track_id))
if new_item:
new_tracks.append(new_item)
replaced = True
else:
new_tracks.append(item)
else:
new_tracks.append(item)
if replaced:
# 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=new_tracks)
return jsonify({"success": True, "message": "Track replaced", "new_playlist_id": str(new_pl.ratingKey)})
else:
return jsonify({"success": False, "error": "Old track not found in playlist"}), 404
elif active_server == 'jellyfin' and jellyfin_client:
current_tracks = jellyfin_client.get_playlist_tracks(playlist_id)
new_track_ids = []
replaced = False
for t in (current_tracks or []):
tid = str(t.ratingKey)
if tid == str(old_track_id) and not replaced:
new_track_ids.append(new_track_id)
replaced = True
else:
new_track_ids.append(tid)
if replaced:
new_track_objs = [type('T', (), {'ratingKey': tid, 'title': ''})() for tid in new_track_ids]
jellyfin_client.update_playlist(playlist_name, new_track_objs)
return jsonify({"success": True, "message": "Track replaced"})
return jsonify({"success": False, "error": "Old track not found"}), 404
elif active_server == 'navidrome' and navidrome_client:
current_tracks = navidrome_client.get_playlist_tracks(playlist_id)
new_track_ids = []
replaced = False
for t in (current_tracks or []):
tid = str(t.ratingKey)
if tid == str(old_track_id) and not replaced:
new_track_ids.append(new_track_id)
replaced = True
else:
new_track_ids.append(tid)
if replaced:
new_track_objs = [type('T', (), {'ratingKey': tid, 'title': ''})() for tid in new_track_ids]
navidrome_client.create_playlist(playlist_name, new_track_objs, playlist_id=playlist_id)
return jsonify({"success": True, "message": "Track replaced"})
return jsonify({"success": False, "error": "Old track not found"}), 404
return jsonify({"success": False, "error": f"Unsupported server: {active_server}"}), 400
except Exception as e:
logger.error(f"Error replacing track in server playlist: {e}", exc_info=True)
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/server/playlist/<playlist_id>/add-track', methods=['POST'])
def server_playlist_add_track(playlist_id):
"""Add a track to a server playlist at a specific position."""
try:
data = request.get_json()
track_id = data.get('track_id')
playlist_name = data.get('playlist_name', '')
position = data.get('position') # 0-based index; None = append
if not track_id:
return jsonify({"success": False, "error": "track_id required"}), 400
if not playlist_name:
return jsonify({"success": False, "error": "playlist_name required"}), 400
active_server = config_manager.get_active_media_server()
if active_server == 'plex' and plex_client:
try:
raw_playlist = plex_client.server.playlist(playlist_name)
except Exception:
return jsonify({"success": False, "error": "Playlist not found"}), 404
new_item = plex_client.server.fetchItem(int(track_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}'")
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)
return jsonify({"success": True, "message": "Track added", "new_playlist_id": new_id})
elif active_server == 'jellyfin' and jellyfin_client:
current_tracks = jellyfin_client.get_playlist_tracks(playlist_id) or []
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)
track_ids.insert(pos, track_id)
new_track_objs = [type('T', (), {'ratingKey': tid, 'title': ''})() for tid in track_ids]
jellyfin_client.update_playlist(playlist_name, new_track_objs)
return jsonify({"success": True, "message": "Track added"})
elif active_server == 'navidrome' and navidrome_client:
current_tracks = navidrome_client.get_playlist_tracks(playlist_id) or []
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)
track_ids.insert(pos, track_id)
new_track_objs = [type('T', (), {'ratingKey': tid, 'title': ''})() for tid in track_ids]
navidrome_client.create_playlist(playlist_name, new_track_objs, playlist_id=playlist_id)
return jsonify({"success": True, "message": "Track added"})
return jsonify({"success": False, "error": f"Unsupported server: {active_server}"}), 400
except Exception as e:
logger.error(f"Error adding track to server playlist: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/server/playlist/<playlist_id>/remove-track', methods=['POST'])
def server_playlist_remove_track(playlist_id):
"""Remove a track from a server playlist by its server track ID."""
try:
data = request.get_json()
remove_track_id = data.get('track_id')
playlist_name = data.get('playlist_name', '')
if not remove_track_id:
return jsonify({"success": False, "error": "track_id required"}), 400
if not playlist_name:
return jsonify({"success": False, "error": "playlist_name required"}), 400
active_server = config_manager.get_active_media_server()
if active_server == 'plex' and plex_client:
try:
raw_playlist = plex_client.server.playlist(playlist_name)
except Exception:
return jsonify({"success": False, "error": "Playlist not found"}), 404
# Rebuild without the target track
current_items = list(raw_playlist.items())
new_items = [item for item in current_items if str(item.ratingKey) != str(remove_track_id)]
if len(new_items) == len(current_items):
return jsonify({"success": False, "error": "Track not found in playlist"}), 404
raw_playlist.delete()
if new_items:
from plexapi.playlist import Playlist
new_pl = Playlist.create(plex_client.server, playlist_name, items=new_items)
return jsonify({"success": True, "message": "Track removed", "new_playlist_id": str(new_pl.ratingKey)})
return jsonify({"success": True, "message": "Track removed (playlist now empty)"})
elif active_server == 'jellyfin' and jellyfin_client:
current_tracks = jellyfin_client.get_playlist_tracks(playlist_id) or []
new_ids = [str(t.ratingKey) for t in current_tracks if str(t.ratingKey) != str(remove_track_id)]
if len(new_ids) == len(current_tracks):
return jsonify({"success": False, "error": "Track not found in playlist"}), 404
new_track_objs = [type('T', (), {'ratingKey': tid, 'title': ''})() for tid in new_ids]
jellyfin_client.update_playlist(playlist_name, new_track_objs)
return jsonify({"success": True, "message": "Track removed"})
elif active_server == 'navidrome' and navidrome_client:
current_tracks = navidrome_client.get_playlist_tracks(playlist_id) or []
new_ids = [str(t.ratingKey) for t in current_tracks if str(t.ratingKey) != str(remove_track_id)]
if len(new_ids) == len(current_tracks):
return jsonify({"success": False, "error": "Track not found in playlist"}), 404
new_track_objs = [type('T', (), {'ratingKey': tid, 'title': ''})() for tid in new_ids]
navidrome_client.create_playlist(playlist_name, new_track_objs, playlist_id=playlist_id)
return jsonify({"success": True, "message": "Track removed"})
return jsonify({"success": False, "error": f"Unsupported server: {active_server}"}), 400
except Exception as e:
logger.error(f"Error removing track from server playlist: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/library/search-tracks', methods=['GET'])
def library_search_tracks():
"""Search SoulSync's local database for tracks (for manual match correction)."""
try:
query = request.args.get('q', '').strip()
limit = int(request.args.get('limit', 10))
if not query:
return jsonify({"success": True, "tracks": []})
active_server = config_manager.get_active_media_server()
database = get_database()
# Build thumb URL resolver for this server
_art_prefix = ''
_art_suffix = ''
if active_server == 'plex' and plex_client and plex_client.server:
_ab = getattr(plex_client.server, '_baseurl', '') or ''
_at = getattr(plex_client.server, '_token', '') or ''
if not _ab:
_pc = config_manager.get_plex_config()
_ab = (_pc.get('base_url', '') or '').rstrip('/')
_at = _at or _pc.get('token', '')
_art_prefix = _ab
_art_suffix = f"?X-Plex-Token={_at}" if _at else ''
def _resolve_search_thumb(url):
if not url:
return ''
if url.startswith('http'):
return url
if url.startswith('/') and _art_prefix:
return f"{_art_prefix}{url}{_art_suffix}"
return url
results = database.search_tracks(title=query, artist='', limit=limit, server_source=active_server)
tracks = []
for t in results:
tracks.append({
'id': t.id,
'title': t.title,
'artist_name': t.artist_name,
'album_title': getattr(t, 'album_title', ''),
'album_thumb_url': _resolve_search_thumb(getattr(t, 'album_thumb_url', '')),
'file_path': getattr(t, 'file_path', ''),
'bitrate': getattr(t, 'bitrate', 0),
'duration': getattr(t, 'duration', 0),
'server_source': getattr(t, 'server_source', ''),
})
return jsonify({"success": True, "tracks": tracks})
except Exception as e:
logger.error(f"Error searching library tracks: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/playlists/<playlist_id>/start-missing-process', methods=['POST'])
def start_missing_tracks_process(playlist_id):
"""

View file

@ -1080,7 +1080,11 @@
<!-- Left Panel: Tabbed Playlist Section -->
<div class="sync-main-panel">
<div class="sync-tabs">
<button class="sync-tab-button active" data-tab="spotify">
<button class="sync-tab-button sync-tab-server active" data-tab="server">
<span class="tab-icon server-icon"></span> Server Playlists
</button>
<div class="sync-tab-divider"></div>
<button class="sync-tab-button" data-tab="spotify">
<span class="tab-icon spotify-icon"></span> Spotify
</button>
<button class="sync-tab-button" data-tab="spotify-public">
@ -1107,7 +1111,7 @@
</div>
<!-- Spotify Tab Content -->
<div class="sync-tab-content active" id="spotify-tab-content">
<div class="sync-tab-content" id="spotify-tab-content">
<div class="playlist-header">
<h3>Your Spotify Playlists</h3>
<button class="refresh-button" id="spotify-refresh-btn">🔄 Refresh</button>
@ -1959,6 +1963,75 @@
<div class="playlist-placeholder">Playlists you parse from any service will appear here as persistent backups.</div>
</div>
</div>
<!-- Server Playlist Manager Tab -->
<div class="sync-tab-content active" id="server-tab-content">
<div class="playlist-header">
<h3 id="server-tab-title">Server Playlists</h3>
<button class="refresh-button" id="server-refresh-btn" onclick="loadServerPlaylists()">🔄 Refresh</button>
</div>
<div id="server-playlist-view">
<!-- Playlist list (default view) -->
<div class="playlist-scroll-container" id="server-playlist-container">
<div class="playlist-placeholder">Playlists from your media server will load automatically.</div>
</div>
<!-- Disambiguation modal -->
<div id="server-disambig-overlay" class="server-disambig-overlay hidden">
<div class="server-disambig-modal">
<div class="server-disambig-header">
<div>
<h3 class="server-disambig-title">Multiple Sources Found</h3>
<p class="server-disambig-subtitle" id="server-disambig-subtitle"></p>
</div>
<button class="server-disambig-close" onclick="closeServerDisambig()">&times;</button>
</div>
<div class="server-disambig-list" id="server-disambig-list"></div>
</div>
</div>
<!-- Comparison editor -->
<div id="server-editor" style="display: none;">
<div class="server-editor-header">
<button class="server-editor-back" onclick="serverEditorBack()">← Back</button>
<div class="server-editor-info">
<h4 class="server-editor-name" id="server-editor-name"></h4>
<span class="server-editor-meta" id="server-editor-meta"></span>
</div>
<div class="server-editor-stats" id="server-editor-stats"></div>
<button class="server-editor-refresh" onclick="_serverEditorRefresh()" title="Re-fetch from server">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2v6h-6M3 12a9 9 0 0115.356-6.356L21 8M3 22v-6h6M21 12a9 9 0 01-15.356 6.356L3 16"/></svg>
</button>
</div>
<div id="server-no-source-banner" class="server-no-source-banner" style="display:none">
No mirrored playlist found matching this name. Showing server tracks only.
</div>
<div class="server-editor-filters">
<button class="discog-filter active" data-filter="all" onclick="_serverEditorFilter(this, 'all')">All</button>
<button class="discog-filter" data-filter="matched" onclick="_serverEditorFilter(this, 'matched')">Matched</button>
<button class="discog-filter" data-filter="missing" onclick="_serverEditorFilter(this, 'missing')">Missing</button>
<button class="discog-filter" data-filter="extra" onclick="_serverEditorFilter(this, 'extra')">Extra</button>
</div>
<div class="server-compare-columns">
<div class="server-compare-col source" id="server-col-source">
<div class="server-col-header">
<span class="server-col-icon" id="server-col-source-icon"></span>
<span class="server-col-label" id="server-col-source-label">Source</span>
<span class="server-col-count" id="server-col-source-count"></span>
</div>
<div class="server-col-scroll" id="server-col-source-scroll"></div>
</div>
<div class="server-compare-col server" id="server-col-server">
<div class="server-col-header">
<span class="server-col-icon" id="server-col-server-icon"></span>
<span class="server-col-label" id="server-col-server-label">Server</span>
<span class="server-col-count" id="server-col-server-count"></span>
</div>
<div class="server-col-scroll" id="server-col-server-scroll"></div>
</div>
</div>
<div class="server-editor-footer" id="server-editor-footer"></div>
</div>
</div>
</div>
</div>
<!-- Right Panel: Sidebar with Options & Logging -->

View file

@ -3403,6 +3403,7 @@ function closeHelperSearch() {
const WHATS_NEW = {
'2.1': [
// Newest features first
{ title: 'Server Playlist Manager', desc: 'Compare source playlists against your media server — find missing tracks, swap wrong matches, remove extras with a dual-column editor', page: 'sync' },
{ title: 'Sync History Dashboard', desc: 'Dashboard shows recent syncs as cards — click for per-track match details with confidence scores and album art' },
{ title: 'Fix Japanese/CJK Soulseek Searches', desc: 'Japanese kanji no longer mangled into Chinese pinyin — searches now use original characters' },
{ title: 'Fix Partial Title Matching', desc: '"Believe" no longer falsely matches "Believe In Me" — length ratio penalty prevents prefix false positives' },

View file

@ -9163,6 +9163,12 @@ async function loadDashboardData() {
async function loadSyncData() {
// This is called when the sync page is navigated to.
// Load server playlists first (default active tab)
if (!window._serverPlaylistsLoaded) {
window._serverPlaylistsLoaded = true;
loadServerPlaylists(); // Don't await — load in background
}
if (!spotifyPlaylistsLoaded) {
await loadSpotifyPlaylists();
}
@ -25634,6 +25640,12 @@ function initializeSyncPage() {
loadMirroredPlaylists();
}
// Auto-load server playlists on first tab activation
if (tabId === 'server' && !window._serverPlaylistsLoaded) {
window._serverPlaylistsLoaded = true;
loadServerPlaylists();
}
// Refresh Beatport download bubbles when switching to the Beatport tab
if (tabId === 'beatport') {
showBeatportDownloadsSection();
@ -64518,6 +64530,733 @@ window.addEventListener('resize', () => {
// DASHBOARD — Recent Syncs Section
// ==================================================================================
// ==================================================================================
// SERVER PLAYLIST MANAGER — Sync Page Server Tab
// ==================================================================================
let _serverPlaylists = [];
let _serverEditorState = { playlistId: null, playlistName: '', tracks: [] };
async function loadServerPlaylists() {
const container = document.getElementById('server-playlist-container');
const editor = document.getElementById('server-editor');
const btn = document.getElementById('server-refresh-btn');
if (editor) editor.style.display = 'none';
if (container) container.style.display = '';
if (btn) { btn.disabled = true; btn.textContent = '🔄 Loading...'; }
// Show skeleton loader
if (container) {
container.innerHTML = `<div class="server-pl-grid">${Array.from({length: 6}, (_, i) => `
<div class="server-pl-card server-pl-skeleton" style="animation-delay: ${i * 0.06}s">
<div class="server-pl-card-top">
<div class="skeleton-box" style="width:44px;height:44px;border-radius:12px"></div>
<div class="skeleton-box" style="width:28px;height:28px;border-radius:8px"></div>
</div>
<div class="server-pl-card-body">
<div class="skeleton-box" style="width:${60 + Math.random()*30}%;height:14px;border-radius:4px;margin-bottom:8px"></div>
<div class="skeleton-box" style="width:40%;height:11px;border-radius:4px"></div>
</div>
<div class="server-pl-card-footer" style="border-top:1px solid rgba(255,255,255,0.05);padding-top:12px">
<div class="skeleton-box" style="width:60px;height:10px;border-radius:3px"></div>
</div>
</div>`).join('')}</div>`;
}
try {
// Fetch server playlists and mirrored playlists in parallel
const [serverRes, mirroredRes] = await Promise.all([
fetch('/api/server/playlists'),
fetch('/api/mirrored-playlists'),
]);
const data = await serverRes.json();
let mirroredAll = [];
try { mirroredAll = await mirroredRes.json(); } catch (_) {}
if (!Array.isArray(mirroredAll)) mirroredAll = [];
if (!data.success || !data.playlists) {
if (container) container.innerHTML = `<div class="playlist-placeholder">${data.error || 'Could not load server playlists'}</div>`;
return;
}
// Only show server playlists that have a matching mirrored playlist
const mirroredNames = new Set(mirroredAll.map(p => p.name.trim().toLowerCase()));
const filtered = data.playlists.filter(pl => mirroredNames.has(pl.name.trim().toLowerCase()));
_serverPlaylists = filtered;
const title = document.getElementById('server-tab-title');
if (title) title.textContent = `Server Playlists (${data.server_type ? data.server_type.charAt(0).toUpperCase() + data.server_type.slice(1) : ''})`;
if (filtered.length === 0) {
if (container) container.innerHTML = '<div class="playlist-placeholder">No synced playlists found. Only server playlists that match your mirrored playlists are shown here.</div>';
return;
}
// Server type icon SVG
const serverIcons = {
plex: '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M11.643 0H4.68l7.679 12L4.68 24h6.963L19.32 12z"/></svg>',
jellyfin: '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M12 2C8.5 2 6 5.1 6 9c0 2.4 1.2 5.5 3.3 8.7.7 1 1.5 2 2.2 2.9.2.3.4.3.5.4.1 0 .3-.1.5-.4.7-.9 1.5-1.9 2.2-2.9C16.8 14.5 18 11.4 18 9c0-3.9-2.5-7-6-7z"/></svg>',
navidrome: '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>'
};
const sIcon = serverIcons[data.server_type] || serverIcons.plex;
container.innerHTML = `<div class="server-pl-grid">${filtered.map((pl, i) => {
const hue = (i * 37 + 200) % 360;
const safeName = _esc(pl.name).replace(/'/g, "\\'");
return `
<div class="server-pl-card" onclick="openServerPlaylistEditor('${pl.id}', '${safeName}')" style="animation-delay: ${i * 0.04}s; --card-hue: ${hue}">
<div class="server-pl-card-glow"></div>
<div class="server-pl-card-top">
<div class="server-pl-card-icon-wrap">
<div class="server-pl-card-bars">
<span></span><span></span><span></span><span></span>
</div>
</div>
<div class="server-pl-card-badge">${sIcon}</div>
</div>
<div class="server-pl-card-body">
<div class="server-pl-card-name">${_esc(pl.name)}</div>
<div class="server-pl-card-meta">
<span class="server-pl-track-count">${pl.track_count}</span> tracks
</div>
</div>
<div class="server-pl-card-footer">
<span class="server-pl-card-action">Open Editor</span>
<span class="server-pl-card-arrow">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</span>
</div>
</div>`;
}).join('')}</div>`;
} catch (e) {
if (container) container.innerHTML = `<div class="playlist-placeholder">Error: ${e.message}</div>`;
} finally {
if (btn) { btn.disabled = false; btn.textContent = '🔄 Refresh'; }
}
}
async function openServerPlaylistEditor(playlistId, playlistName) {
// Step 1: Look up mirrored playlists by name
let mirroredPlaylists = [];
try {
const res = await fetch('/api/mirrored-playlists');
const all = await res.json();
mirroredPlaylists = (Array.isArray(all) ? all : []).filter(p =>
p.name.trim().toLowerCase() === playlistName.trim().toLowerCase()
);
} catch (e) {
console.error('Failed to fetch mirrored playlists:', e);
}
if (mirroredPlaylists.length === 1) {
// Single match — go straight to compare
_openServerCompareView(playlistId, playlistName, mirroredPlaylists[0]);
} else if (mirroredPlaylists.length === 0) {
// No match — server-only view
_openServerCompareView(playlistId, playlistName, null);
} else {
// Multiple — disambiguation
_showServerDisambig(playlistId, playlistName, mirroredPlaylists);
}
}
// ── Disambiguation ──
function _showServerDisambig(playlistId, playlistName, candidates) {
const overlay = document.getElementById('server-disambig-overlay');
const list = document.getElementById('server-disambig-list');
const subtitle = document.getElementById('server-disambig-subtitle');
if (!overlay || !list) return;
if (subtitle) subtitle.textContent = `"${playlistName}" was found on ${candidates.length} sources. Which one do you want to compare against?`;
const sourceIcons = { spotify: '🟢', tidal: '🌊', youtube: '▶️', beatport: '🎛️', deezer: '🟣', file: '📄' };
list.innerHTML = candidates.map((p, i) => {
const icon = sourceIcons[p.source] || '📋';
const ago = timeAgo(p.mirrored_at || p.updated_at);
return `
<div class="server-disambig-card" onclick="selectDisambigPlaylist('${playlistId}', '${_esc(playlistName).replace(/'/g, "\\'")}', ${p.id})" style="animation-delay: ${i * 0.06}s">
<div class="server-disambig-icon">${icon}</div>
<div class="server-disambig-info">
<div class="server-disambig-name">${_esc(p.name)}</div>
<div class="server-disambig-details">
<span class="source-badge">${_esc(p.source)}</span>
<span>${p.track_count || 0} tracks</span>
${p.owner ? `<span>by ${_esc(p.owner)}</span>` : ''}
<span>Mirrored ${ago}</span>
</div>
</div>
<div class="server-disambig-arrow">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</div>
</div>`;
}).join('');
overlay.classList.remove('hidden');
requestAnimationFrame(() => overlay.classList.add('visible'));
// Escape key + click backdrop to close
overlay.onclick = e => { if (e.target === overlay) closeServerDisambig(); };
window._disambigEsc = e => { if (e.key === 'Escape') closeServerDisambig(); };
document.addEventListener('keydown', window._disambigEsc);
}
function closeServerDisambig() {
const overlay = document.getElementById('server-disambig-overlay');
if (overlay) {
overlay.classList.remove('visible');
setTimeout(() => overlay.classList.add('hidden'), 250);
}
if (window._disambigEsc) { document.removeEventListener('keydown', window._disambigEsc); window._disambigEsc = null; }
}
async function selectDisambigPlaylist(playlistId, playlistName, mirroredId) {
closeServerDisambig();
try {
const res = await fetch(`/api/mirrored-playlists/${mirroredId}`);
const mirrored = await res.json();
_openServerCompareView(playlistId, playlistName, mirrored);
} catch (e) {
showToast('Failed to load mirrored playlist: ' + e.message, 'error');
}
}
// ── Compare View ──
async function _openServerCompareView(playlistId, playlistName, mirroredPlaylist) {
const container = document.getElementById('server-playlist-container');
const editor = document.getElementById('server-editor');
if (!editor) return;
if (container) container.style.display = 'none';
editor.style.display = '';
const nameEl = document.getElementById('server-editor-name');
const metaEl = document.getElementById('server-editor-meta');
const banner = document.getElementById('server-no-source-banner');
const sourceScroll = document.getElementById('server-col-source-scroll');
const serverScroll = document.getElementById('server-col-server-scroll');
if (nameEl) nameEl.textContent = playlistName;
if (metaEl) metaEl.textContent = 'Loading comparison...';
if (banner) banner.style.display = 'none';
if (sourceScroll) sourceScroll.innerHTML = '<div style="text-align:center;padding:30px;color:rgba(255,255,255,0.2);font-size:12px">Loading...</div>';
if (serverScroll) serverScroll.innerHTML = '<div style="text-align:center;padding:30px;color:rgba(255,255,255,0.2);font-size:12px">Loading...</div>';
// Store state
_serverEditorState = {
playlistId,
playlistName,
mirroredPlaylist,
tracks: [],
};
// Build API URL
let url = `/api/server/playlist/${playlistId}/tracks?name=${encodeURIComponent(playlistName)}`;
if (mirroredPlaylist && mirroredPlaylist.id) {
url += `&mirrored_playlist_id=${mirroredPlaylist.id}`;
}
try {
const response = await fetch(url);
const data = await response.json();
if (!data.success) {
if (metaEl) metaEl.textContent = data.error || 'Failed to load';
return;
}
_serverEditorState.tracks = data.tracks || [];
_serverEditorState.serverType = data.server_type;
const tracks = _serverEditorState.tracks;
const serverLabel = data.server_type ? data.server_type.charAt(0).toUpperCase() + data.server_type.slice(1) : 'Server';
// Header metadata
if (metaEl) metaEl.textContent = `${serverLabel} · ${data.server_track_count || 0} server tracks · ${data.source_track_count || 0} source tracks`;
// Show no-source banner if needed
if (!mirroredPlaylist && banner) {
banner.style.display = '';
}
// Stats, filter counts, footer
_updateCompareStats(tracks);
// Column headers
const sourceLabel = mirroredPlaylist ? (mirroredPlaylist.source || 'source').charAt(0).toUpperCase() + (mirroredPlaylist.source || 'source').slice(1) : 'Source';
const sourceIconMap = { spotify: '🟢', tidal: '🌊', youtube: '▶️', beatport: '🎛️', deezer: '🟣', file: '📄' };
const serverIconMap = { plex: '🟠', jellyfin: '🟣', navidrome: '🔵' };
const srcIconEl = document.getElementById('server-col-source-icon');
const srcLabelEl = document.getElementById('server-col-source-label');
const srcCountEl = document.getElementById('server-col-source-count');
const svrIconEl = document.getElementById('server-col-server-icon');
const svrLabelEl = document.getElementById('server-col-server-label');
const svrCountEl = document.getElementById('server-col-server-count');
if (srcIconEl) srcIconEl.textContent = mirroredPlaylist ? (sourceIconMap[mirroredPlaylist.source] || '📋') : '📋';
if (srcLabelEl) srcLabelEl.textContent = sourceLabel;
if (srcCountEl) srcCountEl.textContent = `${data.source_track_count || 0} tracks`;
if (svrIconEl) svrIconEl.textContent = serverIconMap[data.server_type] || '💻';
if (svrLabelEl) svrLabelEl.textContent = serverLabel;
if (svrCountEl) svrCountEl.textContent = `${data.server_track_count || 0} tracks`;
// Render columns
_renderCompareColumns(tracks);
// Scroll linking
_setupScrollLinking();
} catch (e) {
if (metaEl) metaEl.textContent = 'Error: ' + e.message;
}
}
function _updateCompareStats(tracks) {
const matched = tracks.filter(t => t.match_status === 'matched').length;
const missing = tracks.filter(t => t.match_status === 'missing').length;
const extra = tracks.filter(t => t.match_status === 'extra').length;
const statsEl = document.getElementById('server-editor-stats');
if (statsEl) {
statsEl.innerHTML = `
<div class="server-editor-stat"><div class="server-editor-stat-num matched">${matched}</div><div class="server-editor-stat-label">Matched</div></div>
<div class="server-editor-stat"><div class="server-editor-stat-num missing">${missing}</div><div class="server-editor-stat-label">Missing</div></div>
${extra > 0 ? `<div class="server-editor-stat"><div class="server-editor-stat-num extra">${extra}</div><div class="server-editor-stat-label">Extra</div></div>` : ''}
`;
}
const editor = document.getElementById('server-editor');
if (editor) {
editor.querySelectorAll('.discog-filter').forEach(btn => {
const f = btn.dataset.filter;
if (f === 'all') btn.textContent = `All (${tracks.length})`;
else if (f === 'matched') btn.textContent = `Matched (${matched})`;
else if (f === 'missing') btn.textContent = `Missing (${missing})`;
else if (f === 'extra') btn.textContent = `Extra (${extra})`;
});
}
const footer = document.getElementById('server-editor-footer');
if (footer) footer.textContent = `${matched}/${matched + missing} matched${extra > 0 ? ` · ${extra} extra on server` : ''}`;
}
function _formatDurationMs(ms) {
if (!ms) return '';
const s = Math.round(ms / 1000);
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
}
function _renderCompareColumns(tracks) {
const sourceScroll = document.getElementById('server-col-source-scroll');
const serverScroll = document.getElementById('server-col-server-scroll');
if (!sourceScroll || !serverScroll) return;
let sourceHTML = '';
let serverHTML = '';
tracks.forEach((t, i) => {
const src = t.source_track;
const svr = t.server_track;
const status = t.match_status;
const pairId = `pair-${i}`;
// ── Source (left) column ──
if (src) {
const dur = _formatDurationMs(src.duration_ms);
sourceHTML += `
<div class="server-track-item ${status}" data-pair-id="${pairId}" data-index="${i}" data-status="${status}"
onclick="_compareTrackClick('source', ${i})">
<div class="server-track-num">${src.position != null ? src.position : i + 1}</div>
<div class="server-track-art">
${src.image_url ? `<img src="${src.image_url}" alt="" loading="lazy">` : '<div class="server-track-art-empty"></div>'}
</div>
<div class="server-track-info">
<div class="server-track-title">${_esc(src.name)}</div>
<div class="server-track-artist">${_esc(src.artist || '')}</div>
</div>
<div class="server-track-duration">${dur}</div>
<div class="server-track-status-dot"></div>
</div>`;
} else {
// Extra track — no source
sourceHTML += `
<div class="server-track-item extra-gap" data-pair-id="${pairId}" data-index="${i}" data-status="${status}">
<div class="server-track-empty-slot extra">
<span class="empty-slot-label">No source track</span>
</div>
</div>`;
}
// ── Server (right) column ──
if (svr) {
const dur = _formatDurationMs(svr.duration);
const conf = t.confidence != null ? t.confidence : null;
let confBadge = '';
if (status === 'matched' && conf != null) {
const pct = Math.round(conf * 100);
const cls = pct >= 100 ? 'exact' : pct >= 90 ? 'high' : 'fuzzy';
confBadge = `<span class="server-track-conf ${cls}" title="Title similarity">${pct}%</span>`;
}
serverHTML += `
<div class="server-track-item ${status}" data-pair-id="${pairId}" data-index="${i}" data-status="${status}"
onclick="_compareTrackClick('server', ${i})">
<div class="server-track-num">${i + 1}</div>
<div class="server-track-art">
${svr.thumb ? `<img src="${svr.thumb}" alt="" loading="lazy">` : '<div class="server-track-art-empty"></div>'}
</div>
<div class="server-track-info">
<div class="server-track-title">${_esc(svr.title)}</div>
<div class="server-track-artist">${_esc(svr.artist || '')}</div>
</div>
${confBadge}
<div class="server-track-duration">${dur}</div>
<div class="server-track-actions">
${status === 'matched' ? `<button class="server-track-swap-btn" onclick="event.stopPropagation(); serverSearchReplace(${i}, 'replace')" title="Swap for different version">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M7 16V4m0 0L3 8m4-4l4 4M17 8v12m0 0l4-4m-4 4l-4-4"/></svg>
</button>` : ''}
<button class="server-track-remove-btn" onclick="event.stopPropagation(); _serverRemoveTrack(${i}, '${svr.id}')" title="Remove from playlist">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>
</div>
<div class="server-track-status-dot"></div>
</div>`;
} else {
// Missing on server — clickable empty slot
const hint = src ? `${src.artist || ''}${src.name}` : '';
serverHTML += `
<div class="server-track-item empty-slot-wrap" data-pair-id="${pairId}" data-index="${i}" data-status="${status}"
onclick="serverSearchReplace(${i}, 'add')">
<div class="server-track-empty-slot missing">
<div class="empty-slot-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
</div>
<span class="empty-slot-label">Find &amp; add</span>
<span class="empty-slot-hint">${_esc(hint)}</span>
</div>
</div>`;
}
});
sourceScroll.innerHTML = sourceHTML;
serverScroll.innerHTML = serverHTML;
}
function _setupScrollLinking() {
const sourceScroll = document.getElementById('server-col-source-scroll');
const serverScroll = document.getElementById('server-col-server-scroll');
if (!sourceScroll || !serverScroll) return;
// Remove old listeners to prevent accumulation on refresh
if (window._serverScrollAC) window._serverScrollAC.abort();
window._serverScrollAC = new AbortController();
const signal = window._serverScrollAC.signal;
let syncing = false;
const syncScroll = (from, to) => {
if (syncing) return;
syncing = true;
const maxFrom = from.scrollHeight - from.clientHeight;
const maxTo = to.scrollHeight - to.clientHeight;
if (maxFrom > 0 && maxTo > 0) {
to.scrollTop = (from.scrollTop / maxFrom) * maxTo;
}
requestAnimationFrame(() => { syncing = false; });
};
sourceScroll.addEventListener('scroll', () => syncScroll(sourceScroll, serverScroll), { signal });
serverScroll.addEventListener('scroll', () => syncScroll(serverScroll, sourceScroll), { signal });
}
function _compareTrackClick(side, index) {
const otherSide = side === 'source' ? 'server' : 'source';
const otherScroll = document.getElementById(`server-col-${otherSide}-scroll`);
const pairId = `pair-${index}`;
// Clear previous highlights
document.querySelectorAll('.server-track-item.highlighted').forEach(el => el.classList.remove('highlighted'));
// Highlight both paired items
document.querySelectorAll(`[data-pair-id="${pairId}"]`).forEach(el => el.classList.add('highlighted'));
// Scroll the OTHER column to show the paired item
const target = otherScroll?.querySelector(`[data-pair-id="${pairId}"]`);
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
function _serverEditorRefresh() {
_openServerCompareView(_serverEditorState.playlistId, _serverEditorState.playlistName, _serverEditorState.mirroredPlaylist);
}
function serverEditorBack() {
const container = document.getElementById('server-playlist-container');
const editor = document.getElementById('server-editor');
if (editor) editor.style.display = 'none';
if (container) container.style.display = '';
}
function _serverEditorFilter(btn, filter) {
btn.closest('.server-editor-filters').querySelectorAll('.discog-filter').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
// Filter both columns simultaneously
['server-col-source-scroll', 'server-col-server-scroll'].forEach(colId => {
document.querySelectorAll(`#${colId} .server-track-item`).forEach(item => {
const status = item.dataset.status;
item.style.display = (filter === 'all' || status === filter) ? '' : 'none';
});
});
}
// ── Track Search / Replace ──
async function serverSearchReplace(trackIndex, mode) {
const track = _serverEditorState.tracks[trackIndex];
if (!track) return;
const src = track.source_track || {};
const svr = track.server_track || {};
// Search by track name only first (more reliable than "artist trackname" blob)
const searchQuery = src.name ? src.name.trim() : (svr.title || '').trim();
const contextArtist = src.artist || svr.artist || '';
const contextName = src.name || svr.title || '';
const existing = document.getElementById('server-search-overlay');
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.id = 'server-search-overlay';
overlay.className = 'server-search-overlay';
overlay.innerHTML = `
<div class="server-search-popover" id="server-search-popover">
<div class="server-search-header">
<div>
<div class="server-search-title">${mode === 'replace' ? 'Swap Track' : 'Add Track to Server'}</div>
${contextName ? `<div class="server-search-context">
<span class="server-search-context-label">Source:</span>
<span class="server-search-context-artist">${_esc(contextArtist)}</span>
<span class="server-search-context-sep"></span>
<span class="server-search-context-name">${_esc(contextName)}</span>
</div>` : ''}
</div>
<button class="server-search-close" onclick="document.getElementById('server-search-overlay')?.remove()">&times;</button>
</div>
<div class="server-search-input-wrap">
<div class="server-search-input-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
</div>
<input type="text" class="server-search-input" id="server-search-input" value="${_esc(searchQuery)}" placeholder="Search by track name, artist, or album..." onkeydown="if(event.key==='Enter') _serverSearchExecute()">
</div>
<div class="server-search-results-header" id="server-search-results-header"></div>
<div class="server-search-results" id="server-search-results">
<div class="server-search-hint">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" style="margin-bottom:6px;opacity:0.4"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
<br>Searching...
</div>
</div>
</div>
`;
// Click overlay background or press Escape to close
overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
overlay._escHandler = e => { if (e.key === 'Escape') overlay.remove(); };
document.addEventListener('keydown', overlay._escHandler);
// Clean up Escape listener when overlay is removed
const obs = new MutationObserver(() => {
if (!document.body.contains(overlay)) { document.removeEventListener('keydown', overlay._escHandler); obs.disconnect(); }
});
obs.observe(document.body, { childList: true });
const popover = overlay.querySelector('.server-search-popover');
popover.dataset.trackIndex = trackIndex;
popover.dataset.mode = mode;
document.body.appendChild(overlay);
requestAnimationFrame(() => overlay.classList.add('visible'));
document.getElementById('server-search-input')?.focus();
document.getElementById('server-search-input')?.select();
_serverSearchExecute();
}
async function _serverSearchExecute() {
const input = document.getElementById('server-search-input');
const results = document.getElementById('server-search-results');
const resultsHeader = document.getElementById('server-search-results-header');
const popover = document.getElementById('server-search-popover');
if (!input || !results || !popover) return;
const query = input.value.trim();
if (!query) {
results.innerHTML = '<div class="server-search-hint">Type a search query</div>';
if (resultsHeader) resultsHeader.textContent = '';
return;
}
results.innerHTML = '<div class="server-search-hint"><div class="server-search-spinner"></div>Searching library...</div>';
if (resultsHeader) resultsHeader.textContent = '';
try {
const response = await fetch(`/api/library/search-tracks?q=${encodeURIComponent(query)}&limit=20`);
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
results.innerHTML = `<div class="server-search-hint">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" style="margin-bottom:6px;opacity:0.3"><path d="M9.172 14.828a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<br>No results found<br><span style="font-size:10px;opacity:0.5">Try different keywords or a shorter query</span>
</div>`;
return;
}
const trackIndex = parseInt(popover.dataset.trackIndex);
const mode = popover.dataset.mode;
if (resultsHeader) resultsHeader.textContent = `${data.tracks.length} result${data.tracks.length !== 1 ? 's' : ''}`;
results.innerHTML = data.tracks.map((t, i) => {
const ext = (t.file_path || '').split('.').pop().toUpperCase();
const format = ['FLAC','MP3','OPUS','OGG','M4A','AAC','WAV'].includes(ext) ? (ext === 'M4A' ? 'AAC' : ext) : '';
const dur = _formatDurationMs(t.duration);
const bitrateStr = t.bitrate ? `${t.bitrate}k` : '';
return `
<div class="server-search-result" onclick="_serverSelectTrack(${trackIndex}, '${mode}', '${t.id}', this)" style="animation-delay:${i * 0.03}s">
<div class="server-search-result-art">
${t.album_thumb_url ? `<img src="${t.album_thumb_url}" alt="" loading="lazy">` : '<div class="server-search-result-art-empty"></div>'}
</div>
<div class="server-search-result-info">
<div class="server-search-result-title">${_esc(t.title)}</div>
<div class="server-search-result-meta">${_esc(t.artist_name)}${t.album_title ? ` · ${_esc(t.album_title)}` : ''}</div>
</div>
<div class="server-search-result-details">
${format ? `<span class="server-search-format">${format}</span>` : ''}
${bitrateStr ? `<span class="server-search-bitrate">${bitrateStr}</span>` : ''}
${dur ? `<span class="server-search-dur">${dur}</span>` : ''}
</div>
<button class="server-search-select-btn">Select</button>
</div>
`;
}).join('');
} catch (e) {
results.innerHTML = `<div class="server-search-hint">Error: ${e.message}</div>`;
}
}
async function _serverSelectTrack(trackIndex, mode, newTrackId, el) {
const track = _serverEditorState.tracks[trackIndex];
if (!track) return;
const btn = el.querySelector('.server-search-select-btn');
if (btn) { btn.disabled = true; btn.textContent = '...'; }
try {
let response;
if (mode === 'replace') {
response = await fetch(`/api/server/playlist/${_serverEditorState.playlistId}/replace-track`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
old_track_id: track.server_track?.id,
new_track_id: newTrackId,
playlist_name: _serverEditorState.playlistName,
})
});
} else {
// Calculate the server-side position for this track
// Count how many server tracks exist before this index
let serverPos = 0;
for (let k = 0; k < trackIndex; k++) {
if (_serverEditorState.tracks[k]?.server_track) serverPos++;
}
response = await fetch(`/api/server/playlist/${_serverEditorState.playlistId}/add-track`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
track_id: newTrackId,
playlist_name: _serverEditorState.playlistName,
position: serverPos,
})
});
}
const data = await response.json();
if (data.success) {
showToast(data.message || 'Track updated', 'success');
document.getElementById('server-search-overlay')?.remove();
// 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);
}
} else {
showToast(data.error || 'Failed to update track', 'error');
if (btn) { btn.disabled = false; btn.textContent = 'Select'; }
}
} catch (e) {
showToast('Error: ' + e.message, 'error');
if (btn) { btn.disabled = false; btn.textContent = 'Select'; }
}
}
async function _serverRemoveTrack(trackIndex, serverTrackId) {
if (!serverTrackId) return;
const track = _serverEditorState.tracks[trackIndex];
const trackTitle = track?.server_track?.title || 'this track';
if (!await showConfirmDialog({ title: 'Remove Track', message: `Remove "${trackTitle}" from this playlist?`, confirmText: 'Remove', destructive: true })) return;
try {
const response = await fetch(`/api/server/playlist/${_serverEditorState.playlistId}/remove-track`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
track_id: serverTrackId,
playlist_name: _serverEditorState.playlistName,
})
});
const data = await response.json();
if (data.success) {
showToast(data.message || 'Track removed', 'success');
const pid = data.new_playlist_id || _serverEditorState.playlistId;
_serverEditorState.playlistId = pid;
_openServerCompareView(pid, _serverEditorState.playlistName, _serverEditorState.mirroredPlaylist);
} else {
showToast(data.error || 'Failed to remove track', 'error');
}
} catch (e) {
showToast('Error: ' + e.message, 'error');
}
}
// Auto-refresh sync cards every 30 seconds when on dashboard
setInterval(() => {
if (typeof currentPage !== 'undefined' && currentPage === 'dashboard') {

File diff suppressed because it is too large Load diff