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:
parent
de3fba3f37
commit
34c8b1bb50
6 changed files with 2504 additions and 7 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
591
web_server.py
591
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/<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):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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()">×</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 -->
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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 & 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()">×</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
Loading…
Reference in a new issue