diff --git a/database/music_database.py b/database/music_database.py index 9113fbaa..d1991aab 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -6068,6 +6068,197 @@ class MusicDatabase: 'error': str(e) } + # ==================== Enhanced Library Management Methods ==================== + + # Field whitelists for safe updates + ARTIST_EDITABLE_FIELDS = {'name', 'genres', 'summary', 'style', 'mood', 'label'} + ALBUM_EDITABLE_FIELDS = {'title', 'year', 'genres', 'style', 'mood', 'label', 'explicit', 'record_type', 'track_count'} + TRACK_EDITABLE_FIELDS = {'title', 'track_number', 'bpm', 'explicit', 'style', 'mood'} + + def get_artist_full_detail(self, artist_id) -> Dict[str, Any]: + """ + Get complete artist information with ALL columns, all albums with ALL columns, + and all tracks per album with ALL columns. For the enhanced library management view. + """ + try: + with self._get_connection() as conn: + cursor = conn.cursor() + + # Get artist with all columns + cursor.execute("SELECT * FROM artists WHERE id = ?", (artist_id,)) + artist_row = cursor.fetchone() + if not artist_row: + return {'success': False, 'error': f'Artist with ID {artist_id} not found'} + + artist_name = artist_row['name'] + server_source = artist_row['server_source'] + + # Parse artist data + artist_data = dict(artist_row) + # Parse genres JSON + if artist_data.get('genres'): + try: + parsed = json.loads(artist_data['genres']) + artist_data['genres'] = parsed if isinstance(parsed, list) else [str(parsed)] + except (json.JSONDecodeError, ValueError): + artist_data['genres'] = [g.strip() for g in artist_data['genres'].split(',') if g.strip()] + else: + artist_data['genres'] = [] + + # Get all album IDs for this artist (including same-name artists on same server) + cursor.execute(""" + SELECT id FROM artists + WHERE name = ? AND server_source = ? + """, (artist_name, server_source)) + artist_ids = [row['id'] for row in cursor.fetchall()] + + # Get all albums with all columns + placeholders = ','.join('?' * len(artist_ids)) + cursor.execute(f""" + SELECT * FROM albums + WHERE artist_id IN ({placeholders}) + ORDER BY year DESC, title + """, artist_ids) + album_rows = cursor.fetchall() + + albums = [] + for album_row in album_rows: + album_data = dict(album_row) + # Parse album genres + if album_data.get('genres'): + try: + parsed = json.loads(album_data['genres']) + album_data['genres'] = parsed if isinstance(parsed, list) else [str(parsed)] + except (json.JSONDecodeError, ValueError): + album_data['genres'] = [g.strip() for g in album_data['genres'].split(',') if g.strip()] + else: + album_data['genres'] = [] + + # Get all tracks for this album with all columns + cursor.execute(""" + SELECT * FROM tracks + WHERE album_id = ? + ORDER BY track_number, title + """, (album_data['id'],)) + track_rows = cursor.fetchall() + album_data['tracks'] = [dict(tr) for tr in track_rows] + + # Determine record type from data if not set + if not album_data.get('record_type'): + track_count = len(album_data['tracks']) or album_data.get('track_count') or 0 + title_lower = (album_data.get('title') or '').lower() + if any(ind in title_lower for ind in ['single', ' - single', '(single)']) and track_count <= 3: + album_data['record_type'] = 'single' + elif any(ind in title_lower for ind in ['ep', ' - ep', '(ep)', 'extended play']) or (4 <= track_count <= 7): + album_data['record_type'] = 'ep' + else: + album_data['record_type'] = 'album' + + albums.append(album_data) + + return { + 'success': True, + 'artist': artist_data, + 'albums': albums + } + + except Exception as e: + logger.error(f"Error getting artist full detail for ID {artist_id}: {e}") + return {'success': False, 'error': str(e)} + + def update_artist_fields(self, artist_id, updates: Dict[str, Any]) -> Dict[str, Any]: + """Update artist metadata fields. Only whitelisted fields are accepted.""" + valid_updates = {k: v for k, v in updates.items() if k in self.ARTIST_EDITABLE_FIELDS} + if not valid_updates: + return {'success': False, 'error': 'No valid fields to update'} + + # Serialize genres to JSON if present + if 'genres' in valid_updates: + if isinstance(valid_updates['genres'], list): + valid_updates['genres'] = json.dumps(valid_updates['genres']) + + try: + with self._get_connection() as conn: + cursor = conn.cursor() + set_clause = ', '.join(f'{k} = ?' for k in valid_updates) + values = list(valid_updates.values()) + [artist_id] + cursor.execute(f"UPDATE artists SET {set_clause}, updated_at = CURRENT_TIMESTAMP WHERE id = ?", values) + conn.commit() + if cursor.rowcount == 0: + return {'success': False, 'error': f'Artist {artist_id} not found'} + return {'success': True, 'updated_fields': list(valid_updates.keys())} + except Exception as e: + logger.error(f"Error updating artist {artist_id}: {e}") + return {'success': False, 'error': str(e)} + + def update_album_fields(self, album_id, updates: Dict[str, Any]) -> Dict[str, Any]: + """Update album metadata fields. Only whitelisted fields are accepted.""" + valid_updates = {k: v for k, v in updates.items() if k in self.ALBUM_EDITABLE_FIELDS} + if not valid_updates: + return {'success': False, 'error': 'No valid fields to update'} + + if 'genres' in valid_updates: + if isinstance(valid_updates['genres'], list): + valid_updates['genres'] = json.dumps(valid_updates['genres']) + + try: + with self._get_connection() as conn: + cursor = conn.cursor() + set_clause = ', '.join(f'{k} = ?' for k in valid_updates) + values = list(valid_updates.values()) + [album_id] + cursor.execute(f"UPDATE albums SET {set_clause}, updated_at = CURRENT_TIMESTAMP WHERE id = ?", values) + conn.commit() + if cursor.rowcount == 0: + return {'success': False, 'error': f'Album {album_id} not found'} + return {'success': True, 'updated_fields': list(valid_updates.keys())} + except Exception as e: + logger.error(f"Error updating album {album_id}: {e}") + return {'success': False, 'error': str(e)} + + def update_track_fields(self, track_id, updates: Dict[str, Any]) -> Dict[str, Any]: + """Update track metadata fields. Only whitelisted fields are accepted.""" + valid_updates = {k: v for k, v in updates.items() if k in self.TRACK_EDITABLE_FIELDS} + if not valid_updates: + return {'success': False, 'error': 'No valid fields to update'} + + try: + with self._get_connection() as conn: + cursor = conn.cursor() + set_clause = ', '.join(f'{k} = ?' for k in valid_updates) + values = list(valid_updates.values()) + [track_id] + cursor.execute(f"UPDATE tracks SET {set_clause}, updated_at = CURRENT_TIMESTAMP WHERE id = ?", values) + conn.commit() + if cursor.rowcount == 0: + return {'success': False, 'error': f'Track {track_id} not found'} + return {'success': True, 'updated_fields': list(valid_updates.keys())} + except Exception as e: + logger.error(f"Error updating track {track_id}: {e}") + return {'success': False, 'error': str(e)} + + def batch_update_tracks(self, track_ids: List[str], updates: Dict[str, Any]) -> Dict[str, Any]: + """Batch update multiple tracks with the same field values.""" + valid_updates = {k: v for k, v in updates.items() if k in self.TRACK_EDITABLE_FIELDS} + if not valid_updates: + return {'success': False, 'error': 'No valid fields to update'} + if not track_ids: + return {'success': False, 'error': 'No track IDs provided'} + + try: + with self._get_connection() as conn: + cursor = conn.cursor() + set_clause = ', '.join(f'{k} = ?' for k in valid_updates) + placeholders = ','.join('?' * len(track_ids)) + values = list(valid_updates.values()) + list(track_ids) + cursor.execute( + f"UPDATE tracks SET {set_clause}, updated_at = CURRENT_TIMESTAMP WHERE id IN ({placeholders})", + values + ) + conn.commit() + return {'success': True, 'updated_count': cursor.rowcount, 'updated_fields': list(valid_updates.keys())} + except Exception as e: + logger.error(f"Error batch updating tracks: {e}") + return {'success': False, 'error': str(e)} + # ==================== Discovery Match Cache Methods ==================== def get_discovery_cache_match(self, normalized_title: str, normalized_artist: str, provider: str) -> Optional[Dict]: diff --git a/web_server.py b/web_server.py index 5026c2c1..60aa0432 100644 --- a/web_server.py +++ b/web_server.py @@ -8728,6 +8728,578 @@ def library_check_tracks(): traceback.print_exc() return jsonify({"success": False, "error": str(e)}), 500 + +# ==================== Enhanced Library Management Endpoints ==================== + +@app.route('/api/library/artist//enhanced') +def get_artist_enhanced_detail(artist_id): + """Get full artist detail with all albums and tracks for enhanced library view.""" + try: + database = get_database() + result = database.get_artist_full_detail(artist_id) + if not result.get('success'): + return jsonify(result), 404 + + # Fix image URLs for artist and all albums (resolve Plex/Jellyfin relative paths) + if result.get('artist', {}).get('thumb_url'): + result['artist']['thumb_url'] = fix_artist_image_url(result['artist']['thumb_url']) + for album in result.get('albums', []): + if album.get('thumb_url'): + album['thumb_url'] = fix_artist_image_url(album['thumb_url']) + + return jsonify(result) + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + +@app.route('/api/library/artist/', methods=['PUT']) +def update_library_artist(artist_id): + """Update artist metadata fields.""" + try: + database = get_database() + data = request.get_json() + if not data: + return jsonify({"success": False, "error": "No data provided"}), 400 + result = database.update_artist_fields(artist_id, data) + if not result.get('success'): + return jsonify(result), 400 + return jsonify(result) + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + +@app.route('/api/library/album/', methods=['PUT']) +def update_library_album(album_id): + """Update album metadata fields.""" + try: + database = get_database() + data = request.get_json() + if not data: + return jsonify({"success": False, "error": "No data provided"}), 400 + result = database.update_album_fields(album_id, data) + if not result.get('success'): + return jsonify(result), 400 + return jsonify(result) + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + +@app.route('/api/library/track/', methods=['PUT']) +def update_library_track(track_id): + """Update track metadata fields.""" + try: + database = get_database() + data = request.get_json() + if not data: + return jsonify({"success": False, "error": "No data provided"}), 400 + result = database.update_track_fields(track_id, data) + if not result.get('success'): + return jsonify(result), 400 + return jsonify(result) + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + +@app.route('/api/library/tracks/batch', methods=['PUT']) +def batch_update_library_tracks(): + """Batch update multiple tracks with the same field values.""" + try: + database = get_database() + data = request.get_json() + if not data: + return jsonify({"success": False, "error": "No data provided"}), 400 + track_ids = data.get('track_ids', []) + updates = data.get('updates', {}) + if not track_ids or not updates: + return jsonify({"success": False, "error": "track_ids and updates are required"}), 400 + result = database.batch_update_tracks(track_ids, updates) + if not result.get('success'): + return jsonify(result), 400 + return jsonify(result) + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + +@app.route('/api/library/play', methods=['POST']) +def library_play_track(): + """Start playing a track directly from the user's library (no download needed).""" + try: + data = request.get_json() + if not data or not data.get('file_path'): + return jsonify({"success": False, "error": "file_path is required"}), 400 + + file_path = data['file_path'] + + # Resolve server-side paths (e.g. /mnt/musicBackup/...) to local transfer path + if not os.path.exists(file_path): + transfer_dir = docker_resolve_path(config_manager.get('soulseek.transfer_path', './Transfer')) + # Try stripping common server-side prefixes and remapping to transfer dir + # The DB stores Plex/media server absolute paths; strip the root to get relative path + path_parts = file_path.replace('\\', '/').split('/') + # Try progressively shorter prefixes: /mnt/musicBackup/artist/... -> artist/... + resolved = None + for i in range(1, len(path_parts)): + relative = '/'.join(path_parts[i:]) + candidate = os.path.join(transfer_dir, relative.replace('/', os.sep)) + if os.path.exists(candidate): + resolved = candidate + break + if resolved: + file_path = resolved + else: + return jsonify({"success": False, "error": "File not found on disk"}), 404 + + print(f"📚 Library play request: {os.path.basename(file_path)}") + + # Set stream state to ready with the library file path directly + with stream_lock: + stream_state.update({ + "status": "ready", + "progress": 100, + "track_info": { + "title": data.get('title', os.path.basename(file_path)), + "artist": data.get('artist', 'Unknown Artist'), + "album": data.get('album', 'Unknown Album'), + }, + "file_path": file_path, + "error_message": None, + "is_library": True + }) + + return jsonify({"success": True, "message": "Library track ready for playback"}) + + except Exception as e: + print(f"❌ Error playing library track: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + +_enrichment_locks = {svc: threading.Lock() for svc in ('audiodb', 'deezer', 'musicbrainz', 'spotify', 'itunes')} + +@app.route('/api/library/enrich', methods=['POST']) +def library_enrich_entity(): + """Trigger enrichment of a specific entity from a single service. + Body: { entity_type: 'artist'|'album'|'track', entity_id: str, service: str, + name: str, artist_name: str? } + service: 'audiodb', 'deezer', 'musicbrainz', 'spotify', 'itunes' + """ + try: + data = request.get_json() + if not data: + return jsonify({"success": False, "error": "No data provided"}), 400 + + entity_type = data.get('entity_type') # artist, album, track + entity_id = data.get('entity_id') + service = data.get('service') + name = data.get('name', '') + artist_name = data.get('artist_name', '') + + if not entity_type or not entity_id or not service: + return jsonify({"success": False, "error": "entity_type, entity_id, and service are required"}), 400 + + if entity_type not in ('artist', 'album', 'track'): + return jsonify({"success": False, "error": "entity_type must be artist, album, or track"}), 400 + + valid_services = ('audiodb', 'deezer', 'musicbrainz', 'spotify', 'itunes') + if service not in valid_services: + return jsonify({"success": False, "error": f"service must be one of: {', '.join(valid_services)}"}), 400 + + # Per-service lock to avoid thread-safety issues with shared worker clients + lock = _enrichment_locks.get(service) + if not lock: + return jsonify({"success": False, "error": f"Unknown service: {service}"}), 400 + + acquired = lock.acquire(blocking=False) + if not acquired: + return jsonify({"success": False, "error": f"{service} enrichment already in progress. Please wait."}), 429 + + try: + results = {} + try: + result = _run_single_enrichment(service, entity_type, entity_id, name, artist_name) + results[service] = result + except Exception as e: + results[service] = {"success": False, "error": str(e)} + finally: + lock.release() + + # Re-fetch updated data to return fresh state + database = get_database() + updated = database.get_artist_full_detail( + data.get('artist_id', entity_id) if entity_type == 'artist' else + _get_artist_id_for_entity(database, entity_type, entity_id) + ) + + # Fix image URLs in updated data + if updated.get('success'): + if updated.get('artist', {}).get('thumb_url'): + updated['artist']['thumb_url'] = fix_artist_image_url(updated['artist']['thumb_url']) + for album in updated.get('albums', []): + if album.get('thumb_url'): + album['thumb_url'] = fix_artist_image_url(album['thumb_url']) + + return jsonify({ + "success": True, + "results": results, + "updated_data": updated if updated.get('success') else None + }) + + except Exception as e: + print(f"❌ Error enriching entity: {e}") + import traceback + traceback.print_exc() + return jsonify({"success": False, "error": str(e)}), 500 + + +def _get_artist_id_for_entity(database, entity_type, entity_id): + """Look up the artist_id for an album or track.""" + try: + with database._get_connection() as conn: + cursor = conn.cursor() + if entity_type == 'album': + cursor.execute("SELECT artist_id FROM albums WHERE id = ?", (entity_id,)) + elif entity_type == 'track': + cursor.execute("SELECT artist_id FROM tracks WHERE id = ?", (entity_id,)) + else: + return entity_id + row = cursor.fetchone() + return row['artist_id'] if row else entity_id + except Exception: + return entity_id + + +def _run_single_enrichment(service, entity_type, entity_id, name, artist_name): + """Run a single enrichment service on a single entity.""" + if service == 'audiodb': + if not audiodb_worker: + return {"success": False, "error": "AudioDB worker not initialized"} + item = {'type': entity_type, 'id': entity_id, 'name': name} + if entity_type in ('album', 'track'): + item['artist'] = artist_name + audiodb_worker._process_item(item) + return {"success": True, "message": f"AudioDB lookup complete for {entity_type}"} + + elif service == 'deezer': + if not deezer_worker: + return {"success": False, "error": "Deezer worker not initialized"} + item = {'type': entity_type, 'id': entity_id, 'name': name, 'artist': artist_name} + if entity_type == 'artist': + deezer_worker._process_artist(entity_id, name) + elif entity_type == 'album': + deezer_worker._process_album(entity_id, name, artist_name, item) + elif entity_type == 'track': + deezer_worker._process_track(entity_id, name, artist_name, item) + return {"success": True, "message": f"Deezer lookup complete for {entity_type}"} + + elif service == 'musicbrainz': + if not mb_worker: + return {"success": False, "error": "MusicBrainz worker not initialized"} + item = {'type': entity_type, 'id': entity_id, 'name': name} + if entity_type in ('album', 'track'): + item['artist'] = artist_name + mb_worker._process_item(item) + return {"success": True, "message": f"MusicBrainz lookup complete for {entity_type}"} + + elif service == 'spotify': + if not spotify_enrichment_worker: + return {"success": False, "error": "Spotify worker not initialized"} + if entity_type == 'artist': + spotify_enrichment_worker._process_artist({'type': 'artist', 'id': entity_id, 'name': name}) + elif entity_type == 'album': + spotify_enrichment_worker._process_album_individual({'type': 'album_individual', 'id': entity_id, 'name': name, 'artist': artist_name}) + elif entity_type == 'track': + spotify_enrichment_worker._process_track_individual({'type': 'track_individual', 'id': entity_id, 'name': name, 'artist': artist_name}) + return {"success": True, "message": f"Spotify lookup complete for {entity_type}"} + + elif service == 'itunes': + if not itunes_enrichment_worker: + return {"success": False, "error": "iTunes worker not initialized"} + if entity_type == 'artist': + itunes_enrichment_worker._process_artist({'type': 'artist', 'id': entity_id, 'name': name}) + elif entity_type == 'album': + itunes_enrichment_worker._process_album_individual({'type': 'album_individual', 'id': entity_id, 'name': name, 'artist': artist_name}) + elif entity_type == 'track': + itunes_enrichment_worker._process_track_individual({'type': 'track_individual', 'id': entity_id, 'name': name, 'artist': artist_name}) + return {"success": True, "message": f"iTunes lookup complete for {entity_type}"} + + else: + return {"success": False, "error": f"Unknown service: {service}"} + + +@app.route('/api/library/search-service', methods=['POST']) +def library_search_service(): + """Search a specific service for matching entities (for manual matching). + Body: { service: str, entity_type: str, query: str } + Returns normalized list of results. + """ + try: + data = request.get_json() + if not data: + return jsonify({"success": False, "error": "Invalid or missing JSON body"}), 400 + service = data.get('service', '') + entity_type = data.get('entity_type', '') + query = data.get('query', '').strip() + + if not service or not entity_type or not query: + return jsonify({"success": False, "error": "service, entity_type, and query are required"}), 400 + + results = _search_service(service, entity_type, query) + return jsonify({"success": True, "results": results}) + + except Exception as e: + print(f"❌ Error searching service: {e}") + import traceback + traceback.print_exc() + return jsonify({"success": False, "error": str(e)}), 500 + + +def _search_service(service, entity_type, query): + """Search a service and return normalized results.""" + import requests as req_lib + + if service == 'spotify': + if not spotify_enrichment_worker or not spotify_enrichment_worker.client: + raise ValueError("Spotify worker not initialized") + client = spotify_enrichment_worker.client + if entity_type == 'artist': + items = client.search_artists(query, limit=8) + return [{'id': a.id, 'name': a.name, 'image': a.image_url, 'extra': ', '.join(a.genres[:3]) if a.genres else ''} for a in items] + elif entity_type == 'album': + items = client.search_albums(query, limit=8) + return [{'id': a.id, 'name': a.name, 'image': a.image_url, 'extra': f"{', '.join(a.artists)} · {a.release_date or ''}"} for a in items] + elif entity_type == 'track': + items = client.search_tracks(query, limit=8) + return [{'id': t.id, 'name': t.name, 'image': t.image_url, 'extra': f"{', '.join(t.artists)} · {t.album or ''}"} for t in items] + + elif service == 'itunes': + if not itunes_enrichment_worker or not itunes_enrichment_worker.client: + raise ValueError("iTunes worker not initialized") + client = itunes_enrichment_worker.client + if entity_type == 'artist': + items = client.search_artists(query, limit=8) + return [{'id': a.id, 'name': a.name, 'image': a.image_url, 'extra': ', '.join(a.genres[:3]) if a.genres else ''} for a in items] + elif entity_type == 'album': + items = client.search_albums(query, limit=8) + return [{'id': a.id, 'name': a.name, 'image': a.image_url, 'extra': f"{', '.join(a.artists)} · {a.release_date or ''}"} for a in items] + elif entity_type == 'track': + items = client.search_tracks(query, limit=8) + return [{'id': t.id, 'name': t.name, 'image': t.image_url, 'extra': f"{', '.join(t.artists)} · {t.album or ''}"} for t in items] + + elif service == 'musicbrainz': + if not mb_worker or not mb_worker.mb_service: + raise ValueError("MusicBrainz worker not initialized") + mb_client = mb_worker.mb_service.mb_client + if entity_type == 'artist': + items = mb_client.search_artist(query, limit=8) + return [{'id': a['id'], 'name': a.get('name', ''), 'image': None, + 'extra': f"Score: {a.get('score', '')} · {a.get('disambiguation', '') or a.get('country', '')}"} for a in items] + elif entity_type == 'album': + items = mb_client.search_release(query, limit=8) + results = [] + for r in items: + artists = ', '.join(ac.get('name', '') for ac in r.get('artist-credit', []) if isinstance(ac, dict)) + # Cover Art Archive provides album art by release MBID + cover_url = f"https://coverartarchive.org/release/{r['id']}/front-250" if r.get('id') else None + results.append({'id': r['id'], 'name': r.get('title', ''), 'image': cover_url, + 'extra': f"{artists} · {r.get('date', '')} · Score: {r.get('score', '')}"}) + return results + elif entity_type == 'track': + items = mb_client.search_recording(query, limit=8) + results = [] + for r in items: + artists = ', '.join(ac.get('name', '') for ac in r.get('artist-credit', []) if isinstance(ac, dict)) + results.append({'id': r['id'], 'name': r.get('title', ''), 'image': None, + 'extra': f"{artists} · Score: {r.get('score', '')}"}) + return results + + elif service == 'deezer': + # Deezer client only returns single results, so hit the API directly for multiple + type_map = {'artist': 'artist', 'album': 'album', 'track': 'track'} + deezer_type = type_map.get(entity_type, 'track') + try: + resp = req_lib.get(f'https://api.deezer.com/search/{deezer_type}', params={'q': query, 'limit': 8}, timeout=10) + data = resp.json().get('data', []) + except Exception: + data = [] + results = [] + for item in data: + if entity_type == 'artist': + results.append({'id': str(item.get('id', '')), 'name': item.get('name', ''), + 'image': item.get('picture_medium'), 'extra': f"{item.get('nb_fan', 0)} fans"}) + elif entity_type == 'album': + artist_name = item.get('artist', {}).get('name', '') if isinstance(item.get('artist'), dict) else '' + results.append({'id': str(item.get('id', '')), 'name': item.get('title', ''), + 'image': item.get('cover_medium'), 'extra': artist_name}) + elif entity_type == 'track': + artist_name = item.get('artist', {}).get('name', '') if isinstance(item.get('artist'), dict) else '' + album_name = item.get('album', {}).get('title', '') if isinstance(item.get('album'), dict) else '' + results.append({'id': str(item.get('id', '')), 'name': item.get('title', ''), + 'image': item.get('album', {}).get('cover_medium') if isinstance(item.get('album'), dict) else None, + 'extra': f"{artist_name} · {album_name}"}) + return results + + elif service == 'audiodb': + if not audiodb_worker or not audiodb_worker.client: + raise ValueError("AudioDB worker not initialized") + client = audiodb_worker.client + result = None + if entity_type == 'artist': + result = client.search_artist(query) + elif entity_type == 'album': + # AudioDB album search needs artist + album, try query as-is + parts = query.split(' - ', 1) if ' - ' in query else [query, ''] + result = client.search_album(parts[0], parts[1] if len(parts) > 1 else query) + elif entity_type == 'track': + parts = query.split(' - ', 1) if ' - ' in query else [query, ''] + result = client.search_track(parts[0], parts[1] if len(parts) > 1 else query) + if result: + if entity_type == 'artist': + return [{'id': str(result.get('idArtist', '')), 'name': result.get('strArtist', ''), + 'image': result.get('strArtistThumb'), 'extra': result.get('strGenre', '')}] + elif entity_type == 'album': + return [{'id': str(result.get('idAlbum', '')), 'name': result.get('strAlbum', ''), + 'image': result.get('strAlbumThumb'), 'extra': f"{result.get('strArtist', '')} · {result.get('intYearReleased', '')}"}] + elif entity_type == 'track': + return [{'id': str(result.get('idTrack', '')), 'name': result.get('strTrack', ''), + 'image': None, 'extra': f"{result.get('strArtist', '')} · {result.get('strAlbum', '')}"}] + return [] + + return [] + + +# Column name mappings for manual matching +_SERVICE_ID_COLUMNS = { + 'spotify': {'artist': 'spotify_artist_id', 'album': 'spotify_album_id', 'track': 'spotify_track_id'}, + 'musicbrainz': {'artist': 'musicbrainz_id', 'album': 'musicbrainz_release_id', 'track': 'musicbrainz_recording_id'}, + 'deezer': {'artist': 'deezer_id', 'album': 'deezer_id', 'track': 'deezer_id'}, + 'audiodb': {'artist': 'audiodb_id', 'album': 'audiodb_id', 'track': 'audiodb_id'}, + 'itunes': {'artist': 'itunes_artist_id', 'album': 'itunes_album_id', 'track': 'itunes_track_id'}, +} + +@app.route('/api/library/manual-match', methods=['PUT']) +def library_manual_match(): + """Manually set a service ID for an entity. + Body: { entity_type: str, entity_id: str, service: str, service_id: str, artist_id: str } + """ + try: + data = request.get_json() + entity_type = data.get('entity_type') + entity_id = data.get('entity_id') + service = data.get('service') + service_id = data.get('service_id') + + if not all([entity_type, entity_id, service, service_id]): + return jsonify({"success": False, "error": "entity_type, entity_id, service, and service_id are required"}), 400 + + id_col = _SERVICE_ID_COLUMNS.get(service, {}).get(entity_type) + if not id_col: + return jsonify({"success": False, "error": f"Invalid service/entity_type combination"}), 400 + + status_col = f"{service}_match_status" + attempted_col = f"{service}_last_attempted" + table = {'artist': 'artists', 'album': 'albums', 'track': 'tracks'}[entity_type] + + database = get_database() + with database._get_connection() as conn: + cursor = conn.cursor() + cursor.execute(f""" + UPDATE {table} + SET {id_col} = ?, {status_col} = 'matched', {attempted_col} = CURRENT_TIMESTAMP + WHERE id = ? + """, (service_id, entity_id)) + conn.commit() + if cursor.rowcount == 0: + return jsonify({"success": False, "error": "Entity not found"}), 404 + + # Re-fetch fresh data + artist_id = data.get('artist_id', entity_id) + if entity_type != 'artist': + artist_id = _get_artist_id_for_entity(database, entity_type, entity_id) + + updated = database.get_artist_full_detail(artist_id) + if updated.get('success'): + if updated.get('artist', {}).get('thumb_url'): + updated['artist']['thumb_url'] = fix_artist_image_url(updated['artist']['thumb_url']) + for album in updated.get('albums', []): + if album.get('thumb_url'): + album['thumb_url'] = fix_artist_image_url(album['thumb_url']) + + return jsonify({ + "success": True, + "message": f"Manually matched {entity_type} to {service} ID: {service_id}", + "updated_data": updated if updated.get('success') else None + }) + + except Exception as e: + print(f"❌ Error manual matching: {e}") + import traceback + traceback.print_exc() + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route('/api/library/track/', methods=['DELETE']) +def library_delete_track(track_id): + """Delete a single track record from the database (does NOT delete the file on disk).""" + try: + database = get_database() + with database._get_connection() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM tracks WHERE id = ?", (track_id,)) + conn.commit() + if cursor.rowcount == 0: + return jsonify({"success": False, "error": "Track not found"}), 404 + return jsonify({"success": True, "deleted_count": cursor.rowcount}) + except Exception as e: + print(f"❌ Error deleting track {track_id}: {e}") + import traceback + traceback.print_exc() + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route('/api/library/album/', methods=['DELETE']) +def library_delete_album(album_id): + """Delete an album and all its tracks from the database (does NOT delete files on disk).""" + try: + database = get_database() + with database._get_connection() as conn: + cursor = conn.cursor() + # Delete all tracks belonging to this album first + cursor.execute("DELETE FROM tracks WHERE album_id = ?", (album_id,)) + tracks_deleted = cursor.rowcount + # Delete the album itself + cursor.execute("DELETE FROM albums WHERE id = ?", (album_id,)) + if cursor.rowcount == 0: + conn.rollback() + return jsonify({"success": False, "error": "Album not found"}), 404 + conn.commit() + return jsonify({"success": True, "deleted_count": 1, "tracks_deleted": tracks_deleted}) + except Exception as e: + print(f"❌ Error deleting album {album_id}: {e}") + import traceback + traceback.print_exc() + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route('/api/library/tracks/delete-batch', methods=['POST']) +def library_delete_tracks_batch(): + """Delete multiple track records from the database (does NOT delete files on disk). + Body: { track_ids: [int] } + """ + try: + data = request.get_json() + track_ids = data.get('track_ids', []) + if not track_ids or not isinstance(track_ids, list): + return jsonify({"success": False, "error": "track_ids array is required"}), 400 + + database = get_database() + with database._get_connection() as conn: + cursor = conn.cursor() + placeholders = ','.join('?' for _ in track_ids) + cursor.execute(f"DELETE FROM tracks WHERE id IN ({placeholders})", track_ids) + conn.commit() + return jsonify({"success": True, "deleted_count": cursor.rowcount}) + except Exception as e: + print(f"❌ Error batch deleting tracks: {e}") + import traceback + traceback.print_exc() + return jsonify({"success": False, "error": str(e)}), 500 + + +# ==================== End Enhanced Library Management ==================== + @app.route('/api/stream/start', methods=['POST']) def stream_start(): """Start streaming a track in the background""" @@ -8751,7 +9323,8 @@ def stream_start(): "progress": 0, "track_info": None, "file_path": None, - "error_message": None + "error_message": None, + "is_library": False }) # Start new background streaming task @@ -8886,23 +9459,29 @@ def stream_audio(): def stream_stop(): """Stop streaming and clean up""" global stream_background_task - + try: # Cancel background task if stream_background_task and not stream_background_task.done(): stream_background_task.cancel() - - # Clear Stream folder - project_root = os.path.dirname(os.path.abspath(__file__)) - stream_folder = os.path.join(project_root, 'Stream') - - if os.path.exists(stream_folder): - for filename in os.listdir(stream_folder): - file_path = os.path.join(stream_folder, filename) - if os.path.isfile(file_path): - os.remove(file_path) - print(f"🗑️ Removed stream file: {filename}") - + + # Only clear Stream folder if NOT playing a library file + with stream_lock: + is_library = stream_state.get("is_library", False) + + if not is_library: + project_root = os.path.dirname(os.path.abspath(__file__)) + stream_folder = os.path.join(project_root, 'Stream') + + if os.path.exists(stream_folder): + for filename in os.listdir(stream_folder): + file_path = os.path.join(stream_folder, filename) + if os.path.isfile(file_path): + os.remove(file_path) + print(f"🗑️ Removed stream file: {filename}") + else: + print(f"📚 Library playback stopped - skipping file deletion") + # Reset stream state with stream_lock: stream_state.update({ @@ -8910,11 +9489,12 @@ def stream_stop(): "progress": 0, "track_info": None, "file_path": None, - "error_message": None + "error_message": None, + "is_library": False }) - + return jsonify({"success": True, "message": "Stream stopped"}) - + except Exception as e: print(f"❌ Error stopping stream: {e}") return jsonify({"success": False, "error": str(e)}), 500 diff --git a/webui/index.html b/webui/index.html index 23d95653..4fe15217 100644 --- a/webui/index.html +++ b/webui/index.html @@ -2248,6 +2248,12 @@ +
+
+ View + + +
@@ -2294,10 +2300,44 @@ + + + + + + + +
+
+ 0 + tracks selected +
+
+ + +
+
+
diff --git a/webui/static/mobile.css b/webui/static/mobile.css index a647f89b..5dc75e67 100644 --- a/webui/static/mobile.css +++ b/webui/static/mobile.css @@ -2172,4 +2172,46 @@ width: calc(100vw - 32px); max-width: 340px; } + + /* Enhanced Library Management - Mobile */ + .enhanced-artist-meta-grid { + grid-template-columns: 1fr; + } + .enhanced-meta-field.wide { + grid-column: span 1; + } + .enhanced-album-row { + padding: 10px 12px; + gap: 10px; + } + .enhanced-album-grid { + grid-template-columns: 1fr; + } + .enhanced-album-type-badge { + display: none; + } + .enhanced-album-track-count { + font-size: 11px; + min-width: 40px; + } + .enhanced-track-table .col-path, + .enhanced-track-table .col-bpm, + .enhanced-track-table .col-format, + .enhanced-track-table .col-match, + .enhanced-track-table .col-disc { + display: none; + } + .enhanced-manual-match-modal { + width: 95vw; + } + .enhanced-album-meta-row { + grid-template-columns: 1fr 1fr; + } + .enhanced-bulk-bar { + left: 0; + padding: 0 16px; + } + .enhanced-bulk-modal { + width: calc(100vw - 32px); + } } \ No newline at end of file diff --git a/webui/static/script.js b/webui/static/script.js index 0d6c064d..674de1b7 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -30858,7 +30858,13 @@ async function toggleLibraryCardWatchlist(btn, artist) { let artistDetailPageState = { isInitialized: false, currentArtistId: null, - currentArtistName: null + currentArtistName: null, + enhancedView: false, + enhancedData: null, + expandedAlbums: new Set(), + selectedTracks: new Set(), + editingCell: null, + enhancedTrackSort: {} }; // Discography filter state @@ -30877,9 +30883,40 @@ function navigateToArtistDetail(artistId, artistName) { artistDetailPageState.completionController = null; } - // Store current artist info + // Cancel any active inline edit and close manual match modal before resetting state + cancelInlineEdit(); + const existingMatchOverlay = document.getElementById('enhanced-manual-match-overlay'); + if (existingMatchOverlay) existingMatchOverlay.remove(); + + // Store current artist info and reset enhanced view state artistDetailPageState.currentArtistId = artistId; artistDetailPageState.currentArtistName = artistName; + artistDetailPageState.enhancedData = null; + artistDetailPageState.expandedAlbums = new Set(); + artistDetailPageState.selectedTracks = new Set(); + artistDetailPageState.enhancedTrackSort = {}; + artistDetailPageState.enhancedView = false; + + // Reset enhanced view toggle to standard + const toggleBtns = document.querySelectorAll('.enhanced-view-toggle-btn'); + toggleBtns.forEach(btn => { + btn.classList.toggle('active', btn.getAttribute('data-view') === 'standard'); + }); + const enhancedContainer = document.getElementById('enhanced-view-container'); + if (enhancedContainer) enhancedContainer.classList.add('hidden'); + const standardSections = document.querySelector('.discography-sections'); + if (standardSections) standardSections.classList.remove('hidden'); + // Restore standard view filter groups + const filterGroups = document.querySelectorAll('#discography-filters .filter-group'); + filterGroups.forEach(group => { + const label = group.querySelector('.filter-label'); + if (label && label.textContent !== 'View') group.style.display = ''; + }); + const dividers = document.querySelectorAll('#discography-filters .filter-divider'); + dividers.forEach(d => d.style.display = ''); + // Hide bulk bar + const bulkBar = document.getElementById('enhanced-bulk-bar'); + if (bulkBar) bulkBar.classList.remove('visible'); // Navigate to artist detail page navigateToPage('artist-detail'); @@ -31993,6 +32030,1829 @@ function applyDiscographyFilters() { } } +// ==================== Enhanced Library Management View ==================== + +function toggleEnhancedView(enabled) { + const standardSections = document.querySelector('.discography-sections'); + const enhancedContainer = document.getElementById('enhanced-view-container'); + const toggleBtns = document.querySelectorAll('.enhanced-view-toggle-btn'); + + if (!standardSections || !enhancedContainer) return; + + artistDetailPageState.enhancedView = enabled; + + // Update toggle button states + toggleBtns.forEach(btn => { + const view = btn.getAttribute('data-view'); + btn.classList.toggle('active', (view === 'enhanced') === enabled); + }); + + // Hide/show standard filter groups (not relevant in enhanced view) + const filterGroups = document.querySelectorAll('#discography-filters .filter-group'); + filterGroups.forEach(group => { + const label = group.querySelector('.filter-label'); + if (label && label.textContent !== 'View') { + group.style.display = enabled ? 'none' : ''; + } + }); + const dividers = document.querySelectorAll('#discography-filters .filter-divider'); + dividers.forEach((d, i) => { + if (i < dividers.length - 1) d.style.display = enabled ? 'none' : ''; + }); + + if (enabled) { + standardSections.classList.add('hidden'); + enhancedContainer.classList.remove('hidden'); + + if (!artistDetailPageState.enhancedData) { + loadEnhancedViewData(artistDetailPageState.currentArtistId); + } else { + renderEnhancedView(); + } + } else { + standardSections.classList.remove('hidden'); + enhancedContainer.classList.add('hidden'); + const bulkBar = document.getElementById('enhanced-bulk-bar'); + if (bulkBar) bulkBar.classList.remove('visible'); + } +} + +async function loadEnhancedViewData(artistId) { + const container = document.getElementById('enhanced-view-container'); + if (!container) return; + + container.innerHTML = '
Loading library data...
'; + + try { + const response = await fetch(`/api/library/artist/${artistId}/enhanced`); + const data = await response.json(); + + if (!data.success) throw new Error(data.error || 'Failed to load enhanced data'); + + artistDetailPageState.enhancedData = data; + artistDetailPageState.expandedAlbums = new Set(); + artistDetailPageState.selectedTracks = new Set(); + artistDetailPageState.enhancedTrackSort = {}; + renderEnhancedView(); + + } catch (error) { + console.error('Error loading enhanced view data:', error); + container.innerHTML = `
Failed to load: ${escapeHtml(error.message)}
`; + } +} + +function renderEnhancedView() { + const container = document.getElementById('enhanced-view-container'); + const data = artistDetailPageState.enhancedData; + if (!container || !data) return; + + container.innerHTML = ''; + + // Artist metadata card (visual + editable) + container.appendChild(renderArtistMetaPanel(data.artist)); + + // Library stats summary bar + container.appendChild(renderEnhancedStatsBar(data)); + + // Group albums by type + const grouped = { album: [], ep: [], single: [] }; + (data.albums || []).forEach(album => { + const type = (album.record_type || 'album').toLowerCase(); + if (grouped[type]) grouped[type].push(album); + else grouped[type] = [album]; + }); + + const sectionLabels = { album: 'Albums', ep: 'EPs', single: 'Singles' }; + for (const [type, label] of Object.entries(sectionLabels)) { + const albums = grouped[type] || []; + if (albums.length === 0) continue; + container.appendChild(renderEnhancedSection(type, label, albums)); + } +} + +function renderEnhancedStatsBar(data) { + const bar = document.createElement('div'); + bar.className = 'enhanced-stats-bar'; + + const albums = data.albums || []; + const totalAlbums = albums.filter(a => (a.record_type || 'album') === 'album').length; + const totalEps = albums.filter(a => a.record_type === 'ep').length; + const totalSingles = albums.filter(a => a.record_type === 'single').length; + const totalTracks = albums.reduce((s, a) => s + (a.tracks ? a.tracks.length : 0), 0); + + // Calculate total duration + let totalDurationMs = 0; + albums.forEach(a => (a.tracks || []).forEach(t => { totalDurationMs += (t.duration || 0); })); + const totalHours = Math.floor(totalDurationMs / 3600000); + const totalMins = Math.floor((totalDurationMs % 3600000) / 60000); + + // Calculate format breakdown + const formatCounts = {}; + albums.forEach(a => (a.tracks || []).forEach(t => { + const fmt = extractFormat(t.file_path); + if (fmt !== '-') formatCounts[fmt] = (formatCounts[fmt] || 0) + 1; + })); + + const statsItems = [ + { value: totalAlbums, label: 'Albums', icon: '💿' }, + { value: totalEps, label: 'EPs', icon: '📀' }, + { value: totalSingles, label: 'Singles', icon: '♪' }, + { value: totalTracks, label: 'Tracks', icon: '🎵' }, + { value: totalHours > 0 ? `${totalHours}h ${totalMins}m` : `${totalMins}m`, label: 'Duration', icon: '⏲' }, + ]; + + let statsHtml = statsItems.map(s => + `
+ ${s.value} + ${s.label} +
` + ).join(''); + + // Format badges + const formatBadges = Object.entries(formatCounts) + .sort((a, b) => b[1] - a[1]) + .map(([fmt, count]) => { + const cls = fmt === 'FLAC' ? 'flac' : (fmt === 'MP3' ? 'mp3' : 'other'); + return `${fmt} (${count})`; + }).join(''); + + bar.innerHTML = ` +
${statsHtml}
+
${formatBadges}
+ `; + + return bar; +} + +function renderArtistMetaPanel(artist) { + const panel = document.createElement('div'); + panel.className = 'enhanced-artist-meta'; + panel.id = 'enhanced-artist-meta'; + + // Build using DOM to avoid innerHTML escaping issues + const header = document.createElement('div'); + header.className = 'enhanced-artist-meta-header'; + + // Left side: artist image + name display + const headerLeft = document.createElement('div'); + headerLeft.className = 'enhanced-artist-meta-header-left'; + + if (artist.thumb_url) { + const img = document.createElement('img'); + img.className = 'enhanced-artist-meta-image'; + img.src = artist.thumb_url; + img.alt = artist.name || ''; + img.onerror = function() { this.style.display = 'none'; }; + headerLeft.appendChild(img); + } + + const headerInfo = document.createElement('div'); + headerInfo.className = 'enhanced-artist-meta-info'; + const artistTitle = document.createElement('div'); + artistTitle.className = 'enhanced-artist-meta-name'; + artistTitle.textContent = artist.name || 'Unknown Artist'; + headerInfo.appendChild(artistTitle); + + // ID badges row (clickable links) + const idBadges = document.createElement('div'); + idBadges.className = 'enhanced-artist-id-badges'; + const idSources = [ + { key: 'spotify_artist_id', label: 'Spotify', svc: 'spotify' }, + { key: 'musicbrainz_id', label: 'MusicBrainz', svc: 'musicbrainz' }, + { key: 'deezer_id', label: 'Deezer', svc: 'deezer' }, + { key: 'audiodb_id', label: 'AudioDB', svc: 'audiodb' }, + { key: 'itunes_artist_id', label: 'iTunes', svc: 'itunes' }, + ]; + idSources.forEach(src => { + if (artist[src.key]) { + idBadges.appendChild(makeClickableBadge(src.svc, 'artist', artist[src.key], src.label)); + } + }); + if (artist.server_source) { + const srcBadge = document.createElement('span'); + srcBadge.className = 'enhanced-id-badge server'; + srcBadge.textContent = artist.server_source; + idBadges.appendChild(srcBadge); + } + headerInfo.appendChild(idBadges); + headerLeft.appendChild(headerInfo); + header.appendChild(headerLeft); + + // Right side: edit toggle + const headerRight = document.createElement('div'); + headerRight.className = 'enhanced-artist-meta-actions'; + const editToggle = document.createElement('button'); + editToggle.className = 'enhanced-meta-edit-toggle'; + editToggle.textContent = 'Edit Metadata'; + editToggle.onclick = () => { + const form = document.getElementById('enhanced-artist-meta-form'); + if (form) { + const isVisible = !form.classList.contains('hidden'); + form.classList.toggle('hidden'); + editToggle.textContent = isVisible ? 'Edit Metadata' : 'Hide Editor'; + editToggle.classList.toggle('active', !isVisible); + } + }; + headerRight.appendChild(editToggle); + + // Enrich dropdown button + const enrichWrap = document.createElement('div'); + enrichWrap.className = 'enhanced-enrich-wrap'; + const enrichBtn = document.createElement('button'); + enrichBtn.className = 'enhanced-enrich-btn'; + enrichBtn.textContent = 'Enrich ▾'; + enrichBtn.onclick = (e) => { + e.stopPropagation(); + enrichMenu.classList.toggle('visible'); + }; + enrichWrap.appendChild(enrichBtn); + + const enrichMenu = document.createElement('div'); + enrichMenu.className = 'enhanced-enrich-menu'; + const services = [ + + { id: 'spotify', label: 'Spotify', icon: '🟢' }, + { id: 'musicbrainz', label: 'MusicBrainz', icon: '🟠' }, + { id: 'deezer', label: 'Deezer', icon: '🟣' }, + { id: 'audiodb', label: 'AudioDB', icon: '🔵' }, + { id: 'itunes', label: 'iTunes', icon: '🔴' }, + ]; + services.forEach(svc => { + const item = document.createElement('div'); + item.className = 'enhanced-enrich-menu-item'; + item.textContent = `${svc.icon} ${svc.label}`; + item.onclick = (e) => { + e.stopPropagation(); + enrichMenu.classList.remove('visible'); + runEnrichment('artist', artist.id, svc.id, artist.name, '', artist.id); + }; + enrichMenu.appendChild(item); + }); + enrichWrap.appendChild(enrichMenu); + headerRight.appendChild(enrichWrap); + + header.appendChild(headerRight); + + panel.appendChild(header); + + // Match status row (clickable to rematch) + const statusRow = document.createElement('div'); + statusRow.className = 'enhanced-match-status-row'; + const statusServices = [ + { key: 'spotify_match_status', label: 'Spotify', attempted: 'spotify_last_attempted', svc: 'spotify' }, + { key: 'musicbrainz_match_status', label: 'MusicBrainz', attempted: 'musicbrainz_last_attempted', svc: 'musicbrainz' }, + { key: 'deezer_match_status', label: 'Deezer', attempted: 'deezer_last_attempted', svc: 'deezer' }, + { key: 'audiodb_match_status', label: 'AudioDB', attempted: 'audiodb_last_attempted', svc: 'audiodb' }, + { key: 'itunes_match_status', label: 'iTunes', attempted: 'itunes_last_attempted', svc: 'itunes' }, + ]; + statusServices.forEach(s => { + const status = artist[s.key]; + const attempted = artist[s.attempted]; + const chip = document.createElement('span'); + chip.className = `enhanced-match-chip clickable ${status === 'matched' ? 'matched' : (status === 'not_found' ? 'not-found' : 'pending')}`; + chip.textContent = `${s.label}: ${status || 'pending'}`; + const tipParts = []; + if (attempted) tipParts.push(`Last: ${new Date(attempted).toLocaleString()}`); + tipParts.push('Click to rematch'); + chip.title = tipParts.join(' · '); + chip.onclick = () => openManualMatchModal('artist', artist.id, s.svc, artist.name, artist.id); + statusRow.appendChild(chip); + }); + panel.appendChild(statusRow); + + // Collapsible edit form (hidden by default) + const form = document.createElement('div'); + form.className = 'enhanced-artist-meta-form hidden'; + form.id = 'enhanced-artist-meta-form'; + + const editableFields = [ + { key: 'name', label: 'Artist Name', type: 'text' }, + { key: 'genres', label: 'Genres (comma separated)', type: 'text', isArray: true }, + { key: 'label', label: 'Label', type: 'text' }, + { key: 'style', label: 'Style', type: 'text' }, + { key: 'mood', label: 'Mood', type: 'text' }, + { key: 'summary', label: 'Summary / Bio', type: 'textarea', wide: true }, + ]; + + const grid = document.createElement('div'); + grid.className = 'enhanced-artist-meta-grid'; + + editableFields.forEach(f => { + const fieldDiv = document.createElement('div'); + fieldDiv.className = 'enhanced-meta-field' + (f.wide ? ' wide' : ''); + + const label = document.createElement('label'); + label.className = 'enhanced-meta-field-label'; + label.textContent = f.label; + fieldDiv.appendChild(label); + + const val = f.isArray + ? (Array.isArray(artist[f.key]) ? artist[f.key].join(', ') : (artist[f.key] || '')) + : (artist[f.key] || ''); + + if (f.type === 'textarea') { + const ta = document.createElement('textarea'); + ta.className = 'enhanced-meta-field-input'; + ta.dataset.field = f.key; + ta.placeholder = f.label + '...'; + ta.textContent = val; + fieldDiv.appendChild(ta); + } else { + const inp = document.createElement('input'); + inp.type = 'text'; + inp.className = 'enhanced-meta-field-input'; + inp.dataset.field = f.key; + inp.value = val; + inp.placeholder = f.label + '...'; + fieldDiv.appendChild(inp); + } + + grid.appendChild(fieldDiv); + }); + + form.appendChild(grid); + + // Save/revert buttons + const formActions = document.createElement('div'); + formActions.className = 'enhanced-artist-form-actions'; + const revertBtn = document.createElement('button'); + revertBtn.className = 'enhanced-meta-cancel-btn'; + revertBtn.textContent = 'Revert'; + revertBtn.onclick = () => revertArtistMetadata(); + const saveBtn = document.createElement('button'); + saveBtn.className = 'enhanced-meta-save-btn'; + saveBtn.textContent = 'Save Changes'; + saveBtn.onclick = () => saveArtistMetadata(); + formActions.appendChild(revertBtn); + formActions.appendChild(saveBtn); + form.appendChild(formActions); + + panel.appendChild(form); + + return panel; +} + +function renderEnhancedSection(type, label, albums) { + const section = document.createElement('div'); + section.className = 'enhanced-section'; + + const totalTracks = albums.reduce((sum, a) => sum + (a.tracks ? a.tracks.length : 0), 0); + + const sectionHeader = document.createElement('div'); + sectionHeader.className = 'enhanced-section-header'; + sectionHeader.innerHTML = ` + ${label} + ${albums.length} release${albums.length !== 1 ? 's' : ''} · ${totalTracks} tracks + `; + section.appendChild(sectionHeader); + + const grid = document.createElement('div'); + grid.className = 'enhanced-album-grid'; + + albums.forEach(album => { + const wrapper = document.createElement('div'); + wrapper.className = 'enhanced-album-wrapper'; + wrapper.id = `enhanced-album-wrapper-${album.id}`; + const isExpanded = artistDetailPageState.expandedAlbums.has(album.id); + if (isExpanded) wrapper.classList.add('expanded'); + + wrapper.appendChild(renderAlbumRow(album, type)); + + const tracksPanel = document.createElement('div'); + tracksPanel.className = 'enhanced-tracks-panel'; + tracksPanel.id = `enhanced-tracks-panel-${album.id}`; + if (isExpanded) tracksPanel.classList.add('visible'); + const inner = document.createElement('div'); + inner.className = 'enhanced-tracks-panel-inner'; + if (isExpanded) { + inner.dataset.rendered = 'true'; + inner.appendChild(renderExpandedAlbumHeader(album)); + inner.appendChild(renderAlbumMetaRow(album)); + inner.appendChild(renderTrackTable(album)); + } + tracksPanel.appendChild(inner); + wrapper.appendChild(tracksPanel); + + grid.appendChild(wrapper); + }); + section.appendChild(grid); + + return section; +} + +function renderAlbumRow(album, type) { + const row = document.createElement('div'); + row.className = 'enhanced-album-row'; + row.id = `enhanced-album-row-${album.id}`; + + if (artistDetailPageState.expandedAlbums.has(album.id)) row.classList.add('expanded'); + + const trackCount = album.tracks ? album.tracks.length : 0; + const typeClass = (type || 'album').toLowerCase(); + + // Total duration for this album + let albumDurMs = 0; + (album.tracks || []).forEach(t => { albumDurMs += (t.duration || 0); }); + const albumDur = formatDurationMs(albumDurMs); + + // Format breakdown for this album + const fmts = {}; + (album.tracks || []).forEach(t => { + const f = extractFormat(t.file_path); + if (f !== '-') fmts[f] = (fmts[f] || 0) + 1; + }); + const primaryFormat = Object.keys(fmts).sort((a, b) => fmts[b] - fmts[a])[0] || ''; + + // Build with DOM for safety + const expandIcon = document.createElement('span'); + expandIcon.className = 'enhanced-album-expand-icon'; + expandIcon.innerHTML = '▶'; + row.appendChild(expandIcon); + + // Album art - larger, prominent + const artWrap = document.createElement('div'); + artWrap.className = 'enhanced-album-art-wrap'; + if (album.thumb_url) { + const img = document.createElement('img'); + img.className = 'enhanced-album-thumb'; + img.src = album.thumb_url; + img.alt = ''; + img.loading = 'lazy'; + img.onerror = function() { + const fallback = document.createElement('div'); + fallback.className = 'enhanced-album-thumb-fallback'; + fallback.innerHTML = '🎵'; + this.replaceWith(fallback); + }; + artWrap.appendChild(img); + } else { + const fallback = document.createElement('div'); + fallback.className = 'enhanced-album-thumb-fallback'; + fallback.innerHTML = '🎵'; + artWrap.appendChild(fallback); + } + row.appendChild(artWrap); + + // Info block (title + meta line) + const infoBlock = document.createElement('div'); + infoBlock.className = 'enhanced-album-info-block'; + + const titleEl = document.createElement('span'); + titleEl.className = 'enhanced-album-title'; + titleEl.textContent = album.title || 'Unknown'; + titleEl.title = album.title || ''; + infoBlock.appendChild(titleEl); + + const metaLine = document.createElement('span'); + metaLine.className = 'enhanced-album-meta-line'; + const metaParts = []; + if (album.year) metaParts.push(String(album.year)); + metaParts.push(`${trackCount} track${trackCount !== 1 ? 's' : ''}`); + if (albumDur !== '-') metaParts.push(albumDur); + if (album.label) metaParts.push(album.label); + metaLine.textContent = metaParts.join(' \u00B7 '); + infoBlock.appendChild(metaLine); + + row.appendChild(infoBlock); + + // Type badge + const badge = document.createElement('span'); + badge.className = `enhanced-album-type-badge ${typeClass}`; + badge.textContent = type; + row.appendChild(badge); + + // Format badge inline + if (primaryFormat) { + const fmtBadge = document.createElement('span'); + const fmtClass = primaryFormat === 'FLAC' ? 'flac' : (primaryFormat === 'MP3' ? 'mp3' : 'other'); + fmtBadge.className = `enhanced-format-badge ${fmtClass}`; + fmtBadge.textContent = primaryFormat; + row.appendChild(fmtBadge); + } + + row.addEventListener('click', () => toggleAlbumExpand(album.id)); + + return row; +} + +function toggleAlbumExpand(albumId) { + const row = document.getElementById(`enhanced-album-row-${albumId}`); + const panel = document.getElementById(`enhanced-tracks-panel-${albumId}`); + const wrapper = document.getElementById(`enhanced-album-wrapper-${albumId}`); + if (!row || !panel) return; + + const isExpanded = artistDetailPageState.expandedAlbums.has(albumId); + + if (isExpanded) { + artistDetailPageState.expandedAlbums.delete(albumId); + row.classList.remove('expanded'); + panel.classList.remove('visible'); + if (wrapper) wrapper.classList.remove('expanded'); + } else { + artistDetailPageState.expandedAlbums.add(albumId); + row.classList.add('expanded'); + panel.classList.add('visible'); + if (wrapper) wrapper.classList.add('expanded'); + + // Lazy render + const inner = panel.querySelector('.enhanced-tracks-panel-inner'); + if (inner && !inner.dataset.rendered) { + const album = findEnhancedAlbum(albumId); + if (album) { + inner.innerHTML = ''; + inner.appendChild(renderExpandedAlbumHeader(album)); + inner.appendChild(renderAlbumMetaRow(album)); + inner.appendChild(renderTrackTable(album)); + inner.dataset.rendered = 'true'; + } + } + } +} + +function findEnhancedAlbum(albumId) { + const data = artistDetailPageState.enhancedData; + if (!data || !data.albums) return null; + return data.albums.find(a => String(a.id) === String(albumId)); +} + +function renderExpandedAlbumHeader(album) { + const header = document.createElement('div'); + header.className = 'enhanced-expanded-header'; + + // Large album art + if (album.thumb_url) { + const img = document.createElement('img'); + img.className = 'enhanced-expanded-art'; + img.src = album.thumb_url; + img.alt = album.title || ''; + img.onerror = function() { this.style.display = 'none'; }; + header.appendChild(img); + } + + const info = document.createElement('div'); + info.className = 'enhanced-expanded-info'; + + const title = document.createElement('div'); + title.className = 'enhanced-expanded-title'; + title.textContent = album.title || 'Unknown'; + info.appendChild(title); + + const meta = document.createElement('div'); + meta.className = 'enhanced-expanded-meta'; + + const details = []; + if (album.year) details.push(String(album.year)); + const trackCount = album.tracks ? album.tracks.length : 0; + details.push(`${trackCount} track${trackCount !== 1 ? 's' : ''}`); + let durMs = 0; + (album.tracks || []).forEach(t => { durMs += (t.duration || 0); }); + if (durMs > 0) details.push(formatDurationMs(durMs)); + if (album.label) details.push(album.label); + if (album.record_type) details.push(album.record_type.toUpperCase()); + + meta.textContent = details.join(' \u00B7 '); + info.appendChild(meta); + + // Genre tags + const genres = Array.isArray(album.genres) ? album.genres : []; + if (genres.length > 0) { + const genreRow = document.createElement('div'); + genreRow.className = 'enhanced-expanded-genres'; + genres.forEach(g => { + const tag = document.createElement('span'); + tag.className = 'enhanced-genre-tag'; + tag.textContent = g; + genreRow.appendChild(tag); + }); + info.appendChild(genreRow); + } + + // External ID badges (clickable links) + const ids = document.createElement('div'); + ids.className = 'enhanced-expanded-ids'; + const idFields = [ + { key: 'spotify_album_id', label: 'Spotify', svc: 'spotify' }, + { key: 'musicbrainz_release_id', label: 'MusicBrainz', svc: 'musicbrainz' }, + { key: 'deezer_id', label: 'Deezer', svc: 'deezer' }, + { key: 'audiodb_id', label: 'AudioDB', svc: 'audiodb' }, + { key: 'itunes_album_id', label: 'iTunes', svc: 'itunes' }, + ]; + idFields.forEach(f => { + if (album[f.key]) { + ids.appendChild(makeClickableBadge(f.svc, 'album', album[f.key], f.label)); + } + }); + if (ids.children.length > 0) info.appendChild(ids); + + // Resolve artist name for enrichment calls + const artistName = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.name : ''; + + // Match status chips (clickable to rematch) + const statusRow = document.createElement('div'); + statusRow.className = 'enhanced-match-status-row compact'; + const statusSvcs = [ + { key: 'spotify_match_status', label: 'Spotify', attempted: 'spotify_last_attempted', svc: 'spotify' }, + { key: 'musicbrainz_match_status', label: 'MB', attempted: 'musicbrainz_last_attempted', svc: 'musicbrainz' }, + { key: 'deezer_match_status', label: 'Deezer', attempted: 'deezer_last_attempted', svc: 'deezer' }, + { key: 'audiodb_match_status', label: 'AudioDB', attempted: 'audiodb_last_attempted', svc: 'audiodb' }, + { key: 'itunes_match_status', label: 'iTunes', attempted: 'itunes_last_attempted', svc: 'itunes' }, + ]; + statusSvcs.forEach(s => { + const status = album[s.key]; + const attempted = album[s.attempted]; + const chip = document.createElement('span'); + chip.className = `enhanced-match-chip clickable ${status === 'matched' ? 'matched' : (status === 'not_found' ? 'not-found' : 'pending')}`; + chip.textContent = `${s.label}: ${status || '—'}`; + const tipParts = []; + if (attempted) tipParts.push(`Last: ${new Date(attempted).toLocaleString()}`); + tipParts.push('Click to rematch'); + chip.title = tipParts.join(' · '); + chip.onclick = (e) => { + e.stopPropagation(); + const aId = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.id : ''; + openManualMatchModal('album', album.id, s.svc, album.title || '', aId); + }; + statusRow.appendChild(chip); + }); + info.appendChild(statusRow); + + // Enrich button for album + const enrichRow = document.createElement('div'); + enrichRow.className = 'enhanced-expanded-actions'; + const albumEnrichWrap = document.createElement('div'); + albumEnrichWrap.className = 'enhanced-enrich-wrap'; + const albumEnrichBtn = document.createElement('button'); + albumEnrichBtn.className = 'enhanced-enrich-btn small'; + albumEnrichBtn.textContent = 'Enrich Album ▾'; + albumEnrichBtn.onclick = (e) => { e.stopPropagation(); albumEnrichMenu.classList.toggle('visible'); }; + albumEnrichWrap.appendChild(albumEnrichBtn); + const albumEnrichMenu = document.createElement('div'); + albumEnrichMenu.className = 'enhanced-enrich-menu'; + [ + + { id: 'spotify', label: 'Spotify', icon: '🟢' }, + { id: 'musicbrainz', label: 'MusicBrainz', icon: '🟠' }, + { id: 'deezer', label: 'Deezer', icon: '🟣' }, + { id: 'audiodb', label: 'AudioDB', icon: '🔵' }, + { id: 'itunes', label: 'iTunes', icon: '🔴' }, + ].forEach(svc => { + const item = document.createElement('div'); + item.className = 'enhanced-enrich-menu-item'; + item.textContent = `${svc.icon} ${svc.label}`; + item.onclick = (e) => { + e.stopPropagation(); + albumEnrichMenu.classList.remove('visible'); + const aId = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.id : ''; + runEnrichment('album', album.id, svc.id, album.title || '', artistName, aId); + }; + albumEnrichMenu.appendChild(item); + }); + albumEnrichWrap.appendChild(albumEnrichMenu); + enrichRow.appendChild(albumEnrichWrap); + + // Delete album button + const deleteAlbumBtn = document.createElement('button'); + deleteAlbumBtn.className = 'enhanced-delete-album-btn'; + deleteAlbumBtn.textContent = 'Delete Album'; + deleteAlbumBtn.onclick = (e) => { e.stopPropagation(); deleteLibraryAlbum(album.id); }; + enrichRow.appendChild(deleteAlbumBtn); + + info.appendChild(enrichRow); + + header.appendChild(info); + return header; +} + +function renderAlbumMetaRow(album) { + const row = document.createElement('div'); + row.className = 'enhanced-album-meta-row'; + row.id = `enhanced-album-meta-${album.id}`; + + const fields = [ + { key: 'title', label: 'Title', value: album.title || '' }, + { key: 'year', label: 'Year', value: album.year || '', type: 'number' }, + { key: 'genres', label: 'Genres', value: Array.isArray(album.genres) ? album.genres.join(', ') : (album.genres || '') }, + { key: 'label', label: 'Label', value: album.label || '' }, + { key: 'style', label: 'Style', value: album.style || '' }, + { key: 'mood', label: 'Mood', value: album.mood || '' }, + { key: 'record_type', label: 'Type', value: album.record_type || 'album' }, + { key: 'explicit', label: 'Explicit', value: album.explicit ? '1' : '0' }, + ]; + + fields.forEach(f => { + const fieldDiv = document.createElement('div'); + fieldDiv.className = 'enhanced-album-meta-field'; + const label = document.createElement('label'); + label.className = 'enhanced-album-meta-label'; + label.textContent = f.label; + fieldDiv.appendChild(label); + const input = document.createElement('input'); + input.className = 'enhanced-album-meta-input'; + input.type = f.type || 'text'; + input.dataset.albumId = album.id; + input.dataset.field = f.key; + input.value = String(f.value); + input.addEventListener('click', e => e.stopPropagation()); + fieldDiv.appendChild(input); + row.appendChild(fieldDiv); + }); + + // Save button + const saveDiv = document.createElement('div'); + saveDiv.className = 'enhanced-album-meta-field'; + const spacer = document.createElement('label'); + spacer.className = 'enhanced-album-meta-label'; + spacer.innerHTML = ' '; + saveDiv.appendChild(spacer); + const saveBtn = document.createElement('button'); + saveBtn.className = 'enhanced-album-save-btn'; + saveBtn.textContent = 'Save Album'; + saveBtn.onclick = (e) => { e.stopPropagation(); saveAlbumMetadata(album.id); }; + saveDiv.appendChild(saveBtn); + row.appendChild(saveDiv); + + return row; +} + +function renderTrackTable(album) { + const wrapper = document.createElement('div'); + const tracks = album.tracks || []; + + // Re-apply stored sort order if any + const activeSort = artistDetailPageState.enhancedTrackSort[album.id]; + if (activeSort) { + sortEnhancedTracks(album, activeSort.field, activeSort.ascending); + } + + if (tracks.length === 0) { + wrapper.innerHTML = '
No tracks in database
'; + return wrapper; + } + + const table = document.createElement('table'); + table.className = 'enhanced-track-table'; + + // Header + const thead = document.createElement('thead'); + const headRow = document.createElement('tr'); + const selectAllTh = document.createElement('th'); + const selectAllCb = document.createElement('input'); + selectAllCb.type = 'checkbox'; + selectAllCb.className = 'enhanced-track-checkbox'; + selectAllCb.onchange = function() { toggleSelectAllTracks(album.id, this.checked); }; + selectAllTh.appendChild(selectAllCb); + headRow.appendChild(selectAllTh); + + const columns = [ + { label: '', cls: 'col-play' }, + { label: '#', cls: 'col-num', sortField: 'track_number' }, + { label: 'Disc', cls: 'col-disc', sortField: 'disc_number' }, + { label: 'Title', cls: 'col-title', sortField: 'title' }, + { label: 'Duration', cls: 'col-duration', sortField: 'duration' }, + { label: 'Format', cls: 'col-format', sortField: 'format' }, + { label: 'Bitrate', cls: 'col-bitrate', sortField: 'bitrate' }, + { label: 'BPM', cls: 'col-bpm', sortField: 'bpm' }, + { label: 'File', cls: 'col-path' }, + { label: 'Match', cls: 'col-match' }, + { label: '', cls: 'col-delete' }, + ]; + columns.forEach(col => { + const th = document.createElement('th'); + th.className = col.cls; + const sortField = col.sortField; + const currentSort = artistDetailPageState.enhancedTrackSort[album.id]; + if (sortField) { + let headerText = col.label; + if (currentSort && currentSort.field === sortField) { + headerText += currentSort.ascending ? ' \u25B2' : ' \u25BC'; + } + th.textContent = headerText; + th.style.cursor = 'pointer'; + th.onclick = () => { + cancelInlineEdit(); + const current = artistDetailPageState.enhancedTrackSort[album.id]; + const ascending = current && current.field === sortField ? !current.ascending : true; + artistDetailPageState.enhancedTrackSort[album.id] = { field: sortField, ascending }; + sortEnhancedTracks(album, sortField, ascending); + const panelWrapper = th.closest('.enhanced-tracks-panel'); + if (panelWrapper) { + const tableContainer = panelWrapper.querySelector('.enhanced-track-table')?.parentElement; + if (tableContainer) { + const parent = tableContainer.parentElement; + const newTable = renderTrackTable(album); + parent.replaceChild(newTable, tableContainer); + } + } + }; + } else { + th.textContent = col.label; + } + headRow.appendChild(th); + }); + thead.appendChild(headRow); + table.appendChild(thead); + + // Body + const tbody = document.createElement('tbody'); + tracks.forEach(track => { + const tr = document.createElement('tr'); + tr.dataset.trackId = track.id; + if (artistDetailPageState.selectedTracks.has(String(track.id))) tr.classList.add('selected'); + + // Checkbox + const cbTd = document.createElement('td'); + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.className = 'enhanced-track-checkbox'; + cb.checked = artistDetailPageState.selectedTracks.has(String(track.id)); + cb.onchange = () => toggleTrackSelection(String(track.id)); + cbTd.appendChild(cb); + tr.appendChild(cbTd); + + // Play button + const playTd = document.createElement('td'); + playTd.className = 'col-play'; + const playBtn = document.createElement('button'); + playBtn.className = 'enhanced-play-btn'; + playBtn.innerHTML = '▶'; + playBtn.title = 'Play track'; + if (track.file_path) { + const artistName = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.name : ''; + playBtn.onclick = (e) => { + e.stopPropagation(); + playLibraryTrack(track, album.title || '', artistName); + }; + } else { + playBtn.disabled = true; + playBtn.title = 'No file available'; + } + playTd.appendChild(playBtn); + tr.appendChild(playTd); + + // Track number (editable) + const numTd = document.createElement('td'); + numTd.className = 'col-num editable'; + numTd.textContent = track.track_number || '-'; + numTd.onclick = (e) => { e.stopPropagation(); startInlineEdit(numTd, 'track', track.id, 'track_number', track.track_number || ''); }; + tr.appendChild(numTd); + + // Disc number + const discTd = document.createElement('td'); + discTd.className = 'col-disc'; + discTd.textContent = track.disc_number || '-'; + tr.appendChild(discTd); + + // Title (editable) + const titleTd = document.createElement('td'); + titleTd.className = 'col-title editable'; + titleTd.textContent = track.title || 'Unknown'; + titleTd.onclick = (e) => { e.stopPropagation(); startInlineEdit(titleTd, 'track', track.id, 'title', track.title || ''); }; + tr.appendChild(titleTd); + + // Duration + const durTd = document.createElement('td'); + durTd.className = 'col-duration'; + durTd.textContent = formatDurationMs(track.duration); + tr.appendChild(durTd); + + // Format + const fmtTd = document.createElement('td'); + fmtTd.className = 'col-format'; + const format = extractFormat(track.file_path); + const fmtSpan = document.createElement('span'); + const fmtClass = format === 'FLAC' ? 'flac' : (format === 'MP3' ? 'mp3' : 'other'); + fmtSpan.className = `enhanced-format-badge ${fmtClass}`; + fmtSpan.textContent = format; + fmtTd.appendChild(fmtSpan); + tr.appendChild(fmtTd); + + // Bitrate + const brTd = document.createElement('td'); + brTd.className = 'col-bitrate'; + const brSpan = document.createElement('span'); + const brClass = (track.bitrate || 0) >= 320 ? 'high' : ((track.bitrate || 0) >= 192 ? 'medium' : 'low'); + brSpan.className = `enhanced-bitrate ${brClass}`; + brSpan.textContent = track.bitrate ? track.bitrate + ' kbps' : '-'; + brTd.appendChild(brSpan); + tr.appendChild(brTd); + + // BPM (editable) + const bpmTd = document.createElement('td'); + bpmTd.className = 'col-bpm editable'; + bpmTd.textContent = track.bpm || '-'; + bpmTd.onclick = (e) => { e.stopPropagation(); startInlineEdit(bpmTd, 'track', track.id, 'bpm', track.bpm || ''); }; + tr.appendChild(bpmTd); + + // File path (last column) + const pathTd = document.createElement('td'); + pathTd.className = 'col-path'; + const filePath = track.file_path || '-'; + const fileName = filePath !== '-' ? filePath.split(/[\\/]/).pop() : '-'; + pathTd.textContent = fileName; + pathTd.title = filePath; + tr.appendChild(pathTd); + + // Match status chips + const matchTd = document.createElement('td'); + matchTd.className = 'col-match'; + const matchCell = document.createElement('div'); + matchCell.className = 'enhanced-track-match-cell'; + const trackServices = [ + { svc: 'spotify', col: 'spotify_track_id', label: 'SP' }, + { svc: 'musicbrainz', col: 'musicbrainz_recording_id', label: 'MB' }, + { svc: 'deezer', col: 'deezer_id', label: 'Dz' }, + { svc: 'audiodb', col: 'audiodb_id', label: 'ADB' }, + { svc: 'itunes', col: 'itunes_track_id', label: 'iT' }, + ]; + const aId = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.id : null; + trackServices.forEach(s => { + const hasId = !!track[s.col]; + const chip = document.createElement('span'); + chip.className = 'enhanced-track-match-chip' + (hasId ? ' matched' : ' not-found'); + chip.textContent = s.label; + chip.title = hasId ? `${s.svc}: ${track[s.col]}` : `${s.svc}: no match`; + chip.onclick = (e) => { + e.stopPropagation(); + openManualMatchModal('track', track.id, s.svc, track.title || '', aId); + }; + matchCell.appendChild(chip); + }); + matchTd.appendChild(matchCell); + tr.appendChild(matchTd); + + // Delete button + const delTd = document.createElement('td'); + delTd.className = 'col-delete'; + const delBtn = document.createElement('button'); + delBtn.className = 'enhanced-delete-btn'; + delBtn.innerHTML = '✕'; + delBtn.title = 'Delete track from library'; + delBtn.onclick = (e) => { e.stopPropagation(); deleteLibraryTrack(track.id, album.id); }; + delTd.appendChild(delBtn); + tr.appendChild(delTd); + + tbody.appendChild(tr); + }); + table.appendChild(tbody); + wrapper.appendChild(table); + + return wrapper; +} + +function sortEnhancedTracks(album, field, ascending) { + const tracks = album.tracks || []; + tracks.sort((a, b) => { + let valA, valB; + if (field === 'format') { + valA = extractFormat(a.file_path); + valB = extractFormat(b.file_path); + } else { + valA = a[field]; + valB = b[field]; + } + if (valA == null) return 1; + if (valB == null) return -1; + if (['track_number', 'disc_number', 'bpm', 'bitrate', 'duration'].includes(field)) { + return ascending ? (Number(valA) - Number(valB)) : (Number(valB) - Number(valA)); + } + valA = String(valA).toLowerCase(); + valB = String(valB).toLowerCase(); + return ascending ? valA.localeCompare(valB) : valB.localeCompare(valA); + }); +} + +async function deleteLibraryTrack(trackId, albumId) { + cancelInlineEdit(); + if (!confirm('Delete this track from the library? (File on disk is not affected)')) return; + try { + const response = await fetch(`/api/library/track/${trackId}`, { method: 'DELETE' }); + const result = await response.json(); + if (!result.success) throw new Error(result.error); + showToast('Track deleted from library', 'success'); + if (artistDetailPageState.enhancedData) { + const albums = artistDetailPageState.enhancedData.albums || []; + const album = albums.find(a => a.id === albumId); + if (album) { + album.tracks = (album.tracks || []).filter(t => t.id !== trackId); + } + } + artistDetailPageState.selectedTracks.delete(String(trackId)); + renderEnhancedView(); + } catch (error) { + showToast(`Delete failed: ${error.message}`, 'error'); + } +} + +async function deleteLibraryAlbum(albumId) { + if (!confirm('Delete this album and all its tracks from the library? (Files on disk are not affected)')) return; + try { + const response = await fetch(`/api/library/album/${albumId}`, { method: 'DELETE' }); + const result = await response.json(); + if (!result.success) throw new Error(result.error); + showToast(`Album deleted (${result.tracks_deleted || 0} tracks removed)`, 'success'); + if (artistDetailPageState.enhancedData) { + const album = (artistDetailPageState.enhancedData.albums || []).find(a => a.id === albumId); + if (album && album.tracks) { + album.tracks.forEach(t => artistDetailPageState.selectedTracks.delete(String(t.id))); + } + artistDetailPageState.enhancedData.albums = (artistDetailPageState.enhancedData.albums || []).filter(a => a.id !== albumId); + } + artistDetailPageState.expandedAlbums.delete(albumId); + delete artistDetailPageState.enhancedTrackSort[albumId]; + renderEnhancedView(); + } catch (error) { + showToast(`Delete failed: ${error.message}`, 'error'); + } +} + +function extractFormat(filePath) { + if (!filePath) return '-'; + const ext = filePath.split('.').pop().toLowerCase(); + const formatMap = { mp3: 'MP3', flac: 'FLAC', m4a: 'AAC', ogg: 'OGG', opus: 'OPUS', wav: 'WAV', wma: 'WMA', aac: 'AAC' }; + return formatMap[ext] || ext.toUpperCase(); +} + +function formatDurationMs(ms) { + if (!ms) return '-'; + const totalSeconds = Math.floor(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${seconds.toString().padStart(2, '0')}`; +} + +function getServiceUrl(service, entityType, id) { + if (!id) return null; + const urls = { + spotify: { + artist: `https://open.spotify.com/artist/${id}`, + album: `https://open.spotify.com/album/${id}`, + track: `https://open.spotify.com/track/${id}`, + }, + musicbrainz: { + artist: `https://musicbrainz.org/artist/${id}`, + album: `https://musicbrainz.org/release/${id}`, + track: `https://musicbrainz.org/recording/${id}`, + }, + deezer: { + artist: `https://www.deezer.com/artist/${id}`, + album: `https://www.deezer.com/album/${id}`, + track: `https://www.deezer.com/track/${id}`, + }, + audiodb: { + artist: `https://www.theaudiodb.com/artist/${id}`, + album: `https://www.theaudiodb.com/album/${id}`, + track: `https://www.theaudiodb.com/track/${id}`, + }, + itunes: { + artist: `https://music.apple.com/artist/${id}`, + album: `https://music.apple.com/album/${id}`, + track: `https://music.apple.com/song/${id}`, + }, + }; + return urls[service] && urls[service][entityType] || null; +} + +function makeClickableBadge(service, entityType, id, label) { + const url = getServiceUrl(service, entityType, id); + if (url) { + const a = document.createElement('a'); + a.className = `enhanced-id-badge ${service === 'musicbrainz' ? 'mb' : service}`; + a.href = url; + a.target = '_blank'; + a.rel = 'noopener noreferrer'; + a.textContent = label; + a.title = `${label}: ${id} (click to open)`; + a.onclick = (e) => e.stopPropagation(); + return a; + } + const span = document.createElement('span'); + span.className = `enhanced-id-badge ${service === 'musicbrainz' ? 'mb' : service}`; + span.textContent = label; + span.title = `${label}: ${id}`; + return span; +} + +// ---- Inline Editing ---- + +function startInlineEdit(cell, type, id, field, currentValue) { + if (cell.querySelector('.enhanced-inline-input')) return; + cancelInlineEdit(); + + const isNumeric = ['track_number', 'bpm'].includes(field); + const originalContent = cell.innerHTML; + cell.dataset.originalContent = originalContent; + + const input = document.createElement('input'); + input.type = isNumeric ? 'number' : 'text'; + input.className = 'enhanced-inline-input' + (isNumeric ? ' num' : ''); + input.value = currentValue || ''; + if (field === 'bpm') input.step = '0.1'; + if (field === 'track_number') { input.min = '1'; input.step = '1'; } + + cell.innerHTML = ''; + cell.appendChild(input); + input.focus(); + input.select(); + + artistDetailPageState.editingCell = { cell, type, id, field, originalContent }; + + input.addEventListener('click', e => e.stopPropagation()); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + saveInlineEdit(type, id, field, input.value); + } else if (e.key === 'Escape') { + cancelInlineEdit(); + } + e.stopPropagation(); + }); + input.addEventListener('blur', () => { + setTimeout(() => { + if (artistDetailPageState.editingCell && artistDetailPageState.editingCell.cell === cell) { + saveInlineEdit(type, id, field, input.value); + } + }, 150); + }); +} + +async function saveInlineEdit(type, id, field, newValue) { + const editInfo = artistDetailPageState.editingCell; + if (!editInfo) return; + artistDetailPageState.editingCell = null; + + let parsedValue = newValue; + if (field === 'track_number') parsedValue = parseInt(newValue) || null; + else if (field === 'bpm') parsedValue = parseFloat(newValue) || null; + else if (field === 'explicit') parsedValue = parseInt(newValue) || 0; + + const url = type === 'track' ? `/api/library/track/${id}` : `/api/library/album/${id}`; + + try { + const response = await fetch(url, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ [field]: parsedValue }) + }); + const result = await response.json(); + if (!result.success) throw new Error(result.error); + + const displayValue = parsedValue !== null && parsedValue !== '' ? String(parsedValue) : '-'; + editInfo.cell.textContent = displayValue; + updateLocalEnhancedData(type, id, field, parsedValue); + showToast(`Updated ${field}`, 'success'); + } catch (error) { + console.error('Failed to save inline edit:', error); + editInfo.cell.innerHTML = editInfo.originalContent; + showToast(`Failed to update: ${error.message}`, 'error'); + } +} + +function cancelInlineEdit() { + const editInfo = artistDetailPageState.editingCell; + if (!editInfo) return; + editInfo.cell.innerHTML = editInfo.originalContent; + artistDetailPageState.editingCell = null; +} + +function updateLocalEnhancedData(type, id, field, value) { + const data = artistDetailPageState.enhancedData; + if (!data) return; + + if (type === 'track') { + for (const album of data.albums) { + const track = (album.tracks || []).find(t => String(t.id) === String(id)); + if (track) { track[field] = value; break; } + } + } else if (type === 'album') { + const album = data.albums.find(a => String(a.id) === String(id)); + if (album) album[field] = value; + } else if (type === 'artist') { + data.artist[field] = value; + } +} + +// ---- Track Selection & Bulk Operations ---- + +function toggleTrackSelection(trackId) { + trackId = String(trackId); + if (artistDetailPageState.selectedTracks.has(trackId)) { + artistDetailPageState.selectedTracks.delete(trackId); + } else { + artistDetailPageState.selectedTracks.add(trackId); + } + const row = document.querySelector(`tr[data-track-id="${trackId}"]`); + if (row) row.classList.toggle('selected', artistDetailPageState.selectedTracks.has(trackId)); + updateBulkBar(); +} + +function toggleSelectAllTracks(albumId, checked) { + const album = findEnhancedAlbum(albumId); + if (!album || !album.tracks) return; + + album.tracks.forEach(track => { + const tid = String(track.id); + if (checked) artistDetailPageState.selectedTracks.add(tid); + else artistDetailPageState.selectedTracks.delete(tid); + + const row = document.querySelector(`tr[data-track-id="${tid}"]`); + if (row) { + row.classList.toggle('selected', checked); + const cb = row.querySelector('.enhanced-track-checkbox'); + if (cb) cb.checked = checked; + } + }); + updateBulkBar(); +} + +function clearTrackSelection() { + artistDetailPageState.selectedTracks.forEach(tid => { + const row = document.querySelector(`tr[data-track-id="${tid}"]`); + if (row) { + row.classList.remove('selected'); + const cb = row.querySelector('.enhanced-track-checkbox'); + if (cb) cb.checked = false; + } + }); + artistDetailPageState.selectedTracks.clear(); + document.querySelectorAll('.enhanced-track-table thead .enhanced-track-checkbox').forEach(cb => cb.checked = false); + updateBulkBar(); +} + +function updateBulkBar() { + const bar = document.getElementById('enhanced-bulk-bar'); + const count = document.getElementById('enhanced-bulk-count'); + if (!bar || !count) return; + const n = artistDetailPageState.selectedTracks.size; + count.textContent = n; + bar.classList.toggle('visible', n > 0); +} + +function showBulkEditModal() { + const overlay = document.getElementById('enhanced-bulk-edit-overlay'); + const body = document.getElementById('enhanced-bulk-modal-body'); + const title = document.getElementById('enhanced-bulk-modal-title'); + if (!overlay || !body) return; + + const count = artistDetailPageState.selectedTracks.size; + title.textContent = `Batch Edit ${count} Track${count !== 1 ? 's' : ''}`; + + body.innerHTML = ` +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ `; + + overlay.classList.remove('hidden'); +} + +function closeBulkEditModal() { + const overlay = document.getElementById('enhanced-bulk-edit-overlay'); + if (overlay) overlay.classList.add('hidden'); +} + +async function executeBulkEdit() { + const trackIds = Array.from(artistDetailPageState.selectedTracks); + if (trackIds.length === 0) return; + + const updates = {}; + const trackNum = document.getElementById('bulk-edit-track-number'); + const bpm = document.getElementById('bulk-edit-bpm'); + const style = document.getElementById('bulk-edit-style'); + const mood = document.getElementById('bulk-edit-mood'); + const explicit = document.getElementById('bulk-edit-explicit'); + + if (trackNum && trackNum.value !== '') updates.track_number = parseInt(trackNum.value); + if (bpm && bpm.value !== '') updates.bpm = parseFloat(bpm.value); + if (style && style.value !== '') updates.style = style.value; + if (mood && mood.value !== '') updates.mood = mood.value; + if (explicit && explicit.value !== '') updates.explicit = parseInt(explicit.value); + + if (Object.keys(updates).length === 0) { + showToast('No changes to apply', 'error'); + return; + } + + try { + const response = await fetch('/api/library/tracks/batch', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ track_ids: trackIds, updates }) + }); + const result = await response.json(); + if (!result.success) throw new Error(result.error); + + showToast(`Updated ${result.updated_count} tracks`, 'success'); + closeBulkEditModal(); + + for (const [field, val] of Object.entries(updates)) { + trackIds.forEach(tid => updateLocalEnhancedData('track', tid, field, val)); + } + + reRenderExpandedPanels(); + clearTrackSelection(); + + } catch (error) { + console.error('Bulk edit failed:', error); + showToast(`Bulk edit failed: ${error.message}`, 'error'); + } +} + +// ---- Save Artist / Album Metadata ---- + +async function saveArtistMetadata() { + const form = document.getElementById('enhanced-artist-meta-form'); + if (!form) return; + + const inputs = form.querySelectorAll('.enhanced-meta-field-input'); + const updates = {}; + const original = artistDetailPageState.enhancedData.artist; + + inputs.forEach(input => { + const field = input.dataset.field; + if (!field) return; + let value = (input.tagName === 'TEXTAREA' ? input.value : input.value).trim(); + + let origVal = original[field]; + if (field === 'genres') { + const newGenres = value ? value.split(',').map(g => g.trim()).filter(Boolean) : []; + const origGenres = Array.isArray(origVal) ? origVal : []; + if (JSON.stringify(newGenres) !== JSON.stringify(origGenres)) updates[field] = newGenres; + } else { + if ((value || '') !== (origVal || '')) updates[field] = value || null; + } + }); + + if (Object.keys(updates).length === 0) { + showToast('No changes to save', 'error'); + return; + } + + try { + const response = await fetch(`/api/library/artist/${original.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updates) + }); + const result = await response.json(); + if (!result.success) throw new Error(result.error); + + for (const [field, value] of Object.entries(updates)) { + artistDetailPageState.enhancedData.artist[field] = value; + } + + // Update the display name in the header + if (updates.name) { + const nameEl = document.querySelector('.enhanced-artist-meta-name'); + if (nameEl) nameEl.textContent = updates.name; + } + + showToast(`Artist metadata saved (${(result.updated_fields || []).join(', ')})`, 'success'); + } catch (error) { + console.error('Failed to save artist metadata:', error); + showToast(`Failed to save: ${error.message}`, 'error'); + } +} + +function revertArtistMetadata() { + const data = artistDetailPageState.enhancedData; + if (!data) return; + + const panel = document.getElementById('enhanced-artist-meta'); + if (!panel) return; + + const parent = panel.parentNode; + const newPanel = renderArtistMetaPanel(data.artist); + parent.replaceChild(newPanel, panel); + showToast('Reverted to saved values', 'success'); +} + +async function saveAlbumMetadata(albumId) { + const metaRow = document.getElementById(`enhanced-album-meta-${albumId}`); + if (!metaRow) return; + + const album = findEnhancedAlbum(albumId); + if (!album) return; + + const inputs = metaRow.querySelectorAll('.enhanced-album-meta-input'); + const updates = {}; + + inputs.forEach(input => { + const field = input.dataset.field; + if (!field) return; + let value = input.value.trim(); + + if (field === 'genres') { + const newGenres = value ? value.split(',').map(g => g.trim()).filter(Boolean) : []; + const origGenres = Array.isArray(album.genres) ? album.genres : []; + if (JSON.stringify(newGenres) !== JSON.stringify(origGenres)) updates[field] = newGenres; + } else if (field === 'year' || field === 'explicit' || field === 'track_count') { + const numVal = value !== '' ? parseInt(value) : null; + if (numVal !== (album[field] || null)) updates[field] = numVal; + } else { + if ((value || '') !== (album[field] || '')) updates[field] = value || null; + } + }); + + if (Object.keys(updates).length === 0) { + showToast('No album changes to save', 'error'); + return; + } + + try { + const response = await fetch(`/api/library/album/${albumId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updates) + }); + const result = await response.json(); + if (!result.success) throw new Error(result.error); + + for (const [field, value] of Object.entries(updates)) { + album[field] = value; + } + + // Update album row display + const albumRow = document.getElementById(`enhanced-album-row-${albumId}`); + if (albumRow) { + if (updates.title) { + const titleEl = albumRow.querySelector('.enhanced-album-title'); + if (titleEl) { titleEl.textContent = updates.title; titleEl.title = updates.title; } + } + if (updates.year !== undefined) { + const yearEl = albumRow.querySelector('.enhanced-album-year'); + if (yearEl) yearEl.textContent = updates.year || '-'; + } + } + + showToast(`Album metadata saved (${(result.updated_fields || []).join(', ')})`, 'success'); + } catch (error) { + console.error('Failed to save album metadata:', error); + showToast(`Failed to save: ${error.message}`, 'error'); + } +} + +function reRenderExpandedPanels() { + artistDetailPageState.expandedAlbums.forEach(albumId => { + const panel = document.getElementById(`enhanced-tracks-panel-${albumId}`); + if (!panel) return; + const inner = panel.querySelector('.enhanced-tracks-panel-inner'); + if (!inner) return; + + const album = findEnhancedAlbum(albumId); + if (album) { + inner.innerHTML = ''; + inner.appendChild(renderExpandedAlbumHeader(album)); + inner.appendChild(renderAlbumMetaRow(album)); + inner.appendChild(renderTrackTable(album)); + } + }); +} + +// ---- Manual Match Modal ---- + +function openManualMatchModal(entityType, entityId, service, defaultQuery, artistId) { + // Remove existing modal if any + const existing = document.getElementById('enhanced-manual-match-overlay'); + if (existing) existing.remove(); + + const serviceLabels = { + spotify: 'Spotify', musicbrainz: 'MusicBrainz', deezer: 'Deezer', + audiodb: 'AudioDB', itunes: 'iTunes' + }; + + const overlay = document.createElement('div'); + overlay.id = 'enhanced-manual-match-overlay'; + overlay.className = 'modal-overlay'; + overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; + + const modal = document.createElement('div'); + modal.className = 'enhanced-manual-match-modal'; + + // Header + const header = document.createElement('div'); + header.className = 'enhanced-bulk-modal-header'; + const title = document.createElement('h3'); + title.textContent = `Match ${entityType} on ${serviceLabels[service] || service}`; + header.appendChild(title); + const closeBtn = document.createElement('button'); + closeBtn.className = 'enhanced-bulk-modal-close'; + closeBtn.innerHTML = '×'; + closeBtn.onclick = () => overlay.remove(); + header.appendChild(closeBtn); + modal.appendChild(header); + + // Search bar + const searchRow = document.createElement('div'); + searchRow.className = 'enhanced-match-search-row'; + const searchInput = document.createElement('input'); + searchInput.type = 'text'; + searchInput.className = 'enhanced-match-search-input'; + searchInput.placeholder = `Search ${serviceLabels[service] || service}...`; + searchInput.value = defaultQuery; + searchRow.appendChild(searchInput); + const searchBtn = document.createElement('button'); + searchBtn.className = 'enhanced-enrich-btn'; + searchBtn.textContent = 'Search'; + searchBtn.onclick = () => doManualMatchSearch(service, entityType, searchInput.value, resultsContainer, entityId, artistId); + searchRow.appendChild(searchBtn); + modal.appendChild(searchRow); + + // Handle Enter key + searchInput.onkeydown = (e) => { + if (e.key === 'Enter') searchBtn.click(); + }; + + // Results container + const resultsContainer = document.createElement('div'); + resultsContainer.className = 'enhanced-match-results'; + resultsContainer.innerHTML = '
Press Search or Enter to find matches
'; + modal.appendChild(resultsContainer); + + overlay.appendChild(modal); + document.body.appendChild(overlay); + + // Auto-search on open + searchInput.focus(); + searchBtn.click(); +} + +async function doManualMatchSearch(service, entityType, query, container, entityId, artistId) { + if (!query.trim()) { + container.innerHTML = '
Enter a search term
'; + return; + } + + container.innerHTML = '
Searching...
'; + + try { + const response = await fetch('/api/library/search-service', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ service, entity_type: entityType, query: query.trim() }) + }); + + const data = await response.json(); + if (!data.success) throw new Error(data.error); + + const results = data.results || []; + container.innerHTML = ''; + + if (results.length === 0) { + container.innerHTML = '
No results found. Try a different search.
'; + return; + } + + results.forEach(result => { + const row = document.createElement('div'); + row.className = 'enhanced-match-result-row'; + + if (result.image) { + const img = document.createElement('img'); + img.className = 'enhanced-match-result-img'; + img.src = result.image; + img.alt = ''; + img.onerror = function() { this.style.display = 'none'; }; + row.appendChild(img); + } else { + const placeholder = document.createElement('div'); + placeholder.className = 'enhanced-match-result-img-placeholder'; + placeholder.innerHTML = '🎵'; + row.appendChild(placeholder); + } + + const info = document.createElement('div'); + info.className = 'enhanced-match-result-info'; + const name = document.createElement('div'); + name.className = 'enhanced-match-result-name'; + name.textContent = result.name || 'Unknown'; + info.appendChild(name); + if (result.extra) { + const extra = document.createElement('div'); + extra.className = 'enhanced-match-result-extra'; + extra.textContent = result.extra; + info.appendChild(extra); + } + const idLine = document.createElement('div'); + idLine.className = 'enhanced-match-result-id'; + idLine.textContent = `ID: ${result.id}`; + info.appendChild(idLine); + row.appendChild(info); + + const matchBtn = document.createElement('button'); + matchBtn.className = 'enhanced-meta-save-btn'; + matchBtn.textContent = 'Match'; + matchBtn.onclick = () => applyManualMatch(entityType, entityId, service, result.id, artistId); + row.appendChild(matchBtn); + + container.appendChild(row); + }); + + } catch (error) { + container.innerHTML = `
Error: ${escapeHtml(error.message)}
`; + } +} + +async function applyManualMatch(entityType, entityId, service, serviceId, artistId) { + try { + showToast(`Matching ${entityType} to ${service}...`, 'info'); + + const response = await fetch('/api/library/manual-match', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + entity_type: entityType, + entity_id: entityId, + service: service, + service_id: serviceId, + artist_id: artistId + }) + }); + + const result = await response.json(); + if (!result.success) throw new Error(result.error); + + showToast(`Manually matched to ${service} ID: ${serviceId}`, 'success'); + + // Close modal + const overlay = document.getElementById('enhanced-manual-match-overlay'); + if (overlay) overlay.remove(); + + // Update view with fresh data + if (result.updated_data && result.updated_data.success) { + artistDetailPageState.enhancedData = result.updated_data; + renderEnhancedView(); + } else if (artistDetailPageState.currentArtistId) { + await loadEnhancedViewData(artistDetailPageState.currentArtistId); + } + + } catch (error) { + showToast(`Match failed: ${error.message}`, 'error'); + } +} + +// ---- Enrichment ---- + +let _enrichmentInFlight = false; + +async function runEnrichment(entityType, entityId, service, name, artistName, artistId) { + if (_enrichmentInFlight) { + showToast('An enrichment is already in progress', 'error'); + return; + } + + _enrichmentInFlight = true; + + // Add loading class to all match chips for this service + document.querySelectorAll('.enhanced-match-chip').forEach(chip => { + const chipText = chip.textContent.toLowerCase(); + if (chipText.startsWith(service === 'musicbrainz' ? 'mb' : service) || + (service === 'musicbrainz' && chipText.startsWith('musicbrainz'))) { + chip.classList.add('loading'); + } + }); + + showToast(`Enriching ${entityType} from ${service}...`, 'info'); + + try { + const response = await fetch('/api/library/enrich', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + entity_type: entityType, + entity_id: entityId, + service: service, + name: name, + artist_name: artistName, + artist_id: artistId + }) + }); + + const result = await response.json(); + + if (response.status === 429) { + showToast(result.error || 'Another enrichment is in progress', 'error'); + return; + } + + if (!result.success) { + throw new Error(result.error || 'Enrichment failed'); + } + + // Show per-service results + const results = result.results || {}; + const successes = Object.entries(results).filter(([, r]) => r.success).map(([s]) => s); + const failures = Object.entries(results).filter(([, r]) => !r.success).map(([s, r]) => `${s}: ${r.error}`); + + if (successes.length > 0) { + showToast(`Enriched from: ${successes.join(', ')}`, 'success'); + } + if (failures.length > 0) { + showToast(`Failed: ${failures.join('; ')}`, 'error'); + } + + // Update local data with fresh response and re-render (preserves expanded state) + if (result.updated_data && result.updated_data.success) { + artistDetailPageState.enhancedData = result.updated_data; + renderEnhancedView(); + } else if (artistDetailPageState.currentArtistId) { + await loadEnhancedViewData(artistDetailPageState.currentArtistId); + } + + } catch (error) { + console.error('Enrichment error:', error); + showToast(`Enrichment error: ${error.message}`, 'error'); + } finally { + _enrichmentInFlight = false; + document.querySelectorAll('.enhanced-match-chip.loading').forEach(c => c.classList.remove('loading')); + } +} + +// Close enrich dropdowns when clicking outside +document.addEventListener('click', (e) => { + if (!e.target.closest('.enhanced-enrich-wrap')) { + document.querySelectorAll('.enhanced-enrich-menu.visible').forEach(m => m.classList.remove('visible')); + } +}); + +async function playLibraryTrack(track, albumTitle, artistName) { + if (!track.file_path) { + showToast('No file available for this track', 'error'); + return; + } + + try { + // Stop any current playback first + if (audioPlayer && !audioPlayer.paused) { + audioPlayer.pause(); + } + + // Set track info in the media player UI + setTrackInfo({ + title: track.title || 'Unknown Track', + artist: artistName || 'Unknown Artist', + album: albumTitle || 'Unknown Album', + filename: track.file_path, + is_library: true + }); + + // Show loading state + showLoadingAnimation(); + const loadingText = document.querySelector('.loading-text'); + if (loadingText) { + loadingText.textContent = 'Loading library track...'; + } + + // POST to library play endpoint + const response = await fetch('/api/library/play', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + file_path: track.file_path, + title: track.title || '', + artist: artistName || '', + album: albumTitle || '' + }) + }); + + const result = await response.json(); + if (!result.success) { + throw new Error(result.error || 'Failed to start library playback'); + } + + // Stream state is already "ready" — start audio playback directly + await startAudioPlayback(); + + } catch (error) { + console.error('Library playback error:', error); + showToast(`Playback error: ${error.message}`, 'error'); + hideLoadingAnimation(); + clearTrack(); + } +} + +// ==================== End Enhanced Library Management View ==================== + // UI state management functions function showArtistDetailLoading(show) { const loadingElement = document.getElementById("artist-detail-loading"); diff --git a/webui/static/style.css b/webui/static/style.css index 913f226a..87d3c673 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -29717,4 +29717,1250 @@ body { .history-total { text-align: center; padding: 12px 0 4px; font-size: 11px; color: rgba(255,255,255,0.3); +} + +/* ==================== Enhanced Library Management View ==================== */ + +/* View Toggle Buttons */ +.enhanced-view-toggle-btn { + padding: 8px 18px; + background: linear-gradient(135deg, rgba(255,255,255,0.06), rgba(255,255,255,0.03)); + border: 1px solid rgba(255,255,255,0.12); + border-radius: 8px; + color: rgba(255,255,255,0.6); + font-size: 13px; + font-weight: 600; + cursor: pointer; + letter-spacing: 0.3px; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); +} +.enhanced-view-toggle-btn:hover:not(.active) { + background: rgba(255,255,255,0.08); + color: rgba(255,255,255,0.8); +} +.enhanced-view-toggle-btn.active { + background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.25) 0%, rgba(var(--accent-light-rgb), 0.15) 100%); + border-color: rgba(var(--accent-rgb), 0.6); + color: rgb(var(--accent-light-rgb)); + font-weight: 700; + box-shadow: 0 4px 16px rgba(var(--accent-rgb), 0.2), inset 0 1px 0 rgba(255,255,255,0.1); + text-shadow: 0 0 12px rgba(var(--accent-rgb), 0.3); +} + +/* Main Container */ +.enhanced-view-container { + padding: 0; + animation: enhancedFadeIn 0.35s ease-out; +} +@keyframes enhancedFadeIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Artist Meta Panel */ +.enhanced-artist-meta { + background: linear-gradient(135deg, rgba(35,35,35,0.6), rgba(20,20,20,0.8)); + border: 1px solid rgba(255,255,255,0.08); + border-radius: 16px; + padding: 0; + margin-bottom: 20px; + position: relative; + overflow: hidden; +} +.enhanced-artist-meta::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, rgba(var(--accent-rgb), 0.6) 0%, transparent 100%); +} +.enhanced-artist-meta-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 24px; +} +.enhanced-artist-meta-header-left { + display: flex; + align-items: center; + gap: 16px; + flex: 1; + min-width: 0; +} +.enhanced-artist-meta-image { + width: 64px; + height: 64px; + border-radius: 50%; + object-fit: cover; + border: 2px solid rgba(var(--accent-rgb), 0.3); + flex-shrink: 0; +} +.enhanced-artist-meta-info { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} +.enhanced-artist-meta-name { + font-size: 22px; + font-weight: 700; + color: #ffffff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.enhanced-artist-id-badges { + display: flex; + gap: 6px; + flex-wrap: wrap; +} +.enhanced-id-badge { + padding: 2px 8px; + border-radius: 4px; + font-size: 9px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + cursor: pointer; + transition: all 0.15s ease; + text-decoration: none; +} +.enhanced-id-badge:hover { + filter: brightness(1.3); + transform: translateY(-1px); +} +a.enhanced-id-badge, +a.enhanced-id-badge:visited { + text-decoration: none; +} +.enhanced-id-badge.spotify { background: rgba(30, 215, 96, 0.15); color: #1ed760; border: 1px solid rgba(30, 215, 96, 0.25); } +.enhanced-id-badge.mb { background: rgba(186, 72, 27, 0.15); color: #e8834a; border: 1px solid rgba(186, 72, 27, 0.25); } +.enhanced-id-badge.deezer { background: rgba(160, 55, 200, 0.15); color: #c770e8; border: 1px solid rgba(160, 55, 200, 0.25); } +.enhanced-id-badge.audiodb { background: rgba(50, 120, 220, 0.15); color: #5a9de6; border: 1px solid rgba(50, 120, 220, 0.25); } +.enhanced-id-badge.server { background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.5); border: 1px solid rgba(255,255,255,0.12); } + +.enhanced-meta-edit-toggle { + padding: 8px 18px; + background: rgba(255,255,255,0.06); + border: 1px solid rgba(255,255,255,0.12); + border-radius: 8px; + color: rgba(255,255,255,0.7); + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} +.enhanced-meta-edit-toggle:hover { + background: rgba(255,255,255,0.1); + color: #fff; +} +.enhanced-meta-edit-toggle.active { + background: rgba(var(--accent-rgb), 0.12); + border-color: rgba(var(--accent-rgb), 0.3); + color: rgb(var(--accent-light-rgb)); +} + +.enhanced-artist-meta-form { + padding: 0 24px 20px; + border-top: 1px solid rgba(255,255,255,0.06); + animation: enhancedFadeIn 0.25s ease-out; +} +.enhanced-artist-meta-form.hidden { + display: none; +} +.enhanced-artist-form-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 16px; + padding-top: 12px; + border-top: 1px solid rgba(255,255,255,0.05); +} + +.enhanced-artist-meta-actions { + display: flex; + gap: 8px; +} +.enhanced-meta-save-btn, +.enhanced-meta-cancel-btn { + padding: 6px 16px; + border: none; + border-radius: 8px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} +.enhanced-meta-save-btn { + background: rgb(var(--accent-rgb)); + color: #ffffff; +} +.enhanced-meta-save-btn:hover { + background: rgb(var(--accent-light-rgb)); + transform: translateY(-1px); +} +.enhanced-meta-cancel-btn { + background: rgba(255,255,255,0.1); + color: rgba(255,255,255,0.7); +} +.enhanced-meta-cancel-btn:hover { + background: rgba(255,255,255,0.15); +} + +/* Stats Summary Bar */ +.enhanced-stats-bar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 24px; + background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.05) 0%, rgba(255,255,255,0.02) 100%); + border: 1px solid rgba(var(--accent-rgb), 0.1); + border-radius: 12px; + margin-bottom: 24px; + flex-wrap: wrap; + gap: 12px; +} +.enhanced-stats-items { + display: flex; + gap: 24px; + flex-wrap: wrap; +} +.enhanced-stat-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} +.enhanced-stat-value { + font-size: 20px; + font-weight: 700; + color: #ffffff; + font-variant-numeric: tabular-nums; +} +.enhanced-stat-label { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.8px; + color: rgba(255,255,255,0.4); +} +.enhanced-stats-formats { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +/* Expanded Album Header (inside panel) */ +.enhanced-expanded-header { + display: flex; + gap: 24px; + margin-bottom: 16px; + padding: 20px; + padding-bottom: 20px; + border-bottom: 1px solid rgba(255,255,255,0.06); + background: linear-gradient(135deg, rgba(255,255,255,0.025) 0%, rgba(255,255,255,0.01) 100%); + border-radius: 12px; +} +.enhanced-expanded-art { + width: 160px; + height: 160px; + border-radius: 12px; + object-fit: cover; + flex-shrink: 0; + border: 1px solid rgba(255,255,255,0.1); + box-shadow: 0 8px 32px rgba(0,0,0,0.4), 0 2px 8px rgba(0,0,0,0.2); +} +.enhanced-expanded-info { + display: flex; + flex-direction: column; + gap: 8px; + justify-content: center; + min-width: 0; + flex: 1; +} +.enhanced-expanded-title { + font-size: 20px; + font-weight: 700; + color: #ffffff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.enhanced-expanded-meta { + font-size: 13px; + color: rgba(255,255,255,0.5); +} +.enhanced-expanded-genres { + display: flex; + gap: 6px; + flex-wrap: wrap; +} +.enhanced-genre-tag { + padding: 2px 10px; + background: rgba(var(--accent-rgb), 0.1); + border: 1px solid rgba(var(--accent-rgb), 0.2); + border-radius: 12px; + font-size: 11px; + color: rgb(var(--accent-light-rgb)); + font-weight: 500; +} +.enhanced-expanded-ids { + display: flex; + gap: 6px; + margin-top: 2px; +} +.enhanced-artist-meta-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 16px; +} +.enhanced-meta-field { + display: flex; + flex-direction: column; + gap: 4px; +} +.enhanced-meta-field.wide { + grid-column: span 2; +} +.enhanced-meta-field-label { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.8px; + color: rgba(255,255,255,0.4); +} +.enhanced-meta-field-input { + width: 100%; + padding: 8px 12px; + background: linear-gradient(135deg, rgba(255,255,255,0.06), rgba(255,255,255,0.03)); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 8px; + color: #ffffff; + font-size: 13px; + font-family: inherit; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-sizing: border-box; +} +.enhanced-meta-field-input:focus { + outline: none; + border-color: rgba(var(--accent-rgb), 0.6); + background: rgba(var(--accent-rgb), 0.05); + box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.12), 0 0 16px rgba(var(--accent-rgb), 0.08); +} +.enhanced-meta-field-input::placeholder { + color: rgba(255,255,255,0.25); +} +textarea.enhanced-meta-field-input { + min-height: 60px; + resize: vertical; +} +.enhanced-meta-field-readonly { + padding: 8px 12px; + background: rgba(255,255,255,0.03); + border: 1px solid rgba(255,255,255,0.05); + border-radius: 8px; + color: rgba(255,255,255,0.5); + font-size: 13px; + font-family: 'SF Mono', 'Consolas', 'Courier New', monospace; + cursor: default; + word-break: break-all; +} + +/* Section Headers */ +.enhanced-section { + margin-bottom: 28px; +} +.enhanced-section-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 0; + margin-bottom: 4px; + border-bottom: 2px solid rgba(255,255,255,0.06); +} +.enhanced-section-title { + font-size: 16px; + font-weight: 700; + color: #ffffff; +} +.enhanced-section-count { + font-size: 12px; + color: rgba(255,255,255,0.4); + font-weight: 500; +} + +/* Album Grid */ +.enhanced-album-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 6px; +} +.enhanced-album-wrapper.expanded { + grid-column: 1 / -1; +} + +/* Album Rows */ +.enhanced-album-row { + display: flex; + align-items: center; + gap: 14px; + padding: 12px 16px; + background: rgba(255,255,255,0.02); + border: 1px solid rgba(255,255,255,0.05); + border-radius: 10px; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + user-select: none; +} +.enhanced-album-row:hover { + background: rgba(255,255,255,0.05); + border-color: rgba(255,255,255,0.1); +} +.enhanced-album-row.expanded { + background: rgba(var(--accent-rgb), 0.04); + border-color: rgba(var(--accent-rgb), 0.2); + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + margin-bottom: 0; +} +.enhanced-album-expand-icon { + font-size: 12px; + color: rgba(255,255,255,0.4); + transition: transform 0.25s ease; + flex-shrink: 0; + width: 16px; + text-align: center; +} +.enhanced-album-row.expanded .enhanced-album-expand-icon { + transform: rotate(90deg); + color: rgb(var(--accent-light-rgb)); +} +.enhanced-album-thumb { + width: 40px; + height: 40px; + border-radius: 6px; + object-fit: cover; + flex-shrink: 0; + background: rgba(255,255,255,0.05); +} +.enhanced-album-thumb-fallback { + width: 40px; + height: 40px; + border-radius: 6px; + background: linear-gradient(135deg, rgba(255,255,255,0.08), rgba(255,255,255,0.03)); + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + flex-shrink: 0; +} +.enhanced-album-info-block { + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} +.enhanced-album-meta-line { + font-size: 12px; + color: rgba(255,255,255,0.4); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.enhanced-album-title { + font-size: 14px; + font-weight: 600; + color: #ffffff; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.enhanced-album-year { + font-size: 12px; + color: rgba(255,255,255,0.4); + flex-shrink: 0; + min-width: 40px; +} +.enhanced-album-type-badge { + padding: 2px 8px; + border-radius: 4px; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + flex-shrink: 0; +} +.enhanced-album-type-badge.album { + background: rgba(var(--accent-rgb), 0.15); + color: rgb(var(--accent-light-rgb)); + border: 1px solid rgba(var(--accent-rgb), 0.25); +} +.enhanced-album-type-badge.ep { + background: rgba(255, 184, 77, 0.15); + color: #ffb84d; + border: 1px solid rgba(255, 184, 77, 0.25); +} +.enhanced-album-type-badge.single { + background: rgba(147, 130, 255, 0.15); + color: #9382ff; + border: 1px solid rgba(147, 130, 255, 0.25); +} +.enhanced-album-track-count { + font-size: 12px; + color: rgba(255,255,255,0.35); + flex-shrink: 0; + min-width: 60px; + text-align: right; +} + +/* Expandable Tracks Panel */ +.enhanced-tracks-panel { + max-height: 0; + overflow: hidden; + transition: max-height 0.35s cubic-bezier(0.4, 0, 0.2, 1); + background: rgba(0,0,0,0.2); + border: 1px solid rgba(var(--accent-rgb), 0.12); + border-top: none; + border-radius: 0 0 10px 10px; + margin-bottom: 4px; +} +.enhanced-tracks-panel.visible { + max-height: 2000px; + transition: max-height 0.5s cubic-bezier(0.4, 0, 0.2, 1); +} +.enhanced-tracks-panel-inner { + padding: 16px; +} + +/* Album Meta Edit Row (inside expanded panel) */ +.enhanced-album-meta-row { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 12px; + padding: 12px 16px; + background: rgba(255,255,255,0.02); + border-radius: 8px; + margin-bottom: 12px; + border: 1px solid rgba(255,255,255,0.05); +} +.enhanced-album-meta-field { + display: flex; + flex-direction: column; + gap: 3px; +} +.enhanced-album-meta-label { + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.8px; + color: rgba(255,255,255,0.35); +} +.enhanced-album-meta-input { + width: 100%; + padding: 6px 10px; + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.08); + border-radius: 6px; + color: #ffffff; + font-size: 12px; + font-family: inherit; + transition: all 0.2s ease; + box-sizing: border-box; +} +.enhanced-album-meta-input:focus { + outline: none; + border-color: rgba(var(--accent-rgb), 0.5); + background: rgba(var(--accent-rgb), 0.04); + box-shadow: 0 0 0 2px rgba(var(--accent-rgb), 0.1); +} +.enhanced-album-save-btn { + align-self: flex-end; + padding: 6px 14px; + background: rgb(var(--accent-rgb)); + color: #fff; + border: none; + border-radius: 6px; + font-size: 11px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} +.enhanced-album-save-btn:hover { + background: rgb(var(--accent-light-rgb)); +} + +/* Track Table */ +.enhanced-track-table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; +} +.enhanced-track-table th { + background: rgba(255,255,255,0.06); + color: rgba(255,255,255,0.7); + font-weight: 600; + padding: 10px 10px; + text-align: left; + border-bottom: 1px solid rgba(255,255,255,0.1); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + position: sticky; + top: 0; + z-index: 1; +} +.enhanced-track-table th:first-child { width: 36px; text-align: center; } +.enhanced-track-table .col-play { width: 36px; text-align: center; padding: 0 4px; } +.enhanced-track-table .col-num { width: 45px; text-align: center; } +.enhanced-track-table .col-title { width: auto; } +.enhanced-track-table .col-duration { width: 70px; text-align: right; } +.enhanced-track-table .col-format { width: 80px; } +.enhanced-track-table .col-bitrate { width: 75px; text-align: right; } +.enhanced-track-table .col-path { width: 25%; } +.enhanced-track-table .col-bpm { width: 60px; text-align: right; } +.enhanced-track-table .col-actions { width: 50px; text-align: center; } + +.enhanced-track-table td { + padding: 8px 10px; + border-bottom: 1px solid rgba(255,255,255,0.04); + font-size: 13px; + color: rgba(255,255,255,0.85); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: middle; +} +.enhanced-track-table td:first-child { text-align: center; } +.enhanced-track-table td.col-num { text-align: center; color: rgba(255,255,255,0.4); font-size: 12px; } +.enhanced-track-table td.col-duration { text-align: right; color: rgba(255,255,255,0.5); font-size: 12px; } +.enhanced-track-table td.col-bitrate { text-align: right; } +.enhanced-track-table td.col-bpm { text-align: right; color: rgba(255,255,255,0.5); } +.enhanced-track-table td.col-path { + font-family: 'SF Mono', 'Consolas', 'Courier New', monospace; + font-size: 11px; + color: rgba(255,255,255,0.35); + direction: rtl; + text-align: left; +} +.enhanced-track-table tr:hover { + background: rgba(255,255,255,0.03); +} +.enhanced-track-table tr.selected { + background: rgba(var(--accent-rgb), 0.06); +} + +/* Play button */ +.enhanced-play-btn { + background: none; + border: none; + color: rgba(var(--accent-rgb), 0.7); + font-size: 12px; + cursor: pointer; + padding: 4px 6px; + border-radius: 50%; + transition: all 0.2s ease; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; +} +.enhanced-play-btn:hover { + color: rgb(var(--accent-rgb)); + background: rgba(var(--accent-rgb), 0.12); + transform: scale(1.15); +} +.enhanced-play-btn:disabled { + color: rgba(255,255,255,0.15); + cursor: not-allowed; + transform: none; + background: none; +} + +/* Editable cells */ +.enhanced-track-table td.editable { + cursor: text; + position: relative; +} +.enhanced-track-table td.editable:hover { + background: rgba(var(--accent-rgb), 0.04); +} +.enhanced-track-table td.editable:hover::after { + content: '\270E'; + position: absolute; + right: 4px; + top: 50%; + transform: translateY(-50%); + font-size: 10px; + color: rgba(var(--accent-rgb), 0.5); +} + +/* Inline Edit Input */ +.enhanced-inline-input { + width: 100%; + padding: 4px 8px; + background: rgba(var(--accent-rgb), 0.06); + border: 1px solid rgba(var(--accent-rgb), 0.5); + border-radius: 4px; + color: #ffffff; + font-size: 13px; + font-family: inherit; + outline: none; + box-shadow: 0 0 0 2px rgba(var(--accent-rgb), 0.15); + box-sizing: border-box; +} +.enhanced-inline-input.num { + text-align: right; + font-variant-numeric: tabular-nums; +} + +/* Track Checkbox */ +.enhanced-track-checkbox { + width: 16px; + height: 16px; + accent-color: rgb(var(--accent-light-rgb)); + cursor: pointer; +} + +/* Format Badge */ +.enhanced-format-badge { + display: inline-block; + padding: 2px 6px; + border-radius: 4px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.3px; +} +.enhanced-format-badge.flac { + background: rgba(255, 184, 77, 0.15); + color: #ffb84d; +} +.enhanced-format-badge.mp3 { + background: rgba(var(--accent-rgb), 0.15); + color: rgb(var(--accent-light-rgb)); +} +.enhanced-format-badge.other { + background: rgba(255,255,255,0.08); + color: rgba(255,255,255,0.5); +} + +/* Bitrate display */ +.enhanced-bitrate { + font-variant-numeric: tabular-nums; + font-size: 12px; +} +.enhanced-bitrate.high { color: rgb(var(--accent-light-rgb)); } +.enhanced-bitrate.medium { color: #ffb84d; } +.enhanced-bitrate.low { color: #ff6b6b; } + +/* Bulk Actions Bar */ +.enhanced-bulk-bar { + position: fixed; + bottom: -80px; + left: 240px; + right: 0; + height: 60px; + background: linear-gradient(135deg, rgba(30,30,30,0.98), rgba(20,20,20,0.98)); + backdrop-filter: blur(12px); + border-top: 1px solid rgba(var(--accent-rgb), 0.25); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 32px; + z-index: 100; + transition: bottom 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 -4px 20px rgba(0,0,0,0.4); +} +.enhanced-bulk-bar.visible { + bottom: 0; +} +.enhanced-bulk-bar-info { + display: flex; + align-items: center; + gap: 12px; +} +.enhanced-bulk-bar-count { + font-size: 14px; + font-weight: 600; + color: rgb(var(--accent-light-rgb)); +} +.enhanced-bulk-bar-label { + font-size: 13px; + color: rgba(255,255,255,0.6); +} +.enhanced-bulk-bar-actions { + display: flex; + gap: 8px; +} +.enhanced-bulk-btn { + padding: 8px 16px; + border: none; + border-radius: 8px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} +.enhanced-bulk-btn.primary { + background: rgb(var(--accent-rgb)); + color: #ffffff; +} +.enhanced-bulk-btn.primary:hover { + background: rgb(var(--accent-light-rgb)); +} +.enhanced-bulk-btn.secondary { + background: rgba(255,255,255,0.1); + color: rgba(255,255,255,0.8); +} +.enhanced-bulk-btn.secondary:hover { + background: rgba(255,255,255,0.15); +} +.enhanced-bulk-btn.clear { + background: rgba(255, 65, 54, 0.1); + color: #ff4136; +} +.enhanced-bulk-btn.clear:hover { + background: rgba(255, 65, 54, 0.2); +} + +/* Bulk Edit Modal */ +.enhanced-bulk-modal { + background: linear-gradient(135deg, #1a1a1a 0%, #121212 100%); + border-radius: 16px; + border: 1px solid rgba(var(--accent-rgb), 0.2); + width: 500px; + max-width: 90vw; + box-shadow: 0 25px 80px rgba(0,0,0,0.7); + overflow: hidden; +} +.enhanced-bulk-modal-header { + padding: 20px 24px; + border-bottom: 1px solid rgba(var(--accent-rgb), 0.15); + display: flex; + justify-content: space-between; + align-items: center; +} +.enhanced-bulk-modal-header h3 { + font-size: 18px; + font-weight: 700; + color: #ffffff; + margin: 0; +} +.enhanced-bulk-modal-close { + background: rgba(255,255,255,0.1); + color: rgba(255,255,255,0.8); + border: none; + border-radius: 50%; + width: 32px; + height: 32px; + cursor: pointer; + font-size: 16px; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; +} +.enhanced-bulk-modal-close:hover { + background: rgba(255,255,255,0.2); + transform: scale(1.1); +} +.enhanced-bulk-modal-body { + padding: 24px; + display: flex; + flex-direction: column; + gap: 16px; +} +.enhanced-bulk-modal-field { + display: flex; + flex-direction: column; + gap: 6px; +} +.enhanced-bulk-modal-field label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: rgba(255,255,255,0.5); +} +.enhanced-bulk-modal-field input, +.enhanced-bulk-modal-field select { + padding: 10px 14px; + background: rgba(255,255,255,0.06); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 8px; + color: #fff; + font-size: 13px; + font-family: inherit; +} +.enhanced-bulk-modal-field input:focus, +.enhanced-bulk-modal-field select:focus { + outline: none; + border-color: rgba(var(--accent-rgb), 0.5); + box-shadow: 0 0 0 2px rgba(var(--accent-rgb), 0.1); +} +.enhanced-bulk-modal-footer { + padding: 16px 24px; + border-top: 1px solid rgba(255,255,255,0.06); + display: flex; + justify-content: flex-end; + gap: 8px; +} + +/* Empty album state */ +.enhanced-no-tracks { + padding: 20px; + text-align: center; + color: rgba(255,255,255,0.3); + font-size: 13px; + font-style: italic; +} + +/* Scrollbar for track panels */ +.enhanced-tracks-panel-inner::-webkit-scrollbar { width: 6px; } +.enhanced-tracks-panel-inner::-webkit-scrollbar-track { background: rgba(255,255,255,0.03); border-radius: 10px; } +.enhanced-tracks-panel-inner::-webkit-scrollbar-thumb { background: rgba(var(--accent-rgb), 0.3); border-radius: 10px; } +.enhanced-tracks-panel-inner::-webkit-scrollbar-thumb:hover { background: rgba(var(--accent-rgb), 0.5); } + +/* Loading state for enhanced view */ +.enhanced-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 60px 0; + color: rgba(255,255,255,0.5); + font-size: 14px; +} +.enhanced-loading::before { + content: ''; + width: 20px; + height: 20px; + border: 2px solid rgba(var(--accent-rgb), 0.3); + border-top: 2px solid rgb(var(--accent-rgb)); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +/* Enrich Dropdown */ +.enhanced-enrich-wrap { + position: relative; + display: inline-block; +} +.enhanced-enrich-btn { + background: rgba(var(--accent-rgb), 0.08); + border: 1px solid rgba(var(--accent-rgb), 0.2); + color: rgb(var(--accent-light-rgb)); + padding: 8px 16px; + border-radius: 8px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} +.enhanced-enrich-btn:hover { + background: rgba(var(--accent-rgb), 0.15); + border-color: rgba(var(--accent-rgb), 0.35); +} +.enhanced-enrich-btn.small { + padding: 5px 12px; + font-size: 11px; +} +.enhanced-enrich-menu { + display: none; + position: absolute; + top: calc(100% + 4px); + right: 0; + background: rgba(20, 20, 20, 0.98); + border: 1px solid rgba(255,255,255,0.12); + border-radius: 10px; + padding: 6px; + min-width: 180px; + z-index: 100; + backdrop-filter: blur(20px); + box-shadow: 0 8px 32px rgba(0,0,0,0.5); +} +.enhanced-enrich-menu.visible { + display: block; +} +.enhanced-enrich-menu-item { + padding: 8px 14px; + font-size: 13px; + color: rgba(255,255,255,0.8); + cursor: pointer; + border-radius: 6px; + transition: background 0.15s ease; + white-space: nowrap; +} +.enhanced-enrich-menu-item:hover { + background: rgba(var(--accent-rgb), 0.12); + color: #fff; +} + +/* Match Status Chips */ +.enhanced-match-status-row { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 10px 20px; + border-top: 1px solid rgba(255,255,255,0.04); +} +.enhanced-match-status-row.compact { + padding: 8px 0; + border-top: none; +} +.enhanced-match-chip { + display: inline-block; + padding: 3px 10px; + border-radius: 12px; + font-size: 11px; + font-weight: 500; + border: 1px solid rgba(255,255,255,0.08); + background: rgba(255,255,255,0.03); + color: rgba(255,255,255,0.45); +} +.enhanced-match-chip.matched { + color: rgb(var(--accent-rgb)); + border-color: rgba(var(--accent-rgb), 0.25); + background: rgba(var(--accent-rgb), 0.06); +} +.enhanced-match-chip.not-found { + color: rgba(255, 100, 100, 0.7); + border-color: rgba(255, 100, 100, 0.15); + background: rgba(255, 100, 100, 0.04); +} +.enhanced-match-chip.clickable { + cursor: pointer; + transition: all 0.15s ease; +} +.enhanced-match-chip.clickable:hover { + filter: brightness(1.3); + transform: translateY(-1px); + border-color: rgba(var(--accent-rgb), 0.4); +} +.enhanced-match-chip.loading { + opacity: 0.6; + pointer-events: none; + animation: chipPulse 1.2s ease-in-out infinite; +} +@keyframes chipPulse { + 0%, 100% { opacity: 0.4; } + 50% { opacity: 0.8; } +} + +/* Expanded Album Actions */ +.enhanced-expanded-actions { + display: flex; + gap: 8px; + margin-top: 8px; +} + +/* iTunes ID badge */ +.enhanced-id-badge.itunes { + background: rgba(255, 45, 85, 0.12); + color: rgba(255, 45, 85, 0.9); + border-color: rgba(255, 45, 85, 0.2); +} + +/* ===== Manual Match Modal ===== */ +.enhanced-manual-match-modal { + background: linear-gradient(165deg, rgba(28,28,28,0.98), rgba(14,14,14,0.99)); + border: 1px solid rgba(255,255,255,0.08); + border-radius: 16px; + width: 560px; + max-width: 95vw; + max-height: 80vh; + display: flex; + flex-direction: column; + box-shadow: 0 24px 80px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.04); + overflow: hidden; +} +.enhanced-match-search-row { + display: flex; + gap: 8px; + padding: 16px 20px; + border-bottom: 1px solid rgba(255,255,255,0.06); +} +.enhanced-match-search-input { + flex: 1; + background: rgba(255,255,255,0.04); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 8px; + padding: 10px 14px; + color: #fff; + font-size: 14px; + outline: none; + transition: border-color 0.2s; +} +.enhanced-match-search-input:focus { + border-color: rgba(var(--accent-rgb), 0.5); + box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.08); +} +.enhanced-match-results { + flex: 1; + overflow-y: auto; + padding: 12px 20px; + min-height: 200px; + max-height: 50vh; +} +.enhanced-match-results-hint { + text-align: center; + color: rgba(255,255,255,0.35); + font-size: 13px; + padding: 40px 20px; +} +.enhanced-match-result-row { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + border-radius: 10px; + border: 1px solid rgba(255,255,255,0.04); + margin-bottom: 8px; + background: rgba(255,255,255,0.02); + transition: background 0.15s, border-color 0.15s; +} +.enhanced-match-result-row:hover { + background: rgba(var(--accent-rgb), 0.04); + border-color: rgba(var(--accent-rgb), 0.15); +} +.enhanced-match-result-img { + width: 48px; + height: 48px; + border-radius: 6px; + object-fit: cover; + flex-shrink: 0; + background: rgba(255,255,255,0.05); +} +.enhanced-match-result-img-placeholder { + width: 48px; + height: 48px; + border-radius: 6px; + flex-shrink: 0; + background: rgba(255,255,255,0.05); + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + color: rgba(255,255,255,0.2); +} +.enhanced-match-result-info { + flex: 1; + min-width: 0; +} +.enhanced-match-result-name { + font-size: 14px; + font-weight: 500; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.enhanced-match-result-extra { + font-size: 12px; + color: rgba(255,255,255,0.45); + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.enhanced-match-result-id { + font-size: 11px; + color: rgba(255,255,255,0.25); + margin-top: 2px; + font-family: monospace; +} + +/* Track-level match status */ +.enhanced-track-match-cell { + display: flex; + flex-wrap: wrap; + gap: 3px; +} +.enhanced-track-match-chip { + display: inline-block; + padding: 1px 6px; + border-radius: 8px; + font-size: 9px; + font-weight: 500; + border: 1px solid rgba(255,255,255,0.06); + background: rgba(255,255,255,0.02); + color: rgba(255,255,255,0.35); + cursor: pointer; + transition: all 0.15s ease; + white-space: nowrap; +} +.enhanced-track-match-chip.matched { + color: rgb(var(--accent-rgb)); + border-color: rgba(var(--accent-rgb), 0.2); + background: rgba(var(--accent-rgb), 0.05); +} +.enhanced-track-match-chip.not-found { + color: rgba(255, 100, 100, 0.6); + border-color: rgba(255, 100, 100, 0.12); + background: rgba(255, 100, 100, 0.03); +} +.enhanced-track-match-chip:hover { + filter: brightness(1.3); + transform: translateY(-1px); + border-color: rgba(var(--accent-rgb), 0.3); +} +.enhanced-track-table .col-match { + min-width: 120px; + white-space: nowrap; +} +.enhanced-track-table .col-disc { + width: 40px; + text-align: center; + color: rgba(255,255,255,0.4); + font-size: 12px; +} +.enhanced-track-table .col-delete { + width: 32px; + text-align: center; +} +.enhanced-delete-btn { + background: none; + border: 1px solid rgba(255, 80, 80, 0.15); + color: rgba(255, 80, 80, 0.4); + border-radius: 50%; + width: 24px; + height: 24px; + font-size: 12px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s ease; + padding: 0; +} +.enhanced-delete-btn:hover { + background: rgba(255, 60, 60, 0.12); + color: rgba(255, 80, 80, 0.9); + border-color: rgba(255, 80, 80, 0.35); + transform: scale(1.1); +} +.enhanced-delete-album-btn { + padding: 6px 14px; + border-radius: 8px; + font-size: 12px; + font-weight: 500; + cursor: pointer; + background: rgba(255, 60, 60, 0.06); + border: 1px solid rgba(255, 60, 60, 0.15); + color: rgba(255, 80, 80, 0.6); + transition: all 0.15s ease; +} +.enhanced-delete-album-btn:hover { + background: rgba(255, 60, 60, 0.12); + color: rgba(255, 80, 80, 0.9); + border-color: rgba(255, 60, 60, 0.35); +} +/* Sortable column headers */ +.enhanced-track-table th[style*="cursor: pointer"]:hover { + color: rgb(var(--accent-rgb)); } \ No newline at end of file