Merge pull request #622 from Nezreka/codex/missing-track-import

Codex/missing track import
This commit is contained in:
BoulderBadgeDad 2026-05-17 14:27:12 -07:00 committed by GitHub
commit 4d882180bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 1893 additions and 32 deletions

View file

@ -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"

View file

@ -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)")

View file

@ -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)

View file

@ -10779,6 +10779,57 @@ def library_clear_match():
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/library/album/<album_id>/import-existing-track', methods=['POST'])
@app.route('/api/library/album/<album_id>/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/<track_id>', methods=['DELETE'])
def library_delete_track(track_id):
"""Delete a track from the database, optionally deleting the file and blacklisting the source."""

View file

@ -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 = '&#9654;';
playBtn.innerHTML = track._missingExpected ? '&mdash;' : '&#9654;';
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 = '&#43;';
@ -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 = '&#9998;';
@ -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 = `
<div class="enhanced-track-actions-group">
<button class="enhanced-source-info-btn" title="View download source info"></button>
<button class="enhanced-redownload-btn" title="Redownload this track">&#8635;</button>
<button class="enhanced-delete-btn" title="Delete track from library">&#10005;</button>
</div>
`;
if (track._missingExpected) {
actionsTd.innerHTML = `
<div class="enhanced-track-actions-group visible">
<button class="enhanced-missing-manage-btn" data-action="manage-missing" title="Manage this missing album track">Manage</button>
</div>
`;
} else {
actionsTd.innerHTML = `
<div class="enhanced-track-actions-group">
<button class="enhanced-source-info-btn" title="View download source info"></button>
<button class="enhanced-redownload-btn" title="Redownload this track">&#8635;</button>
<button class="enhanced-delete-btn" title="Delete track from library">&#10005;</button>
</div>
`;
}
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 = '&#9873;';
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 = '&#9873;';
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 = '<div class="enhanced-no-tracks">No tracks in database</div>';
@ -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 = `
<div class="confirm-modal-header">
<h2>Manage Missing Track</h2>
<button class="confirm-modal-close" type="button">&times;</button>
</div>
<div class="confirm-modal-body enhanced-missing-manage-body">
<div class="enhanced-missing-manage-target">
<div class="enhanced-have-target-label">Missing album slot</div>
<div class="enhanced-have-target-title">#${escapeHtml(String(track.track_number || '?'))} ${escapeHtml(track.title || 'Unknown Track')}</div>
<div class="enhanced-have-target-meta">${escapeHtml(artistName)} &middot; ${escapeHtml(album.title || '')}</div>
</div>
<div class="enhanced-missing-manage-options">
<button class="enhanced-missing-option primary" data-action="library">
<span class="enhanced-missing-option-icon">+</span>
<span>
<span class="enhanced-missing-option-title">Add to Library</span>
<span class="enhanced-missing-option-desc">Open the normal library-add flow with this exact track context.</span>
</span>
</button>
<button class="enhanced-missing-option" data-action="have">
<span class="enhanced-missing-option-icon">OK</span>
<span>
<span class="enhanced-missing-option-title">I Have This</span>
<span class="enhanced-missing-option-desc">Copy an existing file and process it into this album slot. The original stays untouched.</span>
</span>
</button>
</div>
</div>
<div class="confirm-modal-actions">
<button class="modal-button modal-button--secondary" type="button" data-action="cancel">Cancel</button>
</div>
`;
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 = `
<div class="enhanced-bulk-modal-header">
<div>
<h3>I Have This Track</h3>
<div class="enhanced-have-subtitle">Use an existing file as the source audio. SoulSync will copy it into this album.</div>
</div>
<button class="enhanced-bulk-modal-close" type="button">&times;</button>
</div>
<div class="enhanced-have-target">
<div class="enhanced-have-target-label">Missing album slot</div>
<div class="enhanced-have-target-title">#${escapeHtml(String(track.track_number || '?'))} ${escapeHtml(track.title || 'Unknown Track')}</div>
<div class="enhanced-have-target-meta">${escapeHtml(artistName)} &middot; ${escapeHtml(album.title || '')}</div>
</div>
<div class="enhanced-match-search-row">
<input class="enhanced-match-search-input" id="enhanced-have-track-search" type="text" value="${escapeHtml(`${track.title || ''} ${artistName}`.trim())}" placeholder="Search your library...">
<button class="enhanced-enrich-btn" id="enhanced-have-track-search-btn" type="button">Search</button>
</div>
<div class="enhanced-match-results" id="enhanced-have-track-results">
<div class="enhanced-match-results-hint">Searching your library...</div>
</div>
<div class="enhanced-have-selected" id="enhanced-have-selected" hidden>
<span>Selected</span>
<strong></strong>
</div>
<div class="enhanced-have-note">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.</div>
<div class="enhanced-have-import-status" id="enhanced-have-import-status" hidden>
<div class="enhanced-have-import-status-top">
<span class="enhanced-have-import-spinner"></span>
<span class="enhanced-have-import-title">Preparing import...</span>
<span class="enhanced-have-import-time">0s</span>
</div>
<div class="enhanced-have-import-detail">Waiting to start.</div>
</div>
<div class="enhanced-bulk-modal-footer">
<button class="enhanced-bulk-btn secondary" type="button" id="enhanced-have-cancel">Cancel</button>
<button class="enhanced-bulk-btn primary" type="button" id="enhanced-have-confirm" disabled>Import Track</button>
</div>
`;
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 = '<div class="enhanced-match-results-hint">Enter a title or artist to search.</div>';
return;
}
selectedTrackId = null;
selectedTrackSummary = '';
selectedEl.hidden = true;
confirmBtn.disabled = true;
resultsEl.innerHTML = '<div class="enhanced-loading">Searching...</div>';
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 = '<div class="enhanced-match-results-hint">No library tracks found. Try a different search.</div>';
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 = `
<span class="enhanced-have-radio"></span>
<span class="enhanced-have-result-main">
<span class="enhanced-have-result-title">${escapeHtml(result.title || 'Unknown')}</span>
<span class="enhanced-have-result-meta">${escapeHtml(result.artist_name || '')}${result.album_title ? ` &middot; ${escapeHtml(result.album_title)}` : ''}</span>
<span class="enhanced-have-result-file">${escapeHtml(fileName)}</span>
</span>
<span class="enhanced-have-result-side">
${result.duration ? `<span>${formatDurationMs(result.duration)}</span>` : ''}
${result.bitrate ? `<span>${result.bitrate} kbps</span>` : ''}
</span>
`;
resultsEl.appendChild(row);
});
} catch (error) {
resultsEl.innerHTML = `<div class="enhanced-match-results-hint" style="color:#ff6b6b;">Error: ${escapeHtml(error.message)}</div>`;
}
}
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;

View file

@ -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 {