From 3b62bcab0cdbde81056afed957840ef71a395a0f Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 17 May 2026 13:58:11 -0700 Subject: [PATCH 1/3] Add missing-track import from existing library files Show actionable missing album tracks in the enhanced library from canonical metadata, with a practical Manage flow for Add to Library or I Have This. Implement I Have This as a non-destructive copy/import path: copy the chosen existing file, run normal post-processing with the missing track context, insert the real library row, and inherit album identity tags from target siblings so Navidrome does not split albums. Improve the modal with selectable search results, visible import progress, disabled controls during import, and missing-track row styling. --- database/music_database.py | 3 + web_server.py | 457 +++++++++++++++++++++++++ webui/static/library.js | 675 +++++++++++++++++++++++++++++++++++-- webui/static/style.css | 331 ++++++++++++++++++ 4 files changed, 1434 insertions(+), 32 deletions(-) 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 { From f9ae0e8d586a2f3765f5e27f9a55eda3bbd98a76 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 17 May 2026 14:04:28 -0700 Subject: [PATCH 2/3] Extract missing-track import service Move the existing-file missing-track import workflow out of web_server.py and into core/library/missing_track_import.py. Keep the Flask route focused on request wiring and response formatting while the service handles staging copy, post-processing, album identity tag inheritance, DB upsert, and media-server sync. --- core/library/missing_track_import.py | 598 +++++++++++++++++++++++++++ web_server.py | 444 +------------------- 2 files changed, 617 insertions(+), 425 deletions(-) create mode 100644 core/library/missing_track_import.py diff --git a/core/library/missing_track_import.py b/core/library/missing_track_import.py new file mode 100644 index 00000000..65bf6357 --- /dev/null +++ b/core/library/missing_track_import.py @@ -0,0 +1,598 @@ +"""Import an existing library file into a missing album slot. + +This module keeps the "I Have This" behavior out of the Flask route layer: +copy the selected source file, post-process it with target album metadata, +inherit album identity tags from target siblings, and write the real DB row. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging +import os +import shutil +import uuid +from typing import Any, Callable, Dict, Optional + +from core.library_reorganize import _build_post_process_context + + +logger = logging.getLogger("soulsync.library.missing_track_import") + + +class MissingTrackImportError(Exception): + """Expected import failure that should be surfaced to the API caller.""" + + def __init__(self, message: str, status_code: int = 400): + super().__init__(message) + self.status_code = status_code + + +@dataclass +class MissingTrackImportDeps: + database: Any + config_manager: Any + post_process_fn: Callable[[str, dict, str], Any] + resolve_library_file_path_fn: Callable[[Optional[str]], Optional[str]] + docker_resolve_path_fn: Callable[[str], str] + sync_tracks_to_server_fn: Optional[Callable[[list, str], Any]] = None + service_id_columns: Optional[Dict[str, Dict[str, str]]] = None + + +_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", +} + +_ID3_STANDARD_TAGS = { + "album": "TALB", + "albumartist": "TPE2", + "album_artist": "TPE2", + "date": "TDRC", + "year": "TDRC", +} + +_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", +} + +_MP4_STANDARD_TAGS = { + "album": "\xa9alb", + "albumartist": "aART", + "album_artist": "aART", + "date": "\xa9day", + "year": "\xa9day", +} + + +def import_existing_track_for_album_slot(album_id: str, payload: dict, deps: MissingTrackImportDeps) -> dict: + source_track_id = payload.get("source_track_id") or payload.get("linked_track_id") + expected = payload.get("expected_track") or {} + if not source_track_id: + raise MissingTrackImportError("source_track_id is required", 400) + if not expected.get("track_number") or not (expected.get("title") or expected.get("name")): + raise MissingTrackImportError("expected_track with title and track_number is required", 400) + + database = deps.database + album_data, source_track = _load_album_and_source_track(database, album_id, source_track_id) + if album_data.get("server_source") and source_track.get("server_source") and album_data["server_source"] != source_track["server_source"]: + raise MissingTrackImportError("Selected track belongs to a different library source", 400) + + source_path = deps.resolve_library_file_path_fn(source_track.get("file_path")) + if not source_path: + raise MissingTrackImportError(_file_not_found_message(source_track.get("file_path")), 404) + + staging_path = _copy_source_to_staging(source_path, album_id, expected, deps) + metadata_source = (expected.get("source") or payload.get("source") or "").strip().lower() or "library" + expected_title = expected.get("title") or expected.get("name") or "Unknown Track" + expected_track_id = _expected_track_id(expected) + album_source_id = _album_source_id(payload, expected, album_data, album_id) + + api_track = _build_api_track(expected, expected_title, expected_track_id, album_source_id, metadata_source, album_data) + context = _build_context(payload, album_data, source_path, source_track_id, api_track, album_source_id, metadata_source) + + context_key = f"existing_import_{album_id}_{api_track['disc_number']}_{api_track['track_number']}_{uuid.uuid4().hex[:8]}" + deps.post_process_fn(context_key, context, staging_path) + final_path = context.get("_final_processed_path") + if not final_path or not os.path.exists(final_path): + raise MissingTrackImportError("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"], + deps.resolve_library_file_path_fn, + ) + + target_track_id = _upsert_target_track( + database, + deps, + album_id, + album_data, + source_track, + final_path, + expected_title, + expected_track_id, + metadata_source, + api_track, + ) + _sync_imported_track(deps, target_track_id, expected_title, album_data) + + return { + "track_id": target_track_id, + "final_path": final_path, + "artist_id": album_data.get("target_artist_id"), + } + + +def _load_album_and_source_track(database, album_id: str, source_track_id: str) -> tuple[dict, dict]: + 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: + raise MissingTrackImportError("Album not found", 404) + + cursor.execute("SELECT * FROM tracks WHERE id = ?", (source_track_id,)) + source_row = cursor.fetchone() + if not source_row: + raise MissingTrackImportError("Selected library track not found", 404) + + return dict(album_row), dict(source_row) + + +def _copy_source_to_staging(source_path: str, album_id: str, expected: dict, deps: MissingTrackImportDeps) -> str: + download_dir = deps.docker_resolve_path_fn(deps.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}_" + f"{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) + return staging_path + + +def _build_api_track( + expected: dict, + expected_title: str, + expected_track_id: str, + album_source_id: str, + metadata_source: str, + album_data: dict, +) -> dict: + return { + "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 "", + } + + +def _build_context( + payload: dict, + album_data: dict, + source_path: str, + source_track_id: str, + api_track: dict, + album_source_id: str, + metadata_source: str, +) -> dict: + 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, + } + context = _build_post_process_context( + api_album, + api_track, + album_data.get("artist_name") or "", + album_data.get("title") or "", + int(payload.get("total_discs") or payload.get("expected_track", {}).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_data.get('id')}_{uuid.uuid4().hex[:8]}" + context["task_id"] = f"existing_import_{source_track_id}" + return context + + +def _upsert_target_track( + database, + deps: MissingTrackImportDeps, + album_id: str, + album_data: dict, + source_track: dict, + final_path: str, + expected_title: str, + expected_track_id: str, + metadata_source: str, + api_track: dict, +): + file_size, bitrate = _read_file_stats(final_path, source_track) + server_source = album_data.get("server_source") or source_track.get("server_source") or deps.config_manager.get_active_media_server() + + with database._get_connection() as conn: + cursor = conn.cursor() + _ensure_disc_number_column(cursor, conn) + + 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, + server_source, + 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, + server_source, + ), + ) + + track_source_col = (deps.service_id_columns or {}).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() + + return target_track_id + + +def _ensure_disc_number_column(cursor, conn) -> None: + 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() + + +def _read_file_stats(final_path: str, source_track: dict) -> tuple[Optional[int], int]: + 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) + return file_size, bitrate + + +def _sync_imported_track(deps: MissingTrackImportDeps, track_id, expected_title: str, album_data: dict) -> None: + try: + active_server = deps.config_manager.get_active_media_server() + if deps.sync_tracks_to_server_fn and active_server in ("jellyfin", "navidrome"): + deps.sync_tracks_to_server_fn( + [ + { + "id": 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) + + +def copy_album_identity_from_target_sibling( + database, + album_id: str, + final_path: str, + target_disc: int, + target_track: int, + resolve_library_file_path_fn: Callable[[Optional[str]], Optional[str]], +) -> bool: + 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_fn(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 + + +def read_album_identity_tags(file_path: str) -> dict: + try: + from mutagen import File as MutagenFile + from mutagen.id3 import ID3, TXXX + from mutagen.mp4 import MP4 + + audio = MutagenFile(file_path) + if not audio: + return {} + + tags = {} + if isinstance(getattr(audio, "tags", None), ID3): + for tag_key, frame_id in _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 _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 _MP4_STANDARD_TAGS.items(): + value = _first_tag_value(audio, mp4_key) + if value: + tags[tag_key] = value + for tag_key, desc in _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 _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: str, tags: dict) -> bool: + 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 _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 _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 _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 _MP4_STANDARD_TAGS.items(): + if tags.get(tag_key): + audio[mp4_key] = [tags[tag_key]] + for tag_key, desc in _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 _first_tag_value(audio, key: str) -> Optional[str]: + 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 _expected_track_id(expected: dict) -> str: + return ( + 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 "" + ) + + +def _album_source_id(payload: dict, expected: dict, album_data: dict, album_id: str) -> str: + return ( + payload.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) + ) + + +def _file_not_found_message(file_path: Optional[str]) -> str: + if file_path: + return f"File not found: {file_path}" + return "Selected library track does not have a file path" diff --git a/web_server.py b/web_server.py index bb0c6ce5..82591ca7 100644 --- a/web_server.py +++ b/web_server.py @@ -10779,213 +10779,6 @@ 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): @@ -10996,227 +10789,26 @@ def library_import_existing_track_for_missing_slot(album_id): original source file is never moved or deleted. """ try: - import shutil - from core.library_reorganize import _build_post_process_context + from core.library.missing_track_import import ( + MissingTrackImportDeps, + MissingTrackImportError, + import_existing_track_for_album_slot, + ) 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) + deps = MissingTrackImportDeps( + database=database, + config_manager=config_manager, + post_process_fn=_post_process_matched_download, + resolve_library_file_path_fn=_resolve_library_file_path, + docker_resolve_path_fn=docker_resolve_path, + sync_tracks_to_server_fn=_sync_tracks_to_server, + service_id_columns=_SERVICE_ID_COLUMNS, ) - 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')) + result = import_existing_track_for_album_slot(album_id, data, deps) + updated = database.get_artist_full_detail(result.get('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']) @@ -11227,10 +10819,12 @@ def library_import_existing_track_for_missing_slot(album_id): return jsonify({ "success": True, "message": "Imported existing track into album", - "track_id": target_track_id, - "final_path": final_path, + "track_id": result.get('track_id'), + "final_path": result.get('final_path'), "updated_data": updated if updated.get('success') else None, }) + except MissingTrackImportError as e: + return jsonify({"success": False, "error": str(e)}), e.status_code 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 From f3d5ef6528dd59205b9f0b25215c1cb1965f70fa Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 17 May 2026 14:18:17 -0700 Subject: [PATCH 3/3] Test missing-track existing file imports Add service-level coverage for the Enhanced Library I Have This flow: copying an existing source file, writing the target album DB row, preserving source audio, inheriting album identity tags, and migrating older track tables that lack disc_number. --- core/library/missing_track_import.py | 7 + tests/library/test_missing_track_import.py | 260 +++++++++++++++++++++ 2 files changed, 267 insertions(+) create mode 100644 tests/library/test_missing_track_import.py diff --git a/core/library/missing_track_import.py b/core/library/missing_track_import.py index 65bf6357..7b322be4 100644 --- a/core/library/missing_track_import.py +++ b/core/library/missing_track_import.py @@ -140,6 +140,7 @@ def import_existing_track_for_album_slot(album_id: str, payload: dict, deps: Mis if not final_path or not os.path.exists(final_path): raise MissingTrackImportError("Post-processing did not produce a final file", 500) + ensure_tracks_disc_number_column(database) copy_album_identity_from_target_sibling( database, album_id, @@ -383,6 +384,12 @@ def _ensure_disc_number_column(cursor, conn) -> None: conn.commit() +def ensure_tracks_disc_number_column(database) -> None: + with database._get_connection() as conn: + cursor = conn.cursor() + _ensure_disc_number_column(cursor, conn) + + def _read_file_stats(final_path: str, source_track: dict) -> tuple[Optional[int], int]: file_size = None bitrate = source_track.get("bitrate") or 0 diff --git a/tests/library/test_missing_track_import.py b/tests/library/test_missing_track_import.py new file mode 100644 index 00000000..077810be --- /dev/null +++ b/tests/library/test_missing_track_import.py @@ -0,0 +1,260 @@ +"""Tests for the Enhanced Library "I Have This" import service.""" + +from __future__ import annotations + +import os +import shutil +import sqlite3 +from dataclasses import dataclass + +import pytest + +from core.library import missing_track_import as mti + + +class _ConnCtx: + def __init__(self, conn): + self.conn = conn + + def __enter__(self): + return self.conn + + def __exit__(self, exc_type, exc, tb): + return False + + +class _FakeDB: + def __init__(self, conn): + self.conn = conn + + def _get_connection(self): + return _ConnCtx(self.conn) + + +@dataclass +class _FakeConfig: + download_path: str + active_server: str = "navidrome" + + def get(self, key, default=None): + if key == "soulseek.download_path": + return self.download_path + return default + + def get_active_media_server(self): + return self.active_server + + +def _make_db(*, include_disc_number: bool = True) -> tuple[_FakeDB, sqlite3.Connection]: + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + cur = conn.cursor() + cur.execute( + """ + CREATE TABLE artists ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL + ) + """ + ) + cur.execute( + """ + CREATE TABLE albums ( + id TEXT PRIMARY KEY, + artist_id TEXT NOT NULL, + title TEXT NOT NULL, + year INTEGER, + track_count INTEGER, + server_source TEXT, + deezer_id TEXT, + thumb_url TEXT + ) + """ + ) + disc_col = ", disc_number INTEGER DEFAULT 1" if include_disc_number else "" + cur.execute( + f""" + CREATE TABLE tracks ( + id TEXT PRIMARY KEY, + album_id TEXT NOT NULL, + artist_id TEXT NOT NULL, + title TEXT NOT NULL, + track_number INTEGER{disc_col}, + duration INTEGER, + file_path TEXT, + bitrate INTEGER, + file_size INTEGER, + server_source TEXT, + deezer_id TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + cur.execute("INSERT INTO artists (id, name) VALUES ('artist-1', 'Kendrick Lamar')") + cur.execute( + """ + INSERT INTO albums (id, artist_id, title, year, track_count, server_source, deezer_id) + VALUES ('album-basic', 'artist-1', 'DAMN.', 2017, 14, 'navidrome', '302127') + """ + ) + cur.execute( + """ + INSERT INTO albums (id, artist_id, title, year, track_count, server_source, deezer_id) + VALUES ('album-deluxe', 'artist-1', 'DAMN. COLLECTORS EDITION', 2017, 14, 'navidrome', '999999') + """ + ) + conn.commit() + return _FakeDB(conn), conn + + +def _insert_track(conn, *, track_id, album_id, title, track_number, file_path, disc_number=1): + columns = [row[1] for row in conn.execute("PRAGMA table_info(tracks)").fetchall()] + if "disc_number" in columns: + conn.execute( + """ + INSERT INTO tracks (id, album_id, artist_id, title, track_number, disc_number, duration, file_path, bitrate, file_size, server_source) + VALUES (?, ?, 'artist-1', ?, ?, ?, 177000, ?, 900, 1234, 'navidrome') + """, + (track_id, album_id, title, track_number, disc_number, str(file_path)), + ) + else: + conn.execute( + """ + INSERT INTO tracks (id, album_id, artist_id, title, track_number, duration, file_path, bitrate, file_size, server_source) + VALUES (?, ?, 'artist-1', ?, ?, 177000, ?, 900, 1234, 'navidrome') + """, + (track_id, album_id, title, track_number, str(file_path)), + ) + conn.commit() + + +def _deps(tmp_path, db, *, post_process_fn=None, sync_calls=None): + sync_calls = sync_calls if sync_calls is not None else [] + + def _default_post_process(_key, context, staged_path): + final_dir = tmp_path / "Library" / "Kendrick Lamar - 2017 DAMN" + final_dir.mkdir(parents=True, exist_ok=True) + final_path = final_dir / "08 - HUMBLE [FLAC 16bit].flac" + shutil.copy2(staged_path, final_path) + context["_final_processed_path"] = str(final_path) + + return mti.MissingTrackImportDeps( + database=db, + config_manager=_FakeConfig(str(tmp_path / "downloads")), + post_process_fn=post_process_fn or _default_post_process, + resolve_library_file_path_fn=lambda path: str(path) if path and os.path.exists(path) else None, + docker_resolve_path_fn=lambda path: path, + sync_tracks_to_server_fn=lambda rows, server: sync_calls.append((rows, server)), + service_id_columns={"deezer": {"track": "deezer_id"}}, + ) + + +def _payload(): + return { + "source_track_id": "deluxe-humble", + "album_source_id": "302127", + "total_discs": 1, + "expected_track": { + "title": "HUMBLE.", + "track_number": 8, + "disc_number": 1, + "duration": 177000, + "source": "deezer", + "track_id": "350171311", + "deezer_id": "350171311", + "artists": ["Kendrick Lamar"], + }, + } + + +def test_import_existing_track_copies_file_and_writes_target_album_row(tmp_path, monkeypatch): + db, conn = _make_db(include_disc_number=True) + source_file = tmp_path / "deluxe" / "08 - HUMBLE.flac" + source_file.parent.mkdir() + source_file.write_bytes(b"source audio") + sibling_file = tmp_path / "basic" / "01 - BLOOD.flac" + sibling_file.parent.mkdir() + sibling_file.write_bytes(b"sibling audio") + _insert_track(conn, track_id="basic-blood", album_id="album-basic", title="BLOOD.", track_number=1, file_path=sibling_file) + _insert_track(conn, track_id="deluxe-humble", album_id="album-deluxe", title="HUMBLE.", track_number=8, file_path=source_file) + + inherited = [] + monkeypatch.setattr(mti, "read_album_identity_tags", lambda path: {"musicbrainz_albumid": "target-release"} if path == str(sibling_file) else {}) + monkeypatch.setattr(mti, "write_album_identity_tags", lambda path, tags: inherited.append((path, tags)) or True) + + sync_calls = [] + result = mti.import_existing_track_for_album_slot("album-basic", _payload(), _deps(tmp_path, db, sync_calls=sync_calls)) + + assert source_file.read_bytes() == b"source audio" + assert os.path.exists(result["final_path"]) + assert inherited == [(result["final_path"], {"musicbrainz_albumid": "target-release"})] + + row = conn.execute("SELECT * FROM tracks WHERE album_id = 'album-basic' AND track_number = 8").fetchone() + assert row is not None + assert row["title"] == "HUMBLE." + assert row["disc_number"] == 1 + assert row["file_path"] == result["final_path"] + assert row["deezer_id"] == "350171311" + assert sync_calls and sync_calls[0][1] == "navidrome" + + +def test_import_adds_disc_number_column_for_older_track_tables(tmp_path, monkeypatch): + db, conn = _make_db(include_disc_number=False) + source_file = tmp_path / "deluxe" / "08 - HUMBLE.flac" + source_file.parent.mkdir() + source_file.write_bytes(b"source audio") + sibling_file = tmp_path / "basic" / "01 - BLOOD.flac" + sibling_file.parent.mkdir() + sibling_file.write_bytes(b"sibling audio") + _insert_track(conn, track_id="basic-blood", album_id="album-basic", title="BLOOD.", track_number=1, file_path=sibling_file) + _insert_track(conn, track_id="deluxe-humble", album_id="album-deluxe", title="HUMBLE.", track_number=8, file_path=source_file) + + write_calls = [] + monkeypatch.setattr(mti, "read_album_identity_tags", lambda path: {"musicbrainz_albumid": "target-release"} if path == str(sibling_file) else {}) + monkeypatch.setattr(mti, "write_album_identity_tags", lambda path, tags: write_calls.append((path, tags)) or True) + + result = mti.import_existing_track_for_album_slot("album-basic", _payload(), _deps(tmp_path, db)) + + columns = [row[1] for row in conn.execute("PRAGMA table_info(tracks)").fetchall()] + assert "disc_number" in columns + row = conn.execute("SELECT title, disc_number, file_path FROM tracks WHERE album_id = 'album-basic' AND track_number = 8").fetchone() + assert row["title"] == "HUMBLE." + assert row["disc_number"] == 1 + assert row["file_path"] == result["final_path"] + assert write_calls, "album identity inheritance should still run after old DB migration" + + +def test_copy_album_identity_uses_target_sibling_and_leaves_track_tags_to_imported_file(tmp_path, monkeypatch): + db, conn = _make_db(include_disc_number=True) + sibling_file = tmp_path / "basic" / "01 - BLOOD.flac" + sibling_file.parent.mkdir() + sibling_file.write_bytes(b"sibling") + final_file = tmp_path / "basic" / "08 - HUMBLE.flac" + final_file.write_bytes(b"imported") + _insert_track(conn, track_id="basic-blood", album_id="album-basic", title="BLOOD.", track_number=1, file_path=sibling_file) + + monkeypatch.setattr(mti, "read_album_identity_tags", lambda path: {"musicbrainz_albumid": "target-release", "barcode": "target-barcode"}) + writes = [] + monkeypatch.setattr(mti, "write_album_identity_tags", lambda path, tags: writes.append((path, tags)) or True) + + copied = mti.copy_album_identity_from_target_sibling( + db, + "album-basic", + str(final_file), + 1, + 8, + lambda path: str(path) if os.path.exists(path) else None, + ) + + assert copied is True + assert writes == [(str(final_file), {"musicbrainz_albumid": "target-release", "barcode": "target-barcode"})] + + +def test_import_rejects_missing_expected_track_context(tmp_path): + db, _conn = _make_db(include_disc_number=True) + with pytest.raises(mti.MissingTrackImportError) as exc: + mti.import_existing_track_for_album_slot("album-basic", {"source_track_id": "x", "expected_track": {}}, _deps(tmp_path, db)) + + assert exc.value.status_code == 400 + assert "expected_track" in str(exc.value)