diff --git a/database/music_database.py b/database/music_database.py index a89970c3..547fb8cb 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -862,6 +862,9 @@ class MusicDatabase: if 'server_source' not in tracks_columns: cursor.execute("ALTER TABLE tracks ADD COLUMN server_source TEXT DEFAULT 'plex'") logger.info("Added server_source column to tracks table") + if 'disc_number' not in tracks_columns: + cursor.execute("ALTER TABLE tracks ADD COLUMN disc_number INTEGER DEFAULT 1") + logger.info("Added disc_number column to tracks table") # Create indexes for server_source columns for performance cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_server_source ON artists (server_source)") diff --git a/web_server.py b/web_server.py index 3b88ca4c..bb0c6ce5 100644 --- a/web_server.py +++ b/web_server.py @@ -10779,6 +10779,463 @@ def library_clear_match(): return jsonify({"success": False, "error": str(e)}), 500 +_IMPORT_ALBUM_IDENTITY_TAGS = { + 'album', + 'albumartist', + 'album_artist', + 'date', + 'year', + 'tracktotal', + 'totaltracks', + 'totaldiscs', + 'musicbrainz_albumid', + 'musicbrainz_albumartistid', + 'musicbrainz_releasegroupid', + 'barcode', + 'catalognumber', + 'originaldate', + 'releasecountry', + 'releasestatus', + 'releasetype', + 'media', + 'script', + 'copyright', + 'spotify_album_id', + 'deezer_album_id', + 'tidal_album_id', + 'qobuz_album_id', + 'itunes_album_id', + 'audiodb_album_id', +} + +_IMPORT_ID3_STANDARD_TAGS = { + 'album': 'TALB', + 'albumartist': 'TPE2', + 'album_artist': 'TPE2', + 'date': 'TDRC', + 'year': 'TDRC', +} + +_IMPORT_ID3_TXXX_DESCS = { + 'musicbrainz_albumid': 'MusicBrainz Album Id', + 'musicbrainz_albumartistid': 'MusicBrainz Album Artist Id', + 'musicbrainz_releasegroupid': 'MusicBrainz Release Group Id', + 'barcode': 'BARCODE', + 'catalognumber': 'CATALOGNUMBER', + 'originaldate': 'ORIGINALDATE', + 'releasecountry': 'RELEASECOUNTRY', + 'releasestatus': 'RELEASESTATUS', + 'releasetype': 'RELEASETYPE', + 'media': 'MEDIA', + 'script': 'SCRIPT', + 'totaldiscs': 'TOTALDISCS', + 'tracktotal': 'TOTALTRACKS', + 'totaltracks': 'TOTALTRACKS', + 'spotify_album_id': 'Spotify Album Id', + 'deezer_album_id': 'Deezer Album Id', + 'tidal_album_id': 'Tidal Album Id', + 'qobuz_album_id': 'Qobuz Album Id', + 'itunes_album_id': 'iTunes Album Id', + 'audiodb_album_id': 'AudioDB Album Id', +} + +_IMPORT_MP4_STANDARD_TAGS = { + 'album': '\xa9alb', + 'albumartist': 'aART', + 'album_artist': 'aART', + 'date': '\xa9day', + 'year': '\xa9day', +} + + +def _first_tag_value(audio, key): + try: + values = audio.get(key) + if not values: + return None + value = values[0] if isinstance(values, (list, tuple)) else values + if isinstance(value, bytes): + value = value.decode('utf-8', errors='ignore') + return str(value).strip() or None + except Exception: + return None + + +def _read_album_identity_tags(file_path): + try: + from mutagen import File as MutagenFile + from mutagen.id3 import ID3, TXXX + from mutagen.mp4 import MP4, MP4FreeForm + + audio = MutagenFile(file_path) + if not audio: + return {} + + tags = {} + if isinstance(getattr(audio, 'tags', None), ID3): + for tag_key, frame_id in _IMPORT_ID3_STANDARD_TAGS.items(): + frames = audio.tags.getall(frame_id) + if frames and getattr(frames[0], 'text', None): + tags[tag_key] = str(frames[0].text[0]).strip() + desc_to_key = {desc: key for key, desc in _IMPORT_ID3_TXXX_DESCS.items()} + for frame in audio.tags.getall('TXXX'): + if isinstance(frame, TXXX) and frame.desc in desc_to_key and frame.text: + tags[desc_to_key[frame.desc]] = str(frame.text[0]).strip() + elif isinstance(audio, MP4): + for tag_key, mp4_key in _IMPORT_MP4_STANDARD_TAGS.items(): + value = _first_tag_value(audio, mp4_key) + if value: + tags[tag_key] = value + for tag_key, desc in _IMPORT_ID3_TXXX_DESCS.items(): + value = _first_tag_value(audio, f"----:com.apple.iTunes:{desc}") + if value: + tags[tag_key] = value + else: + for tag_key in _IMPORT_ALBUM_IDENTITY_TAGS: + value = _first_tag_value(audio, tag_key) + if value: + tags[tag_key] = value + return {k: v for k, v in tags.items() if v} + except Exception as exc: + logger.debug("Failed reading album identity tags from %s: %s", file_path, exc) + return {} + + +def _write_album_identity_tags(file_path, tags): + if not tags: + return False + try: + from mutagen import File as MutagenFile + from mutagen.id3 import ID3, TALB, TDRC, TPE2, TXXX + from mutagen.mp4 import MP4, MP4FreeForm + + audio = MutagenFile(file_path) + if not audio: + return False + + tags = {k: str(v).strip() for k, v in tags.items() if k in _IMPORT_ALBUM_IDENTITY_TAGS and str(v).strip()} + if not tags: + return False + + if isinstance(getattr(audio, 'tags', None), ID3): + standard_frames = {'TALB': TALB, 'TPE2': TPE2, 'TDRC': TDRC} + written_standard = set() + for tag_key, frame_id in _IMPORT_ID3_STANDARD_TAGS.items(): + value = tags.get(tag_key) + if not value or frame_id in written_standard: + continue + audio.tags.delall(frame_id) + audio.tags.add(standard_frames[frame_id](encoding=3, text=[value])) + written_standard.add(frame_id) + for tag_key, desc in _IMPORT_ID3_TXXX_DESCS.items(): + value = tags.get(tag_key) + if not value: + continue + for existing in list(audio.tags.getall('TXXX')): + if getattr(existing, 'desc', None) == desc: + audio.tags.remove(existing.HashKey) + audio.tags.add(TXXX(encoding=3, desc=desc, text=[value])) + elif isinstance(audio, MP4): + for tag_key, mp4_key in _IMPORT_MP4_STANDARD_TAGS.items(): + if tags.get(tag_key): + audio[mp4_key] = [tags[tag_key]] + for tag_key, desc in _IMPORT_ID3_TXXX_DESCS.items(): + if tags.get(tag_key): + audio[f"----:com.apple.iTunes:{desc}"] = [MP4FreeForm(tags[tag_key].encode('utf-8'))] + else: + for tag_key, value in tags.items(): + audio[tag_key] = [value] + + audio.save() + return True + except Exception as exc: + logger.warning("Failed writing album identity tags to %s: %s", file_path, exc) + return False + + +def _copy_album_identity_from_target_sibling(database, album_id, final_path, target_disc, target_track): + try: + with database._get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT file_path FROM tracks + WHERE album_id = ? + AND file_path IS NOT NULL + AND file_path != '' + AND NOT (COALESCE(disc_number, 1) = ? AND track_number = ?) + ORDER BY COALESCE(disc_number, 1), track_number + LIMIT 12 + """, (album_id, target_disc, target_track)) + sibling_rows = cursor.fetchall() + + for row in sibling_rows: + sibling_path = _resolve_library_file_path(row['file_path']) + if not sibling_path or not os.path.exists(sibling_path): + continue + tags = _read_album_identity_tags(sibling_path) + if not tags: + continue + if _write_album_identity_tags(final_path, tags): + logger.info( + "Imported track inherited album identity tags from sibling: %s", + os.path.basename(sibling_path) + ) + return True + except Exception as exc: + logger.warning("Failed to inherit album identity tags for imported track: %s", exc) + return False + + +@app.route('/api/library/album//import-existing-track', methods=['POST']) +@app.route('/api/library/album//missing-track/import-existing', methods=['POST']) +def library_import_existing_track_for_missing_slot(album_id): + """Use an existing library file as source audio for a missing album slot. + + The selected source file is copied to staging, then routed through the + normal post-processing pipeline with the target album/track metadata. The + original source file is never moved or deleted. + """ + try: + import shutil + from core.library_reorganize import _build_post_process_context + + data = request.get_json() or {} + source_track_id = data.get('source_track_id') or data.get('linked_track_id') + expected = data.get('expected_track') or {} + if not source_track_id: + return jsonify({"success": False, "error": "source_track_id is required"}), 400 + if not expected.get('track_number') or not (expected.get('title') or expected.get('name')): + return jsonify({"success": False, "error": "expected_track with title and track_number is required"}), 400 + + database = get_database() + with database._get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT al.*, ar.name AS artist_name, ar.id AS target_artist_id + FROM albums al + JOIN artists ar ON ar.id = al.artist_id + WHERE al.id = ? + """, (album_id,)) + album_row = cursor.fetchone() + if not album_row: + return jsonify({"success": False, "error": "Album not found"}), 404 + album_data = dict(album_row) + + cursor.execute("SELECT * FROM tracks WHERE id = ?", (source_track_id,)) + source_row = cursor.fetchone() + if not source_row: + return jsonify({"success": False, "error": "Selected library track not found"}), 404 + source_track = dict(source_row) + if album_data.get('server_source') and source_track.get('server_source') and album_data['server_source'] != source_track['server_source']: + return jsonify({"success": False, "error": "Selected track belongs to a different library source"}), 400 + + source_path = _resolve_library_file_path(source_track.get('file_path')) + if not source_path: + return jsonify({"success": False, "error": _get_file_not_found_error(source_track.get('file_path'))}), 404 + + download_dir = docker_resolve_path(config_manager.get('soulseek.download_path', './downloads')) + staging_root = os.path.join(download_dir, 'ssync_existing_import') + os.makedirs(staging_root, exist_ok=True) + source_ext = os.path.splitext(source_path)[1] or '.audio' + staging_name = f"existing_{album_id}_{expected.get('disc_number') or 1}_{expected.get('track_number')}_{uuid.uuid4().hex[:8]}{source_ext}" + staging_path = os.path.join(staging_root, staging_name) + shutil.copy2(source_path, staging_path) + + metadata_source = (expected.get('source') or data.get('source') or '').strip().lower() or 'library' + expected_title = expected.get('title') or expected.get('name') or 'Unknown Track' + expected_track_id = ( + expected.get('track_id') or expected.get('id') or expected.get('source_track_id') or + expected.get('spotify_track_id') or expected.get('deezer_id') or + expected.get('itunes_track_id') or expected.get('musicbrainz_recording_id') or '' + ) + album_source_id = ( + data.get('album_source_id') or expected.get('album_id') or + album_data.get('spotify_album_id') or album_data.get('deezer_id') or + album_data.get('itunes_album_id') or album_data.get('musicbrainz_release_id') or + album_data.get('discogs_id') or album_data.get('tidal_id') or album_data.get('qobuz_id') or + str(album_id) + ) + + api_album = { + 'id': album_source_id, + 'name': album_data.get('title') or '', + 'title': album_data.get('title') or '', + 'release_date': f"{album_data.get('year')}-01-01" if album_data.get('year') else '', + 'total_tracks': album_data.get('api_track_count') or album_data.get('track_count') or 0, + 'image_url': album_data.get('thumb_url') or '', + 'source': metadata_source, + } + api_track = { + 'id': expected_track_id, + 'track_id': expected_track_id, + 'name': expected_title, + 'title': expected_title, + 'track_number': int(expected.get('track_number') or 1), + 'disc_number': int(expected.get('disc_number') or 1), + 'duration_ms': int(expected.get('duration') or expected.get('duration_ms') or 0), + 'artists': expected.get('artists') or [album_data.get('artist_name') or ''], + 'source': metadata_source, + 'album_id': album_source_id, + 'spotify_track_id': expected.get('spotify_track_id') or '', + 'deezer_id': expected.get('deezer_id') or '', + 'itunes_track_id': expected.get('itunes_track_id') or '', + 'musicbrainz_recording_id': expected.get('musicbrainz_recording_id') or '', + } + + context = _build_post_process_context( + api_album, + api_track, + album_data.get('artist_name') or '', + album_data.get('title') or '', + int(data.get('total_discs') or expected.get('total_discs') or 1), + ) + context['source'] = metadata_source + context['source_service'] = 'existing_library' + context['source_filename'] = os.path.basename(source_path) + context['source_size'] = os.path.getsize(source_path) if os.path.exists(source_path) else 0 + context['explicit_album_context'] = True + context['from_existing_library_track'] = True + context['batch_id'] = f"existing_import_{album_id}_{uuid.uuid4().hex[:8]}" + context['task_id'] = f"existing_import_{source_track_id}" + + context_key = f"existing_import_{album_id}_{api_track['disc_number']}_{api_track['track_number']}_{uuid.uuid4().hex[:8]}" + _post_process_matched_download(context_key, context, staging_path) + final_path = context.get('_final_processed_path') + if not final_path or not os.path.exists(final_path): + return jsonify({"success": False, "error": "Post-processing did not produce a final file"}), 500 + _copy_album_identity_from_target_sibling( + database, + album_id, + final_path, + api_track['disc_number'], + api_track['track_number'], + ) + + # Insert a real library track row for the target album so the album is + # complete immediately without waiting for a media-server rescan. + file_size = None + bitrate = source_track.get('bitrate') or 0 + try: + file_size = os.path.getsize(final_path) + from mutagen import File as MutagenFile + audio = MutagenFile(final_path) + if audio and getattr(audio, 'info', None) and getattr(audio.info, 'bitrate', None): + bitrate = int(audio.info.bitrate / 1000) + except Exception as meta_err: + logger.debug("Existing-track import metadata read failed: %s", meta_err) + + with database._get_connection() as conn: + cursor = conn.cursor() + cursor.execute("PRAGMA table_info(tracks)") + track_columns = {row[1] for row in cursor.fetchall()} + if 'disc_number' not in track_columns: + cursor.execute("ALTER TABLE tracks ADD COLUMN disc_number INTEGER DEFAULT 1") + conn.commit() + track_columns.add('disc_number') + cursor.execute("SELECT id FROM tracks WHERE file_path = ? LIMIT 1", (final_path,)) + existing_by_path = cursor.fetchone() + cursor.execute(""" + SELECT id FROM tracks + WHERE album_id = ? AND COALESCE(disc_number, 1) = ? AND track_number = ? + LIMIT 1 + """, (album_id, api_track['disc_number'], api_track['track_number'])) + existing_target = cursor.fetchone() + if existing_by_path: + target_track_id = existing_by_path['id'] + cursor.execute(""" + UPDATE tracks + SET album_id = ?, artist_id = ?, title = ?, track_number = ?, disc_number = ?, + duration = ?, file_path = ?, bitrate = ?, file_size = ?, + server_source = COALESCE(server_source, ?), + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, ( + album_id, + album_data.get('target_artist_id'), + expected_title, + api_track['track_number'], + api_track['disc_number'], + api_track['duration_ms'], + final_path, + bitrate, + file_size, + album_data.get('server_source') or source_track.get('server_source') or config_manager.get_active_media_server(), + target_track_id, + )) + elif existing_target: + target_track_id = existing_target['id'] + cursor.execute(""" + UPDATE tracks + SET title = ?, duration = ?, file_path = ?, bitrate = ?, file_size = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, (expected_title, api_track['duration_ms'], final_path, bitrate, file_size, target_track_id)) + else: + cursor.execute("SELECT COALESCE(MAX(CAST(id AS INTEGER)), 0) + 1 AS next_id FROM tracks") + target_track_id = cursor.fetchone()['next_id'] + cursor.execute(""" + INSERT INTO tracks ( + id, album_id, artist_id, title, track_number, disc_number, duration, + file_path, bitrate, file_size, server_source, created_at, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """, ( + target_track_id, + album_id, + album_data.get('target_artist_id'), + expected_title, + api_track['track_number'], + api_track['disc_number'], + api_track['duration_ms'], + final_path, + bitrate, + file_size, + album_data.get('server_source') or source_track.get('server_source') or config_manager.get_active_media_server(), + )) + + track_source_col = _SERVICE_ID_COLUMNS.get(metadata_source, {}).get('track') + if track_source_col and expected_track_id: + try: + cursor.execute(f"UPDATE tracks SET {track_source_col} = ? WHERE id = ?", (expected_track_id, target_track_id)) + except Exception as source_err: + logger.debug("Imported track source-id update failed: %s", source_err) + + conn.commit() + + try: + active_server = config_manager.get_active_media_server() + if active_server in ('jellyfin', 'navidrome'): + _sync_tracks_to_server([{ + 'id': target_track_id, + 'title': expected_title, + 'artist_name': album_data.get('artist_name'), + 'album_title': album_data.get('title'), + 'year': album_data.get('year'), + 'server_source': album_data.get('server_source'), + }], active_server) + except Exception as sync_err: + logger.debug("Existing-track import server sync skipped/failed: %s", sync_err) + + updated = database.get_artist_full_detail(album_data.get('target_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": "Imported existing track into album", + "track_id": target_track_id, + "final_path": final_path, + "updated_data": updated if updated.get('success') else None, + }) + except Exception as e: + logger.error(f"Error importing existing track for album slot: {e}", exc_info=True) + return jsonify({"success": False, "error": str(e)}), 500 + + @app.route('/api/library/track/', methods=['DELETE']) def library_delete_track(track_id): """Delete a track from the database, optionally deleting the file and blacklisting the source.""" diff --git a/webui/static/library.js b/webui/static/library.js index 46491f22..877ce284 100644 --- a/webui/static/library.js +++ b/webui/static/library.js @@ -3358,11 +3358,27 @@ function toggleAlbumExpand(albumId) { inner.appendChild(renderAlbumMetaRow(album)); inner.appendChild(renderTrackTable(album)); inner.dataset.rendered = 'true'; + ensureEnhancedAlbumCanonicalTracks(album).then(updated => { + if (!updated || !artistDetailPageState.expandedAlbums.has(albumId)) return; + rerenderEnhancedAlbumPanel(album.id); + }); } } } } +function rerenderEnhancedAlbumPanel(albumId) { + const panel = document.getElementById(`enhanced-tracks-panel-${albumId}`); + const inner = panel?.querySelector('.enhanced-tracks-panel-inner'); + const album = findEnhancedAlbum(albumId); + if (!inner || !album) return; + inner.innerHTML = ''; + inner.appendChild(renderExpandedAlbumHeader(album)); + inner.appendChild(renderAlbumMetaRow(album)); + inner.appendChild(renderTrackTable(album)); + inner.dataset.rendered = 'true'; +} + function findEnhancedAlbum(albumId) { // Use cached map for O(1) lookups instead of O(n) array scan if (artistDetailPageState._albumMap) { @@ -3408,8 +3424,21 @@ function renderExpandedAlbumHeader(album) { 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' : ''}`); + const ownedTrackCount = album.tracks ? album.tracks.length : 0; + const visibleTrackRows = _getEnhancedAlbumTrackRows(album); + const expectedTrackCount = Math.max( + ownedTrackCount, + visibleTrackRows.length, + Number(album.api_track_count || album.track_count || 0) + ); + const missingCount = visibleTrackRows.filter(t => t._missingExpected).length; + if (album._canonicalTracksLoading) details.push('checking tracklist'); + if (expectedTrackCount > ownedTrackCount) { + details.push(`${ownedTrackCount}/${expectedTrackCount} tracks`); + } else { + details.push(`${ownedTrackCount} track${ownedTrackCount !== 1 ? 's' : ''}`); + } + if (missingCount > 0) details.push(`${missingCount} missing`); let durMs = 0; (album.tracks || []).forEach(t => { durMs += (t.duration || 0); }); if (durMs > 0) details.push(formatDurationMs(durMs)); @@ -3644,20 +3673,167 @@ function renderAlbumMetaRow(album) { return row; } +function _trackSlotKey(track) { + const disc = Number(track.disc_number || track.expected_disc_number || 1); + const num = Number(track.track_number || track.expected_track_number || 0); + return `${disc}:${num}`; +} + +function _normalizeExpectedMissingTrack(source, album) { + const title = source.title || source.name || `Track ${source.track_number || '?'}`; + const sourceTrackId = source.track_id || source.id || source.source_track_id || ''; + const hasActionableContext = !!( + title && + source.track_number && + (sourceTrackId || source.spotify_track_id || source.deezer_id || source.itunes_track_id || source.musicbrainz_recording_id) + ); + return { + id: `missing-${album.id}-${source.disc_number || 1}-${source.track_number || ''}`, + title, + track_number: source.track_number || source.position || '', + disc_number: source.disc_number || 1, + duration: source.duration || source.duration_ms || 0, + spotify_track_id: source.spotify_track_id || (source.source === 'spotify' ? sourceTrackId : ''), + deezer_id: source.deezer_id || (source.source === 'deezer' ? sourceTrackId : ''), + itunes_track_id: source.itunes_track_id || (source.source === 'itunes' ? sourceTrackId : ''), + musicbrainz_recording_id: source.musicbrainz_recording_id || (source.source === 'musicbrainz' ? sourceTrackId : ''), + source: source.source || source.metadata_source || '', + track_id: sourceTrackId, + album_id: source.album_id || source.source_album_id || '', + artists: source.artists || source.artist_names || [], + _hasActionableContext: hasActionableContext, + _missingExpected: true, + _sourceTrack: source, + }; +} + +function _getEnhancedAlbumCanonicalSource(album) { + const priority = [ + ['spotify', 'spotify_album_id'], + ['deezer', 'deezer_id'], + ['itunes', 'itunes_album_id'], + ['musicbrainz', 'musicbrainz_release_id'], + ['discogs', 'discogs_id'], + ['tidal', 'tidal_id'], + ['qobuz', 'qobuz_id'], + ]; + for (const [source, key] of priority) { + if (album[key]) return { source, id: album[key] }; + } + return null; +} + +async function ensureEnhancedAlbumCanonicalTracks(album) { + if (!album || album._canonicalTracksLoaded || album._canonicalTracksLoading) return false; + + const canonicalSource = _getEnhancedAlbumCanonicalSource(album); + if (!canonicalSource) { + album._canonicalTracksLoaded = true; + return false; + } + + album._canonicalTracksLoading = true; + try { + const artistName = artistDetailPageState.enhancedData?.artist?.name || artistDetailPageState.currentArtistName || ''; + const params = new URLSearchParams({ + name: album.title || '', + artist: artistName, + source: canonicalSource.source, + }); + const response = await fetch(`/api/album/${encodeURIComponent(canonicalSource.id)}/tracks?${params}`); + const data = await response.json(); + if (!response.ok || !data.success) { + throw new Error(data.error || 'Failed to load canonical tracklist'); + } + + const canonicalTracks = Array.isArray(data.tracks) ? data.tracks : []; + album.canonical_tracks = canonicalTracks.map((track, index) => ({ + ...track, + title: track.title || track.name || `Track ${track.track_number || index + 1}`, + name: track.name || track.title || `Track ${track.track_number || index + 1}`, + track_number: track.track_number || index + 1, + disc_number: track.disc_number || 1, + duration: track.duration || track.duration_ms || 0, + source: data.source || canonicalSource.source, + track_id: track.id || track.track_id || '', + id: track.id || track.track_id || `${canonicalSource.source}:${canonicalSource.id}:${track.disc_number || 1}:${track.track_number || index + 1}`, + })); + album.api_track_count = Math.max(Number(album.api_track_count || 0), album.canonical_tracks.length); + album.missing_tracks = _deriveEnhancedMissingTracks(album, album.canonical_tracks); + album._canonicalTracksLoaded = true; + return true; + } catch (error) { + album._canonicalTracksError = error.message; + album._canonicalTracksLoaded = true; + console.debug('Failed to load canonical album tracks:', album.title, error); + return false; + } finally { + album._canonicalTracksLoading = false; + } +} + +function _deriveEnhancedMissingTracks(album, canonicalTracks) { + const occupiedSlots = new Set(); + (album.tracks || []).forEach(track => { + const key = _trackSlotKey(track); + if (key !== '1:0') occupiedSlots.add(key); + }); + return (canonicalTracks || []) + .map(track => ({ + ...track, + name: track.name || track.title, + duration_ms: track.duration_ms || track.duration || 0, + })) + .filter(track => { + const key = _trackSlotKey(track); + const normalized = _normalizeExpectedMissingTrack(track, album); + return key !== '1:0' && normalized._hasActionableContext && !occupiedSlots.has(key); + }); +} + +function _getEnhancedAlbumTrackRows(album) { + const ownedTracks = Array.isArray(album.tracks) ? album.tracks : []; + const rowsBySlot = new Map(); + ownedTracks.forEach(track => { + const key = _trackSlotKey(track); + rowsBySlot.set(key === '1:0' ? `owned:${track.id}` : key, track); + }); + + const explicitMissing = Array.isArray(album.missing_tracks) ? album.missing_tracks : []; + explicitMissing.forEach(missing => { + const row = _normalizeExpectedMissingTrack(missing, album); + const key = _trackSlotKey(row); + if (row._hasActionableContext && !rowsBySlot.has(key)) rowsBySlot.set(key, row); + }); + + return Array.from(rowsBySlot.values()).sort((a, b) => { + const discDelta = Number(a.disc_number || 1) - Number(b.disc_number || 1); + if (discDelta !== 0) return discDelta; + const trackDelta = Number(a.track_number || 0) - Number(b.track_number || 0); + if (trackDelta !== 0) return trackDelta; + return String(a.title || '').localeCompare(String(b.title || '')); + }); +} + function _buildTrackRow(track, album, admin) { const tr = document.createElement('tr'); tr.dataset.trackId = track.id; tr.dataset.albumId = album.id; + tr._enhancedTrack = track; + tr._enhancedAlbum = album; + if (track._missingExpected) tr.classList.add('enhanced-missing-track-row'); if (artistDetailPageState.selectedTracks.has(String(track.id))) tr.classList.add('selected'); // Checkbox (admin only) if (admin) { 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)); - cbTd.appendChild(cb); + if (!track._missingExpected) { + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.className = 'enhanced-track-checkbox'; + cb.checked = artistDetailPageState.selectedTracks.has(String(track.id)); + cbTd.appendChild(cb); + } tr.appendChild(cbTd); } @@ -3666,7 +3842,7 @@ function _buildTrackRow(track, album, admin) { playTd.className = 'col-play'; const playBtn = document.createElement('button'); playBtn.className = 'enhanced-play-btn'; - playBtn.innerHTML = '▶'; + playBtn.innerHTML = track._missingExpected ? '—' : '▶'; playBtn.title = track.file_path ? 'Play track' : 'No file available'; if (!track.file_path) playBtn.disabled = true; playTd.appendChild(playBtn); @@ -3688,6 +3864,13 @@ function _buildTrackRow(track, album, admin) { const titleTd = document.createElement('td'); titleTd.className = 'col-title' + (admin ? ' editable' : ''); titleTd.textContent = track.title || 'Unknown'; + if (track._missingExpected) { + titleTd.classList.remove('editable'); + const status = document.createElement('span'); + status.className = 'enhanced-missing-track-badge'; + status.textContent = 'Missing'; + titleTd.appendChild(status); + } tr.appendChild(titleTd); // Duration @@ -3699,12 +3882,16 @@ function _buildTrackRow(track, album, admin) { // 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); + if (track._missingExpected) { + fmtTd.textContent = '-'; + } else { + 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 @@ -3727,7 +3914,9 @@ function _buildTrackRow(track, album, admin) { const pathTd = document.createElement('td'); pathTd.className = 'col-path'; const filePath = track.file_path || '-'; - const fileName = filePath !== '-' ? filePath.split(/[\\/]/).pop() : '-'; + const fileName = track._missingExpected + ? 'Missing from library' + : (filePath !== '-' ? filePath.split(/[\\/]/).pop() : '-'); pathTd.textContent = fileName; pathTd.title = filePath; tr.appendChild(pathTd); @@ -3761,7 +3950,7 @@ function _buildTrackRow(track, album, admin) { // Add to Queue button const queueTd = document.createElement('td'); queueTd.className = 'col-queue'; - if (track.file_path) { + if (!track._missingExpected && track.file_path) { const queueBtn = document.createElement('button'); queueBtn.className = 'enhanced-queue-btn'; queueBtn.innerHTML = '+'; @@ -3774,7 +3963,7 @@ function _buildTrackRow(track, album, admin) { // Write Tags button (admin only) const tagTd = document.createElement('td'); tagTd.className = 'col-writetag'; - if (track.file_path) { + if (track.file_path && !track._missingExpected) { const tagBtn = document.createElement('button'); tagBtn.className = 'enhanced-write-tag-btn'; tagBtn.innerHTML = '✎'; @@ -3789,26 +3978,42 @@ function _buildTrackRow(track, album, admin) { } tr.appendChild(tagTd); - // Track actions cell — source info, redownload, delete (admin only) + // Track actions cell: source info, redownload, delete, or missing-track actions. const actionsTd = document.createElement('td'); actionsTd.className = 'col-track-actions'; - actionsTd.innerHTML = ` -
- - - -
- `; + if (track._missingExpected) { + actionsTd.innerHTML = ` +
+ +
+ `; + } else { + actionsTd.innerHTML = ` +
+ + + +
+ `; + } tr.appendChild(actionsTd); } else { // Report Issue button per track (non-admin) const reportTd = document.createElement('td'); reportTd.className = 'col-report'; - const reportBtn = document.createElement('button'); - reportBtn.className = 'enhanced-track-report-btn'; - reportBtn.innerHTML = '⚑'; - reportBtn.title = 'Report issue with this track'; - reportTd.appendChild(reportBtn); + if (track._missingExpected) { + const manageBtn = document.createElement('button'); + manageBtn.className = 'enhanced-missing-manage-btn'; + manageBtn.textContent = 'Manage'; + manageBtn.dataset.action = 'manage-missing'; + reportTd.appendChild(manageBtn); + } else { + const reportBtn = document.createElement('button'); + reportBtn.className = 'enhanced-track-report-btn'; + reportBtn.innerHTML = '⚑'; + reportBtn.title = 'Report issue with this track'; + reportTd.appendChild(reportBtn); + } tr.appendChild(reportTd); } @@ -3826,6 +4031,14 @@ function _buildTrackRow(track, album, admin) { } function _getTrackDataFromRow(tr) { + if (tr._enhancedTrack && tr._enhancedAlbum) { + return { + track: tr._enhancedTrack, + album: tr._enhancedAlbum, + trackId: tr._enhancedTrack.id, + albumId: tr._enhancedAlbum.id + }; + } const trackId = tr.dataset.trackId; const albumId = tr.dataset.albumId; const album = findEnhancedAlbum(albumId); @@ -3872,6 +4085,13 @@ function _attachTableDelegation(table, album) { if (!info) return; const { track, trackId } = info; + const manageAction = target.closest('.enhanced-missing-manage-btn'); + if (manageAction && track._missingExpected) { + e.stopPropagation(); + openMissingTrackManageModal(track, album); + return; + } + // Checkbox if (target.classList.contains('enhanced-track-checkbox')) { toggleTrackSelection(String(trackId)); @@ -4065,7 +4285,7 @@ function _rebuildTbody(table, album) { const admin = isEnhancedAdmin(); const oldTbody = table.querySelector('tbody'); const newTbody = document.createElement('tbody'); - (album.tracks || []).forEach(track => { + _getEnhancedAlbumTrackRows(album).forEach(track => { newTbody.appendChild(_buildTrackRow(track, album, admin)); }); if (oldTbody) table.replaceChild(newTbody, oldTbody); @@ -4074,13 +4294,13 @@ function _rebuildTbody(table, album) { 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); } + const tracks = _getEnhancedAlbumTrackRows(album); if (tracks.length === 0) { wrapper.innerHTML = '
No tracks in database
'; @@ -5785,6 +6005,397 @@ async function applyManualMatch(entityType, entityId, service, serviceId, artist } } +async function wishlistEnhancedMissingTrack(track, album, downloadNow = false) { + if (!track._hasActionableContext) { + showToast('This missing track needs metadata context before it can be wishlisted or downloaded.', 'error'); + return; + } + const artistName = artistDetailPageState.enhancedData?.artist?.name || artistDetailPageState.currentArtistName || ''; + const artist = { + id: artistDetailPageState.enhancedData?.artist?.id || artistDetailPageState.currentArtistId || '', + name: artistName, + image_url: artistDetailPageState.enhancedData?.artist?.thumb_url || getArtistImageFromPage() || '', + }; + const albumData = { + id: album.id, + name: album.title || 'Unknown Album', + title: album.title || 'Unknown Album', + image_url: album.thumb_url || '', + release_date: album.year ? `${album.year}-01-01` : '', + album_type: album.record_type || 'album', + total_tracks: Number(album.api_track_count || album.track_count || album.tracks?.length || 1), + }; + const wishlistTrack = { + id: track.spotify_track_id || track.deezer_id || track.itunes_track_id || track.musicbrainz_recording_id || track.id, + name: track.title || `Track ${track.track_number || ''}`, + title: track.title || `Track ${track.track_number || ''}`, + artists: [{ name: artistName }], + duration_ms: track.duration || 0, + track_number: track.track_number || 1, + disc_number: track.disc_number || 1, + album: albumData, + }; + + if (typeof openAddToWishlistModal !== 'function') { + showToast('Wishlist modal is not available on this page', 'error'); + return; + } + + await openAddToWishlistModal(albumData, artist, [wishlistTrack], albumData.album_type, { [wishlistTrack.name]: false }); + if (downloadNow && typeof handleWishlistDownloadNow === 'function') { + setTimeout(() => handleWishlistDownloadNow(), 150); + } +} + +function openMissingTrackManageModal(track, album) { + if (track._missingExpected && !track._hasActionableContext) { + showToast('This missing track needs metadata context before it can be managed.', 'error'); + return; + } + + const existing = document.getElementById('enhanced-missing-manage-overlay'); + if (existing) existing.remove(); + + const artistName = artistDetailPageState.enhancedData?.artist?.name || artistDetailPageState.currentArtistName || ''; + const overlay = document.createElement('div'); + overlay.id = 'enhanced-missing-manage-overlay'; + overlay.className = 'modal-overlay'; + let isImporting = false; + overlay.onclick = (e) => { if (e.target === overlay && !isImporting) overlay.remove(); }; + + const modal = document.createElement('div'); + modal.className = 'confirm-modal enhanced-missing-manage-modal'; + modal.innerHTML = ` +
+

Manage Missing Track

+ +
+
+
+
Missing album slot
+
#${escapeHtml(String(track.track_number || '?'))} ${escapeHtml(track.title || 'Unknown Track')}
+
${escapeHtml(artistName)} · ${escapeHtml(album.title || '')}
+
+
+ + +
+
+
+ +
+ `; + + overlay.appendChild(modal); + document.body.appendChild(overlay); + + const close = () => overlay.remove(); + modal.querySelector('.confirm-modal-close').onclick = close; + modal.querySelector('[data-action="cancel"]').onclick = close; + modal.querySelectorAll('.enhanced-missing-option').forEach(button => { + button.onclick = async () => { + const action = button.dataset.action; + close(); + if (action === 'library') { + await wishlistEnhancedMissingTrack(track, album, false); + } else if (action === 'have') { + openHaveMissingTrackModal(track, album); + } + }; + }); +} + +function openHaveMissingTrackModal(track, album) { + if (track._missingExpected && !track._hasActionableContext) { + showToast('This missing track needs metadata context before it can be imported.', 'error'); + return; + } + const existing = document.getElementById('enhanced-have-track-overlay'); + if (existing) existing.remove(); + + const artistName = artistDetailPageState.enhancedData?.artist?.name || artistDetailPageState.currentArtistName || ''; + const overlay = document.createElement('div'); + overlay.id = 'enhanced-have-track-overlay'; + overlay.className = 'modal-overlay'; + let isImporting = false; + overlay.onclick = (e) => { if (e.target === overlay && !isImporting) overlay.remove(); }; + + const modal = document.createElement('div'); + modal.className = 'enhanced-manual-match-modal enhanced-have-track-modal'; + modal.innerHTML = ` +
+
+

I Have This Track

+
Use an existing file as the source audio. SoulSync will copy it into this album.
+
+ +
+
+
Missing album slot
+
#${escapeHtml(String(track.track_number || '?'))} ${escapeHtml(track.title || 'Unknown Track')}
+
${escapeHtml(artistName)} · ${escapeHtml(album.title || '')}
+
+
+ + +
+
+
Searching your library...
+
+ +
The selected file stays in its current album/folder. SoulSync copies it, writes the missing track's tags, and places the copy in this album.
+ + + `; + overlay.appendChild(modal); + document.body.appendChild(overlay); + + let selectedTrackId = null; + let selectedTrackSummary = ''; + let importTimer = null; + const searchResultsById = new Map(); + const searchInput = modal.querySelector('#enhanced-have-track-search'); + const resultsEl = modal.querySelector('#enhanced-have-track-results'); + const selectedEl = modal.querySelector('#enhanced-have-selected'); + const selectedText = selectedEl.querySelector('strong'); + const confirmBtn = modal.querySelector('#enhanced-have-confirm'); + const cancelBtn = modal.querySelector('#enhanced-have-cancel'); + const closeBtn = modal.querySelector('.enhanced-bulk-modal-close'); + const searchBtn = modal.querySelector('#enhanced-have-track-search-btn'); + const statusEl = modal.querySelector('#enhanced-have-import-status'); + const statusTitle = statusEl.querySelector('.enhanced-have-import-title'); + const statusDetail = statusEl.querySelector('.enhanced-have-import-detail'); + const statusTime = statusEl.querySelector('.enhanced-have-import-time'); + + const close = () => { + if (isImporting) return; + if (importTimer) clearInterval(importTimer); + overlay.remove(); + }; + closeBtn.onclick = close; + cancelBtn.onclick = close; + searchBtn.onclick = () => runSearch(); + searchInput.onkeydown = (e) => { if (e.key === 'Enter' && !isImporting) runSearch(); }; + + function selectResultRow(row) { + if (isImporting || !row) return; + const trackId = row.dataset.trackId; + const result = searchResultsById.get(String(trackId)); + if (!trackId || !result) return; + resultsEl.querySelectorAll('.enhanced-have-result-row').forEach(r => { + r.classList.remove('selected'); + r.setAttribute('aria-pressed', 'false'); + }); + row.classList.add('selected'); + row.setAttribute('aria-pressed', 'true'); + selectedTrackId = trackId; + selectedTrackSummary = `${result.title || 'Unknown'}${result.album_title ? ` from ${result.album_title}` : ''}`; + selectedText.textContent = selectedTrackSummary; + selectedEl.hidden = false; + confirmBtn.disabled = false; + } + + resultsEl.addEventListener('click', (event) => { + const row = event.target.closest('.enhanced-have-result-row'); + if (!row || !resultsEl.contains(row)) return; + event.preventDefault(); + selectResultRow(row); + }); + + resultsEl.addEventListener('keydown', (event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + const row = event.target.closest('.enhanced-have-result-row'); + if (!row) return; + event.preventDefault(); + selectResultRow(row); + }); + + function setImportStatus(title, detail, tone = 'working') { + statusEl.hidden = false; + statusEl.classList.toggle('error', tone === 'error'); + statusEl.classList.toggle('success', tone === 'success'); + statusTitle.textContent = title; + statusDetail.textContent = detail; + } + + function startImportTimer() { + const start = Date.now(); + const stages = [ + { after: 0, text: 'Copying selected file into staging.' }, + { after: 4, text: 'Verifying audio and writing the missing track tags.' }, + { after: 10, text: 'Post-processing can take a moment for FLAC files, lyrics, ReplayGain, and metadata.' }, + { after: 20, text: 'Still working. Waiting for the backend to finish and return the refreshed library row.' }, + ]; + if (importTimer) clearInterval(importTimer); + importTimer = setInterval(() => { + const elapsed = Math.floor((Date.now() - start) / 1000); + statusTime.textContent = `${elapsed}s`; + const stage = [...stages].reverse().find(item => elapsed >= item.after); + if (stage) statusDetail.textContent = stage.text; + }, 250); + } + + async function runSearch() { + const query = searchInput.value.trim(); + if (!query) { + resultsEl.innerHTML = '
Enter a title or artist to search.
'; + return; + } + selectedTrackId = null; + selectedTrackSummary = ''; + selectedEl.hidden = true; + confirmBtn.disabled = true; + resultsEl.innerHTML = '
Searching...
'; + searchResultsById.clear(); + try { + const res = await fetch(`/api/library/search-tracks?q=${encodeURIComponent(query)}&limit=12`); + const data = await res.json(); + if (!data.success) throw new Error(data.error || 'Search failed'); + const tracks = data.tracks || []; + if (tracks.length === 0) { + resultsEl.innerHTML = '
No library tracks found. Try a different search.
'; + return; + } + resultsEl.innerHTML = ''; + tracks.forEach(result => { + if (!result.id) return; + searchResultsById.set(String(result.id), result); + const row = document.createElement('div'); + row.className = 'enhanced-have-result-row'; + row.dataset.trackId = String(result.id); + row.setAttribute('role', 'button'); + row.setAttribute('tabindex', '0'); + row.setAttribute('aria-pressed', 'false'); + const fileName = result.file_path ? result.file_path.split(/[\\/]/).pop() : 'No file path'; + row.innerHTML = ` + + + ${escapeHtml(result.title || 'Unknown')} + ${escapeHtml(result.artist_name || '')}${result.album_title ? ` · ${escapeHtml(result.album_title)}` : ''} + ${escapeHtml(fileName)} + + + ${result.duration ? `${formatDurationMs(result.duration)}` : ''} + ${result.bitrate ? `${result.bitrate} kbps` : ''} + + `; + resultsEl.appendChild(row); + }); + } catch (error) { + resultsEl.innerHTML = `
Error: ${escapeHtml(error.message)}
`; + } + } + + confirmBtn.onclick = async () => { + if (!selectedTrackId) return; + isImporting = true; + confirmBtn.disabled = true; + confirmBtn.textContent = 'Importing...'; + cancelBtn.disabled = true; + closeBtn.disabled = true; + searchBtn.disabled = true; + searchInput.disabled = true; + resultsEl.querySelectorAll('.enhanced-have-result-row').forEach(row => { + row.setAttribute('aria-disabled', 'true'); + row.classList.add('disabled'); + }); + setImportStatus( + 'Importing selected file', + selectedTrackSummary ? `Using ${selectedTrackSummary}.` : 'Using the selected library track.' + ); + startImportTimer(); + try { + const sourceTrack = track._sourceTrack || track; + const expectedTrack = { + title: track.title || sourceTrack.title || sourceTrack.name || '', + name: track.title || sourceTrack.title || sourceTrack.name || '', + track_number: track.track_number || sourceTrack.track_number, + disc_number: track.disc_number || sourceTrack.disc_number || 1, + duration: track.duration || sourceTrack.duration || sourceTrack.duration_ms || 0, + duration_ms: track.duration || sourceTrack.duration_ms || sourceTrack.duration || 0, + source: track.source || sourceTrack.source || '', + track_id: track.track_id || sourceTrack.track_id || sourceTrack.id || '', + id: track.track_id || sourceTrack.track_id || sourceTrack.id || '', + album_id: track.album_id || sourceTrack.album_id || '', + spotify_track_id: track.spotify_track_id || sourceTrack.spotify_track_id || '', + deezer_id: track.deezer_id || sourceTrack.deezer_id || '', + itunes_track_id: track.itunes_track_id || sourceTrack.itunes_track_id || '', + musicbrainz_recording_id: track.musicbrainz_recording_id || sourceTrack.musicbrainz_recording_id || '', + artists: track.artists || sourceTrack.artists || [artistName], + }; + const discs = (album.canonical_tracks || album.tracks || []).map(t => Number(t.disc_number || 1)); + const res = await fetch(`/api/library/album/${album.id}/import-existing-track`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + source_track_id: selectedTrackId, + expected_track: expectedTrack, + album_source_id: album.spotify_album_id || album.deezer_id || album.itunes_album_id || album.musicbrainz_release_id || album.discogs_id || album.tidal_id || album.qobuz_id || '', + total_discs: Math.max(1, ...discs), + }), + }); + setImportStatus('Finalizing import', 'Backend finished. Refreshing the enhanced library view...'); + const data = await res.json(); + if (!data.success) throw new Error(data.error || 'Failed to import track'); + if (importTimer) clearInterval(importTimer); + statusTime.textContent = statusTime.textContent || 'done'; + setImportStatus('Import complete', 'The copied file is now being shown in this album.', 'success'); + showToast('Track imported. Original file was left untouched.', 'success'); + if (data.updated_data && data.updated_data.success) { + artistDetailPageState.enhancedData = data.updated_data; + _rebuildAlbumMap(); + renderEnhancedView(); + } else if (artistDetailPageState.currentArtistId) { + await loadEnhancedViewData(artistDetailPageState.currentArtistId); + } + setTimeout(() => overlay.remove(), 650); + } catch (error) { + if (importTimer) clearInterval(importTimer); + isImporting = false; + confirmBtn.disabled = false; + confirmBtn.textContent = 'Import Track'; + cancelBtn.disabled = false; + closeBtn.disabled = false; + searchBtn.disabled = false; + searchInput.disabled = false; + resultsEl.querySelectorAll('.enhanced-have-result-row').forEach(row => { + row.setAttribute('aria-disabled', 'false'); + row.classList.remove('disabled'); + }); + setImportStatus('Import failed', error.message, 'error'); + showToast(`Import failed: ${error.message}`, 'error'); + } + }; + + searchInput.focus(); + runSearch(); +} + // ---- Enrichment ---- let _enrichmentInFlight = false; diff --git a/webui/static/style.css b/webui/static/style.css index feeae228..627f7924 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -45526,6 +45526,74 @@ textarea.enhanced-meta-field-input { .enhanced-track-table tr.selected { background: rgba(var(--accent-rgb), 0.06); } +.enhanced-track-table tr.enhanced-missing-track-row { + background: rgba(255, 184, 77, 0.025); +} +.enhanced-track-table tr.enhanced-missing-track-row:hover { + background: rgba(255, 184, 77, 0.055); +} +.enhanced-missing-track-badge { + display: inline-flex; + align-items: center; + margin-left: 8px; + padding: 2px 6px; + border-radius: 7px; + font-size: 9px; + font-weight: 700; + line-height: 1; + text-transform: uppercase; + letter-spacing: 0.4px; + vertical-align: middle; +} +.enhanced-missing-track-badge { + color: #ffb84d; + background: rgba(255, 184, 77, 0.11); + border: 1px solid rgba(255, 184, 77, 0.22); +} +.enhanced-missing-action-btn, +.enhanced-missing-manage-btn { + min-height: 24px; + padding: 4px 8px; + border-radius: 7px; + border: 1px solid rgba(255,255,255,0.09); + font-size: 10px; + font-weight: 700; + line-height: 1; + cursor: pointer; + transition: all 0.18s ease; + white-space: nowrap; +} +.enhanced-missing-manage-btn { + min-width: 68px; + color: rgb(var(--accent-light-rgb)); + background: rgba(var(--accent-rgb), 0.1); + border-color: rgba(var(--accent-rgb), 0.26); +} +.enhanced-missing-manage-btn:hover { + color: #fff; + background: rgba(var(--accent-rgb), 0.2); + border-color: rgba(var(--accent-rgb), 0.42); + transform: translateY(-1px); +} +.enhanced-missing-action-btn.primary { + color: #fff; + background: rgba(var(--accent-rgb), 0.9); + border-color: rgba(var(--accent-light-rgb), 0.55); + box-shadow: 0 0 12px rgba(var(--accent-rgb), 0.16); +} +.enhanced-missing-action-btn.primary:hover { + background: rgb(var(--accent-light-rgb)); + transform: translateY(-1px); +} +.enhanced-missing-action-btn.secondary { + color: rgba(255,255,255,0.68); + background: rgba(255,255,255,0.055); +} +.enhanced-missing-action-btn.secondary:hover { + color: rgb(var(--accent-light-rgb)); + background: rgba(var(--accent-rgb), 0.11); + border-color: rgba(var(--accent-rgb), 0.25); +} /* Play button */ .enhanced-play-btn { @@ -46679,6 +46747,268 @@ textarea.enhanced-meta-field-input { margin-top: 2px; font-family: monospace; } +.enhanced-have-track-modal { + width: 640px; +} +.enhanced-have-subtitle { + margin-top: 4px; + font-size: 12px; + color: rgba(255,255,255,0.45); + font-weight: 400; +} +.enhanced-have-target { + margin: 16px 20px 0; + padding: 14px 16px; + border: 1px solid rgba(var(--accent-rgb), 0.18); + border-radius: 10px; + background: rgba(var(--accent-rgb), 0.055); +} +.enhanced-have-target-label { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.7px; + text-transform: uppercase; + color: rgb(var(--accent-light-rgb)); + margin-bottom: 5px; +} +.enhanced-have-target-title { + color: #fff; + font-size: 15px; + font-weight: 700; +} +.enhanced-have-target-meta { + margin-top: 3px; + color: rgba(255,255,255,0.48); + font-size: 12px; +} +.enhanced-have-note { + margin: 0 20px 16px; + padding: 10px 12px; + border-radius: 8px; + color: rgba(255,255,255,0.52); + background: rgba(255,255,255,0.035); + border: 1px solid rgba(255,255,255,0.06); + font-size: 12px; + line-height: 1.45; +} +.enhanced-have-import-status { + margin: 0 20px 16px; + padding: 12px 14px; + border-radius: 8px; + border: 1px solid rgba(var(--accent-rgb), 0.22); + background: rgba(var(--accent-rgb), 0.07); +} +.enhanced-have-import-status[hidden] { + display: none; +} +.enhanced-have-import-status-top { + display: flex; + align-items: center; + gap: 9px; + min-width: 0; +} +.enhanced-have-import-spinner { + width: 14px; + height: 14px; + border-radius: 50%; + border: 2px solid rgba(var(--accent-rgb), 0.24); + border-top-color: rgb(var(--accent-light-rgb)); + flex-shrink: 0; + animation: enhanced-have-spin 0.8s linear infinite; +} +.enhanced-have-import-title { + color: rgba(255,255,255,0.92); + font-size: 13px; + font-weight: 700; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.enhanced-have-import-time { + color: rgba(255,255,255,0.44); + font-size: 11px; + font-variant-numeric: tabular-nums; +} +.enhanced-have-import-detail { + margin-top: 6px; + padding-left: 23px; + color: rgba(255,255,255,0.54); + font-size: 12px; + line-height: 1.4; +} +.enhanced-have-import-status.success { + border-color: rgba(76, 217, 100, 0.3); + background: rgba(76, 217, 100, 0.08); +} +.enhanced-have-import-status.success .enhanced-have-import-spinner { + animation: none; + border-color: rgba(76, 217, 100, 0.35); + background: rgba(76, 217, 100, 0.8); +} +.enhanced-have-import-status.error { + border-color: rgba(255, 107, 107, 0.32); + background: rgba(255, 107, 107, 0.08); +} +.enhanced-have-import-status.error .enhanced-have-import-spinner { + animation: none; + border-color: rgba(255, 107, 107, 0.38); + background: rgba(255, 107, 107, 0.85); +} +@keyframes enhanced-have-spin { + to { transform: rotate(360deg); } +} +.enhanced-have-result-row { + width: 100%; + display: flex; + align-items: center; + gap: 12px; + padding: 11px 12px; + margin-bottom: 8px; + border-radius: 10px; + border: 1px solid rgba(255,255,255,0.05); + background: rgba(255,255,255,0.025); + color: inherit; + text-align: left; + cursor: pointer; + transition: all 0.16s ease; +} +.enhanced-have-result-row:hover, +.enhanced-have-result-row.selected { + background: rgba(var(--accent-rgb), 0.07); + border-color: rgba(var(--accent-rgb), 0.28); +} +.enhanced-have-result-row.disabled { + opacity: 0.55; + cursor: wait; + pointer-events: none; +} +.enhanced-have-radio { + width: 15px; + height: 15px; + border-radius: 50%; + border: 1px solid rgba(255,255,255,0.28); + flex-shrink: 0; +} +.enhanced-have-result-row.selected .enhanced-have-radio { + border-color: rgb(var(--accent-light-rgb)); + box-shadow: inset 0 0 0 4px #151515; + background: rgb(var(--accent-light-rgb)); +} +.enhanced-have-result-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} +.enhanced-have-result-title { + color: rgba(255,255,255,0.92); + font-size: 13px; + font-weight: 700; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.enhanced-have-result-meta, +.enhanced-have-result-file { + color: rgba(255,255,255,0.42); + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.enhanced-have-result-file { + color: rgba(255,255,255,0.28); + font-family: 'SF Mono', 'Consolas', monospace; +} +.enhanced-have-result-side { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 3px; + color: rgba(255,255,255,0.45); + font-size: 11px; + font-variant-numeric: tabular-nums; +} +.enhanced-missing-manage-modal { + width: 520px; +} +.enhanced-missing-manage-modal .confirm-modal-header h2 { + color: #fff; + font-size: 18px; + font-weight: 700; + margin: 0; +} +.enhanced-missing-manage-body { + padding: 20px 24px; +} +.enhanced-missing-manage-target { + padding: 14px 16px; + border: 1px solid rgba(var(--accent-rgb), 0.18); + border-radius: 10px; + background: rgba(var(--accent-rgb), 0.055); +} +.enhanced-missing-manage-options { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 16px; +} +.enhanced-missing-option { + width: 100%; + display: flex; + align-items: center; + gap: 12px; + padding: 13px 14px; + border-radius: 10px; + border: 1px solid rgba(255,255,255,0.07); + background: rgba(255,255,255,0.035); + color: inherit; + text-align: left; + cursor: pointer; + transition: all 0.16s ease; +} +.enhanced-missing-option:hover, +.enhanced-missing-option.primary:hover { + background: rgba(var(--accent-rgb), 0.1); + border-color: rgba(var(--accent-rgb), 0.28); + transform: translateY(-1px); +} +.enhanced-missing-option.primary { + background: rgba(var(--accent-rgb), 0.065); + border-color: rgba(var(--accent-rgb), 0.2); +} +.enhanced-missing-option-icon { + width: 28px; + height: 28px; + border-radius: 8px; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(var(--accent-rgb), 0.12); + color: rgb(var(--accent-light-rgb)); + border: 1px solid rgba(var(--accent-rgb), 0.2); + font-size: 14px; + font-weight: 800; +} +.enhanced-missing-option-title, +.enhanced-missing-option-desc { + display: block; +} +.enhanced-missing-option-title { + color: rgba(255,255,255,0.9); + font-size: 13px; + font-weight: 700; +} +.enhanced-missing-option-desc { + margin-top: 2px; + color: rgba(255,255,255,0.46); + font-size: 11px; + line-height: 1.35; +} /* Track-level match status */ .enhanced-track-match-cell { @@ -56853,6 +57183,7 @@ tr.tag-diff-same { transition: opacity 0.15s; } tr:hover .enhanced-track-actions-group { opacity: 1; } +.enhanced-track-actions-group.visible { opacity: 1; } .enhanced-source-info-btn, .enhanced-redownload-btn {