diff --git a/database/music_database.py b/database/music_database.py index 026f6797..f8470d69 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -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 diff --git a/web_server.py b/web_server.py index 18bd58ac..3a94dbb4 100644 --- a/web_server.py +++ b/web_server.py @@ -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//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//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//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//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//start-missing-process', methods=['POST']) def start_missing_tracks_process(playlist_id): """ diff --git a/webui/index.html b/webui/index.html index bff0fa35..081395cc 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1080,7 +1080,11 @@
- +
+
-
+

Your Spotify Playlists

@@ -1959,6 +1963,75 @@
Playlists you parse from any service will appear here as persistent backups.
+ + +
+
+

Server Playlists

+ +
+
+ +
+
Playlists from your media server will load automatically.
+
+ + + + +
+
diff --git a/webui/static/helper.js b/webui/static/helper.js index b0376249..a7d289f3 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -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' }, diff --git a/webui/static/script.js b/webui/static/script.js index 7049c00c..177afe5f 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -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 = `
${Array.from({length: 6}, (_, i) => ` +
+
+
+
+
+
+
+
+
+ +
`).join('')}
`; + } + + 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 = `
${data.error || 'Could not load server playlists'}
`; + 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 = '
No synced playlists found. Only server playlists that match your mirrored playlists are shown here.
'; + return; + } + + // Server type icon SVG + const serverIcons = { + plex: '', + jellyfin: '', + navidrome: '' + }; + const sIcon = serverIcons[data.server_type] || serverIcons.plex; + + container.innerHTML = `
${filtered.map((pl, i) => { + const hue = (i * 37 + 200) % 360; + const safeName = _esc(pl.name).replace(/'/g, "\\'"); + return ` +
+
+
+
+
+ +
+
+
${sIcon}
+
+
+
${_esc(pl.name)}
+
+ ${pl.track_count} tracks +
+
+ +
`; + }).join('')}
`; + + } catch (e) { + if (container) container.innerHTML = `
Error: ${e.message}
`; + } 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 ` +
+
${icon}
+
+
${_esc(p.name)}
+
+ ${_esc(p.source)} + ${p.track_count || 0} tracks + ${p.owner ? `by ${_esc(p.owner)}` : ''} + Mirrored ${ago} +
+
+
+ +
+
`; + }).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 = '
Loading...
'; + if (serverScroll) serverScroll.innerHTML = '
Loading...
'; + + // 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 = ` +
${matched}
Matched
+
${missing}
Missing
+ ${extra > 0 ? `
${extra}
Extra
` : ''} + `; + } + + 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 += ` +
+
${src.position != null ? src.position : i + 1}
+
+ ${src.image_url ? `` : '
'} +
+
+
${_esc(src.name)}
+
${_esc(src.artist || '')}
+
+
${dur}
+
+
`; + } else { + // Extra track โ€” no source + sourceHTML += ` +
+
+ No source track +
+
`; + } + + // โ”€โ”€ 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 = `${pct}%`; + } + serverHTML += ` +
+
${i + 1}
+
+ ${svr.thumb ? `` : '
'} +
+
+
${_esc(svr.title)}
+
${_esc(svr.artist || '')}
+
+ ${confBadge} +
${dur}
+
+ ${status === 'matched' ? `` : ''} + +
+
+
`; + } else { + // Missing on server โ€” clickable empty slot + const hint = src ? `${src.artist || ''} โ€” ${src.name}` : ''; + serverHTML += ` +
+
+
+ +
+ Find & add + ${_esc(hint)} +
+
`; + } + }); + + 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 = ` +
+
+
+
${mode === 'replace' ? 'Swap Track' : 'Add Track to Server'}
+ ${contextName ? `
+ Source: + ${_esc(contextArtist)} + โ€” + ${_esc(contextName)} +
` : ''} +
+ +
+
+
+ +
+ +
+
+
+
+ +
Searching... +
+
+
+ `; + // 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 = '
Type a search query
'; + if (resultsHeader) resultsHeader.textContent = ''; + return; + } + + results.innerHTML = '
Searching library...
'; + 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 = `
+ +
No results found
Try different keywords or a shorter query +
`; + 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 ` +
+
+ ${t.album_thumb_url ? `` : '
'} +
+
+
${_esc(t.title)}
+
${_esc(t.artist_name)}${t.album_title ? ` ยท ${_esc(t.album_title)}` : ''}
+
+
+ ${format ? `${format}` : ''} + ${bitrateStr ? `${bitrateStr}` : ''} + ${dur ? `${dur}` : ''} +
+ +
+ `; + }).join(''); + + } catch (e) { + results.innerHTML = `
Error: ${e.message}
`; + } +} + +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') { diff --git a/webui/static/style.css b/webui/static/style.css index 4ab28cff..4f9a658c 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -9012,6 +9012,28 @@ body.helper-mode-active #dashboard-activity-feed:hover { box-shadow: 0 4px 15px rgba(29, 185, 84, 0.3); } +/* Server tab โ€” prominent first tab */ +.sync-tab-server { + flex: 1.4 !important; + font-size: 12px; + letter-spacing: 0.02em; + position: relative; +} +.sync-tab-server.active { + background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.9), rgba(var(--accent-rgb), 0.7)) !important; + color: #000 !important; + box-shadow: 0 4px 18px rgba(var(--accent-rgb), 0.35), 0 0 0 1px rgba(var(--accent-rgb), 0.2) !important; +} + +.sync-tab-divider { + width: 1px; + height: 20px; + background: rgba(255, 255, 255, 0.08); + flex-shrink: 0; + margin: 0 2px; + align-self: center; +} + .sync-tab-button:hover:not(.active) { background: rgba(255, 255, 255, 0.1); color: #ffffff; @@ -50849,3 +50871,1072 @@ tr.tag-diff-same { .sync-detail-table { font-size: 11px; } .sync-detail-table tbody td { max-width: 120px; } } + + +/* ================================================================================== + SERVER PLAYLIST MANAGER โ€” Sync Page Server Tab + ================================================================================== */ + +/* Playlist grid layout */ +.server-pl-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 14px; + padding: 4px 2px; +} + +/* Playlist cards */ +.server-pl-card { + position: relative; + display: flex; + flex-direction: column; + padding: 18px 16px 14px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 16px; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + overflow: hidden; + animation: sync-card-enter 0.35s ease both; +} + +.server-pl-card-glow { + position: absolute; + top: -40%; + left: -20%; + width: 140%; + height: 100%; + background: radial-gradient(ellipse at center, hsla(var(--card-hue), 70%, 55%, 0.08) 0%, transparent 70%); + pointer-events: none; + transition: opacity 0.4s; + opacity: 0.5; +} + +.server-pl-card:hover { + transform: translateY(-4px); + border-color: hsla(var(--card-hue), 60%, 60%, 0.25); + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.35), 0 0 20px hsla(var(--card-hue), 70%, 55%, 0.08); +} + +.server-pl-card:hover .server-pl-card-glow { + opacity: 1; +} + +/* Top row โ€” visual bars + server badge */ +.server-pl-card-top { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + position: relative; + z-index: 1; +} + +.server-pl-card-icon-wrap { + width: 44px; + height: 44px; + border-radius: 12px; + background: hsla(var(--card-hue), 50%, 50%, 0.12); + display: flex; + align-items: flex-end; + justify-content: center; + padding-bottom: 8px; + gap: 3px; +} + +/* Animated equalizer bars */ +.server-pl-card-bars { + display: flex; + align-items: flex-end; + gap: 2.5px; + height: 20px; +} + +.server-pl-card-bars span { + display: block; + width: 3.5px; + border-radius: 2px; + background: hsla(var(--card-hue), 65%, 60%, 0.7); + transition: height 0.3s ease; +} + +.server-pl-card-bars span:nth-child(1) { height: 8px; } +.server-pl-card-bars span:nth-child(2) { height: 14px; } +.server-pl-card-bars span:nth-child(3) { height: 10px; } +.server-pl-card-bars span:nth-child(4) { height: 6px; } + +.server-pl-card:hover .server-pl-card-bars span:nth-child(1) { height: 14px; animation: eq-bar 0.6s ease infinite alternate; } +.server-pl-card:hover .server-pl-card-bars span:nth-child(2) { height: 8px; animation: eq-bar 0.5s ease infinite alternate 0.1s; } +.server-pl-card:hover .server-pl-card-bars span:nth-child(3) { height: 16px; animation: eq-bar 0.55s ease infinite alternate 0.2s; } +.server-pl-card:hover .server-pl-card-bars span:nth-child(4) { height: 12px; animation: eq-bar 0.65s ease infinite alternate 0.15s; } + +@keyframes eq-bar { + 0% { height: 6px; } + 100% { height: 18px; } +} + +.server-pl-card-badge { + width: 28px; + height: 28px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.08); + display: flex; + align-items: center; + justify-content: center; + color: rgba(255, 255, 255, 0.35); + transition: color 0.3s, border-color 0.3s; +} + +.server-pl-card:hover .server-pl-card-badge { + color: rgba(255, 255, 255, 0.6); + border-color: rgba(255, 255, 255, 0.15); +} + +/* Card body */ +.server-pl-card-body { + position: relative; + z-index: 1; + margin-bottom: 14px; +} + +.server-pl-card-name { + font-size: 14px; + font-weight: 650; + color: rgba(255, 255, 255, 0.9); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + letter-spacing: -0.01em; + line-height: 1.3; +} + +.server-pl-card-meta { + font-size: 12px; + color: rgba(255, 255, 255, 0.35); + margin-top: 4px; +} + +.server-pl-track-count { + font-weight: 700; + color: hsla(var(--card-hue), 60%, 65%, 0.9); + font-variant-numeric: tabular-nums; +} + +/* Card footer */ +.server-pl-card-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding-top: 12px; + border-top: 1px solid rgba(255, 255, 255, 0.05); + position: relative; + z-index: 1; +} + +.server-pl-card-action { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: rgba(255, 255, 255, 0.25); + transition: color 0.3s; +} + +.server-pl-card:hover .server-pl-card-action { + color: hsla(var(--card-hue), 60%, 65%, 0.8); +} + +.server-pl-card-arrow { + color: rgba(255, 255, 255, 0.2); + transition: transform 0.3s, color 0.3s; + display: flex; + align-items: center; +} + +.server-pl-card:hover .server-pl-card-arrow { + transform: translateX(4px); + color: hsla(var(--card-hue), 60%, 65%, 0.7); +} + +/* Skeleton loading state */ +.server-pl-skeleton { + pointer-events: none; + --card-hue: 220; +} + +.server-pl-skeleton .skeleton-box { + background: linear-gradient(90deg, rgba(255,255,255,0.04) 25%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0.04) 75%); + background-size: 200% 100%; + animation: skeleton-shimmer 1.5s ease infinite; +} + +@keyframes skeleton-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +/* โ”€โ”€ Disambiguation Modal โ”€โ”€ */ +.server-disambig-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(8px); + z-index: 9000; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.25s ease; + pointer-events: none; +} +.server-disambig-overlay.visible { opacity: 1; pointer-events: auto; } +.server-disambig-overlay.hidden { display: none; } + +.server-disambig-modal { + background: rgba(22, 22, 30, 0.98); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 20px; + padding: 28px; + max-width: 520px; + width: 90vw; + box-shadow: 0 32px 80px rgba(0, 0, 0, 0.5); + transform: scale(0.95); + transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); +} +.server-disambig-overlay.visible .server-disambig-modal { transform: scale(1); } + +.server-disambig-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 20px; +} + +.server-disambig-title { + font-size: 16px; + font-weight: 700; + color: #fff; + margin: 0; +} + +.server-disambig-subtitle { + font-size: 12px; + color: rgba(255, 255, 255, 0.4); + margin: 4px 0 0; +} + +.server-disambig-close { + width: 32px; + height: 32px; + border: none; + background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.5); + border-radius: 50%; + font-size: 20px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: all 0.2s; +} +.server-disambig-close:hover { background: rgba(255, 255, 255, 0.12); color: #fff; } + +.server-disambig-card { + display: flex; + align-items: center; + gap: 14px; + padding: 14px 16px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); + cursor: pointer; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + margin-bottom: 8px; + animation: fadeSlideUp 0.3s ease forwards; + opacity: 0; +} +.server-disambig-card:hover { + background: rgba(255, 255, 255, 0.07); + border-color: rgba(var(--accent-rgb), 0.2); + transform: translateX(4px); +} + +.server-disambig-icon { + width: 44px; + height: 44px; + border-radius: 12px; + background: rgba(var(--accent-rgb), 0.1); + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + flex-shrink: 0; +} + +.server-disambig-info { flex: 1; min-width: 0; } + +.server-disambig-name { + font-size: 14px; + font-weight: 650; + color: rgba(255, 255, 255, 0.9); + margin-bottom: 4px; +} + +.server-disambig-details { + display: flex; + flex-wrap: wrap; + gap: 8px; + font-size: 11px; + color: rgba(255, 255, 255, 0.35); + align-items: center; +} + +.server-disambig-details .source-badge { + font-size: 9px; + padding: 2px 8px; + border-radius: 4px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + background: rgba(var(--accent-rgb), 0.12); + color: var(--accent); +} + +.server-disambig-arrow { + color: rgba(255, 255, 255, 0.2); + flex-shrink: 0; + transition: transform 0.25s, color 0.25s; +} +.server-disambig-card:hover .server-disambig-arrow { + transform: translateX(4px); + color: var(--accent); +} + +@keyframes fadeSlideUp { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +/* โ”€โ”€ Editor Header โ”€โ”€ */ +.server-editor-header { + display: flex; + align-items: center; + gap: 14px; + padding: 8px 0 12px; +} + +.server-editor-back { + padding: 6px 14px; + font-size: 12px; + font-weight: 600; + color: rgba(255, 255, 255, 0.6); + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; + flex-shrink: 0; +} +.server-editor-back:hover { background: rgba(255, 255, 255, 0.1); color: #fff; } + +.server-editor-info { flex: 1; min-width: 0; } + +.server-editor-refresh { + width: 32px; + height: 32px; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.04); + color: rgba(255, 255, 255, 0.4); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: all 0.2s; +} +.server-editor-refresh:hover { + background: rgba(255, 255, 255, 0.1); + color: #fff; + border-color: rgba(255, 255, 255, 0.15); +} + +.server-editor-name { + font-size: 16px; + font-weight: 700; + color: #fff; + margin: 0; +} + +.server-editor-meta { + font-size: 11px; + color: rgba(255, 255, 255, 0.35); +} + +.server-editor-stats { + display: flex; + gap: 10px; + flex-shrink: 0; +} + +.server-editor-stat { + text-align: center; + padding: 4px 10px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.05); +} + +.server-editor-stat-num { + font-size: 16px; + font-weight: 800; + line-height: 1.2; +} +.server-editor-stat-num.matched { color: #4caf50; } +.server-editor-stat-num.missing { color: #ef5350; } +.server-editor-stat-num.extra { color: #ffb74d; } + +.server-editor-stat-label { + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: rgba(255, 255, 255, 0.3); + font-weight: 600; +} + +/* No-source banner */ +.server-no-source-banner { + padding: 10px 16px; + background: rgba(255, 183, 77, 0.06); + border: 1px solid rgba(255, 183, 77, 0.15); + border-radius: 10px; + font-size: 12px; + color: rgba(255, 183, 77, 0.8); + margin-bottom: 10px; +} + +/* Editor filters */ +.server-editor-filters { + display: flex; + gap: 4px; + margin-bottom: 10px; +} + +/* โ”€โ”€ Dual-Column Compare Layout โ”€โ”€ */ +.server-compare-columns { + display: flex; + gap: 0; + height: calc(100vh - 340px); + min-height: 380px; + max-height: 620px; + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 14px; + overflow: hidden; +} + +.server-compare-col { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; +} +.server-compare-col.source { + border-right: 1px solid rgba(255, 255, 255, 0.06); +} + +.server-col-header { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + background: rgba(255, 255, 255, 0.02); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: rgba(255, 255, 255, 0.5); + flex-shrink: 0; +} + +.server-col-icon { + font-size: 14px; + display: flex; + align-items: center; +} + +.server-col-count { + margin-left: auto; + font-variant-numeric: tabular-nums; + font-size: 10px; + padding: 2px 8px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.4); +} + +.server-col-scroll { + flex: 1; + overflow-y: auto; + overscroll-behavior: contain; +} +.server-col-scroll::-webkit-scrollbar { width: 4px; } +.server-col-scroll::-webkit-scrollbar-track { background: transparent; } +.server-col-scroll::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.08); border-radius: 2px; } +.server-col-scroll::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.15); } + +/* โ”€โ”€ Track Items โ”€โ”€ */ +.server-track-item { + display: flex; + align-items: center; + gap: 10px; + padding: 7px 14px; + border-bottom: 1px solid rgba(255, 255, 255, 0.03); + cursor: pointer; + transition: background 0.15s, box-shadow 0.2s; + min-height: 50px; +} +.server-track-item:hover { background: rgba(255, 255, 255, 0.03); } +.server-track-item:last-child { border-bottom: none; } + +.server-track-item.highlighted { + background: rgba(100, 181, 246, 0.07); + box-shadow: inset 3px 0 0 rgba(100, 181, 246, 0.5); +} + +.server-track-num { + width: 22px; + font-size: 10px; + color: rgba(255, 255, 255, 0.2); + text-align: right; + flex-shrink: 0; + font-variant-numeric: tabular-nums; +} + +.server-track-art { + width: 36px; + height: 36px; + border-radius: 6px; + overflow: hidden; + flex-shrink: 0; +} +.server-track-art img { + width: 100%; + height: 100%; + object-fit: cover; +} +.server-track-art-empty { + width: 100%; + height: 100%; + background: rgba(255, 255, 255, 0.04); + border-radius: 6px; +} + +.server-track-info { + flex: 1; + min-width: 0; +} +.server-track-title { + font-size: 12px; + font-weight: 600; + color: rgba(255, 255, 255, 0.85); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.server-track-artist { + font-size: 10px; + color: rgba(255, 255, 255, 0.35); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 1px; +} + +.server-track-duration { + font-size: 10px; + color: rgba(255, 255, 255, 0.2); + flex-shrink: 0; + width: 34px; + text-align: right; + font-variant-numeric: tabular-nums; +} + +.server-track-status-dot { + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; +} +.server-track-item.matched .server-track-status-dot { + background: #4caf50; + box-shadow: 0 0 6px rgba(76, 175, 80, 0.35); +} +.server-track-item.missing .server-track-status-dot { + background: #ef5350; + box-shadow: 0 0 6px rgba(239, 83, 80, 0.35); +} +.server-track-item.extra .server-track-status-dot { + background: #ffb74d; + box-shadow: 0 0 6px rgba(255, 183, 77, 0.35); +} + +/* โ”€โ”€ Empty Slots (missing / extra gap) โ”€โ”€ */ +.server-track-empty-slot { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 6px 12px; + border-radius: 8px; + border: 1.5px dashed rgba(239, 83, 80, 0.2); + background: rgba(239, 83, 80, 0.02); + transition: all 0.2s; + min-height: 36px; +} +.server-track-empty-slot:hover { + border-color: rgba(239, 83, 80, 0.45); + background: rgba(239, 83, 80, 0.06); +} +.server-track-empty-slot.extra { + border-color: rgba(255, 183, 77, 0.15); + background: rgba(255, 183, 77, 0.02); +} + +.empty-slot-wrap { cursor: pointer; } +.empty-slot-wrap:hover .empty-slot-label { color: rgba(239, 83, 80, 0.9); } + +.empty-slot-icon { + color: rgba(239, 83, 80, 0.4); + flex-shrink: 0; + display: flex; + align-items: center; +} + +.empty-slot-label { + font-size: 11px; + font-weight: 600; + color: rgba(239, 83, 80, 0.5); + transition: color 0.2s; + white-space: nowrap; +} + +.empty-slot-hint { + font-size: 10px; + color: rgba(255, 255, 255, 0.15); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + min-width: 0; +} + +/* Confidence badge on matched tracks */ +.server-track-conf { + font-size: 9px; + font-weight: 700; + padding: 2px 6px; + border-radius: 4px; + flex-shrink: 0; + font-variant-numeric: tabular-nums; + letter-spacing: 0.02em; +} +.server-track-conf.exact { + background: rgba(76, 175, 80, 0.12); + color: #4caf50; +} +.server-track-conf.high { + background: rgba(139, 195, 74, 0.12); + color: #8bc34a; +} +.server-track-conf.fuzzy { + background: rgba(255, 183, 77, 0.12); + color: #ffb74d; +} + +/* Track action buttons (swap + remove) */ +.server-track-actions { + display: flex; + align-items: center; + gap: 2px; + flex-shrink: 0; + opacity: 0; + transition: opacity 0.15s; +} +.server-track-item:hover .server-track-actions { opacity: 1; } + +.server-track-swap-btn { + width: 22px; + height: 22px; + border-radius: 6px; + border: 1px solid transparent; + background: transparent; + color: rgba(255, 255, 255, 0.15); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: all 0.2s; + padding: 0; +} +.server-track-swap-btn:hover { + background: rgba(var(--accent-rgb), 0.12); + border-color: rgba(var(--accent-rgb), 0.25); + color: var(--accent); +} + +.server-track-remove-btn { + width: 22px; + height: 22px; + border-radius: 6px; + border: 1px solid transparent; + background: transparent; + color: rgba(255, 255, 255, 0.15); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: all 0.2s; + padding: 0; +} +.server-track-remove-btn:hover { + background: rgba(239, 83, 80, 0.12); + border-color: rgba(239, 83, 80, 0.25); + color: #ef5350; +} + +.extra-gap { opacity: 0.5; } +.extra-gap .server-track-empty-slot { border-color: rgba(255, 183, 77, 0.15); } +.extra-gap .empty-slot-label { color: rgba(255, 183, 77, 0.4); } + +/* โ”€โ”€ Editor Footer โ”€โ”€ */ +.server-editor-footer { + padding: 10px 0; + font-size: 12px; + color: rgba(255, 255, 255, 0.35); + text-align: center; +} + +/* โ”€โ”€ Search Modal โ”€โ”€ */ +.server-search-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + backdrop-filter: blur(4px); + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.2s ease; +} +.server-search-overlay.visible { opacity: 1; } + +.server-search-popover { + width: 580px; + max-width: 95vw; + max-height: 80vh; + background: rgba(20, 20, 28, 0.98); + backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 20px; + box-shadow: 0 32px 80px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(255,255,255,0.03) inset; + display: flex; + flex-direction: column; + transform: scale(0.96) translateY(8px); + transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); +} +.server-search-overlay.visible .server-search-popover { + transform: scale(1) translateY(0); +} + +.server-search-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 20px 20px 0; +} + +.server-search-title { + font-size: 16px; + font-weight: 700; + color: #fff; + margin-bottom: 4px; +} + +.server-search-context { + font-size: 11px; + color: rgba(255, 255, 255, 0.3); + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; +} +.server-search-context-label { + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + font-size: 9px; + color: rgba(255, 255, 255, 0.25); + background: rgba(255, 255, 255, 0.05); + padding: 1px 6px; + border-radius: 3px; +} +.server-search-context-artist { color: rgba(255, 255, 255, 0.5); font-weight: 600; } +.server-search-context-sep { color: rgba(255, 255, 255, 0.15); } +.server-search-context-name { color: var(--accent); font-weight: 600; } + +.server-search-close { + width: 30px; + height: 30px; + border: none; + background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.4); + border-radius: 50%; + font-size: 18px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: all 0.2s; +} +.server-search-close:hover { background: rgba(255, 255, 255, 0.12); color: #fff; } + +.server-search-input-wrap { + display: flex; + align-items: center; + gap: 0; + margin: 14px 20px 0; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + transition: border-color 0.2s; +} +.server-search-input-wrap:focus-within { + border-color: rgba(var(--accent-rgb), 0.4); +} + +.server-search-input-icon { + padding: 0 4px 0 14px; + color: rgba(255, 255, 255, 0.25); + display: flex; + align-items: center; + flex-shrink: 0; +} + +.server-search-input { + flex: 1; + padding: 11px 14px 11px 8px; + background: transparent; + border: none; + color: #fff; + font-size: 13px; + outline: none; +} + +.server-search-results-header { + padding: 10px 20px 0; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: rgba(255, 255, 255, 0.25); + min-height: 20px; +} + +.server-search-results { + overflow-y: auto; + flex: 1; + padding: 6px 12px 16px; + max-height: 420px; +} +.server-search-results::-webkit-scrollbar { width: 4px; } +.server-search-results::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 2px; } + +.server-search-hint { + text-align: center; + padding: 32px 20px; + color: rgba(255, 255, 255, 0.2); + font-size: 12px; + line-height: 1.6; +} + +.server-search-spinner { + width: 20px; + height: 20px; + border: 2px solid rgba(255,255,255,0.1); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.6s linear infinite; + margin: 0 auto 8px; +} +@keyframes spin { to { transform: rotate(360deg); } } + +.server-search-result { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 10px; + border-radius: 12px; + margin-bottom: 2px; + cursor: pointer; + transition: background 0.15s; + animation: fadeSlideUp 0.2s ease both; +} +.server-search-result:hover { background: rgba(255, 255, 255, 0.05); } + +.server-search-result-art { + width: 44px; + height: 44px; + border-radius: 8px; + overflow: hidden; + flex-shrink: 0; +} +.server-search-result-art img { + width: 100%; + height: 100%; + object-fit: cover; +} +.server-search-result-art-empty { + width: 100%; + height: 100%; + background: rgba(255, 255, 255, 0.04); + border-radius: 8px; +} + +.server-search-result-info { + flex: 1; + min-width: 0; +} + +.server-search-result-title { + font-size: 13px; + font-weight: 600; + color: rgba(255, 255, 255, 0.9); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.server-search-result-meta { + font-size: 11px; + color: rgba(255, 255, 255, 0.35); + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.server-search-result-details { + display: flex; + align-items: center; + gap: 5px; + flex-shrink: 0; +} + +.server-search-format { + padding: 2px 6px; + border-radius: 4px; + font-size: 9px; + font-weight: 700; + background: rgba(var(--accent-rgb), 0.12); + color: rgba(var(--accent-rgb), 0.85); + letter-spacing: 0.03em; +} + +.server-search-bitrate { + font-size: 9px; + font-weight: 600; + color: rgba(255, 255, 255, 0.2); + font-variant-numeric: tabular-nums; +} + +.server-search-dur { + font-size: 10px; + color: rgba(255, 255, 255, 0.2); + font-variant-numeric: tabular-nums; + min-width: 28px; + text-align: right; +} + +.server-search-select-btn { + padding: 6px 14px; + background: rgba(var(--accent-rgb), 0.1); + border: 1px solid rgba(var(--accent-rgb), 0.2); + border-radius: 8px; + color: var(--accent); + font-size: 11px; + font-weight: 600; + cursor: pointer; + flex-shrink: 0; + transition: all 0.2s; +} +.server-search-select-btn:hover { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +/* Server tab icon */ +.tab-icon.server-icon::before { + content: '๐Ÿ’ป'; +} + +/* โ”€โ”€ Server Playlist Manager โ€” Mobile โ”€โ”€ */ +@media (max-width: 768px) { + .server-pl-grid { + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 10px; + } + + .server-pl-card { padding: 14px 12px 12px; } + .server-pl-card-icon-wrap { width: 36px; height: 36px; } + .server-pl-card-name { font-size: 12px; } + + /* Stack columns vertically with toggle */ + .server-compare-columns { + flex-direction: column; + height: auto; + max-height: none; + } + + .server-compare-col { + max-height: 50vh; + } + .server-compare-col.source { + border-right: none; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + } + + .server-editor-header { + flex-wrap: wrap; + gap: 8px; + } + + .server-editor-stats { + width: 100%; + justify-content: flex-start; + } + + .server-track-item { padding: 6px 10px; gap: 8px; min-height: 44px; } + .server-track-art { width: 32px; height: 32px; } + .server-track-title { font-size: 11px; } + .server-track-artist { font-size: 9px; } + .server-track-duration { display: none; } + .server-track-actions { opacity: 1; } + + .server-disambig-modal { padding: 20px 16px; } + + .server-search-popover { max-width: 100vw; width: 100vw; max-height: 100vh; border-radius: 0; } + .server-search-result { gap: 8px; padding: 8px 6px; } + .server-search-result-art { width: 36px; height: 36px; } + .server-search-result-details { gap: 3px; } + + .sync-tab-server { flex: 1 !important; } + .sync-tab-divider { display: none; } +}