diff --git a/core/library/missing_track_import.py b/core/library/missing_track_import.py new file mode 100644 index 00000000..7b322be4 --- /dev/null +++ b/core/library/missing_track_import.py @@ -0,0 +1,605 @@ +"""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) + + ensure_tracks_disc_number_column(database) + 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 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 + 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/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/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) diff --git a/web_server.py b/web_server.py index 3b88ca4c..82591ca7 100644 --- a/web_server.py +++ b/web_server.py @@ -10779,6 +10779,57 @@ def library_clear_match(): return jsonify({"success": False, "error": str(e)}), 500 +@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: + from core.library.missing_track_import import ( + MissingTrackImportDeps, + MissingTrackImportError, + import_existing_track_for_album_slot, + ) + + data = request.get_json() or {} + database = get_database() + 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, + ) + + 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']) + 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": 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 + + @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 {