Merge pull request #622 from Nezreka/codex/missing-track-import
Codex/missing track import
This commit is contained in:
commit
4d882180bb
6 changed files with 1893 additions and 32 deletions
605
core/library/missing_track_import.py
Normal file
605
core/library/missing_track_import.py
Normal 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"
|
||||
|
|
@ -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)")
|
||||
|
|
|
|||
260
tests/library/test_missing_track_import.py
Normal file
260
tests/library/test_missing_track_import.py
Normal 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)
|
||||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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 = `
|
||||
<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">↻</button>
|
||||
<button class="enhanced-delete-btn" title="Delete track from library">✕</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">↻</button>
|
||||
<button class="enhanced-delete-btn" title="Delete track from library">✕</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 = '⚑';
|
||||
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 = '<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">×</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)} · ${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">×</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)} · ${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 ? ` · ${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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue