Add per-download Audit Trail modal to Library History
- new "Audit" button on each download row in the library history modal opens a second modal visualizing the download lifecycle as an interactive horizontal stepper (request → source → match → verify → process → place) with click-to-expand detail cards - hero header with album art + track title + meta line + status pills (source / quality / acoustid result) - three tabs: Lifecycle / Tags / Lyrics - Tags tab reads the audio file live via mutagen at audit-open time via new GET /api/library/history/<id>/file-tags endpoint; file is the single source of truth so background enrichment writes (audiodb / lastfm / genius / replaygain / lyrics fetch) show up too. flat key/value rows stacked vertically (label-above- value) so long MBIDs / URLs / joined genre lists wrap cleanly. source IDs grouped per-service into 2-col sub-card grid. - Lyrics tab renders the full transcript with dimmed timecodes. - post-processing step infers observable changes from source-vs- final state (format conversion, file rename via tag template, folder template). - "Download History" button also added to the Downloads page batch panel header so it's reachable outside the dashboard. - mobile responsive: tabs + stepper scroll horizontally, modal goes full-screen, hero stacks below 480px. 19 helper tests pin the mutagen reader: id3 (TIT2/TPE1/TALB + TXXX + USLT + APIC), vorbis (FLAC dict + _id/_url passthrough), file metadata (format / bitrate / duration), defensive paths (empty / missing file / mutagen returns None / mutagen raises), stringify edge cases (list / tuple / int / frame-with-text / whitespace).
This commit is contained in:
parent
253c7676d6
commit
6ce185491d
7 changed files with 1943 additions and 5 deletions
367
core/library/file_tags.py
Normal file
367
core/library/file_tags.py
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
"""Read embedded tags from an audio file for the Audit Trail UI.
|
||||
|
||||
The Audit Trail modal on the Library History view needs to show
|
||||
exactly what tags are currently embedded in a downloaded file —
|
||||
title/artist/album metadata, MusicBrainz/Spotify/Tidal IDs,
|
||||
ReplayGain values, ISRC, cover-art presence, lyrics, and anything
|
||||
else SoulSync or its background enrichment workers wrote.
|
||||
|
||||
The file is the single source of truth. A persisted snapshot at
|
||||
post-process time would drift the moment a background worker
|
||||
(audiodb, lastfm, genius, deezer enrichment, lyrics fetch) writes
|
||||
more tags, or if the user manually re-tags. So the audit endpoint
|
||||
reads the file live on demand.
|
||||
|
||||
This module is the pure mutagen wrapper. Returns a canonical
|
||||
JSON-serializable dict; never raises (failure modes degrade to an
|
||||
``{'available': False, 'reason': '...'}`` shape so the caller can
|
||||
surface a useful error to the user).
|
||||
|
||||
Frontend renders the canonical shape directly — no per-source
|
||||
mapping at the API layer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Dict
|
||||
|
||||
from core.metadata.common import get_mutagen_symbols
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
|
||||
logger = get_logger("library.file_tags")
|
||||
|
||||
|
||||
# ID3 frame names that carry textual values we want to surface
|
||||
# under the "core" tag group. mutagen exposes ID3 frames keyed by
|
||||
# their 4-letter codes, so map those codes to friendly labels.
|
||||
_ID3_TEXT_FRAMES = {
|
||||
"TIT2": "title",
|
||||
"TPE1": "artist",
|
||||
"TPE2": "album_artist",
|
||||
"TALB": "album",
|
||||
"TDRC": "date",
|
||||
"TCON": "genre",
|
||||
"TRCK": "tracknumber",
|
||||
"TPOS": "discnumber",
|
||||
"TBPM": "bpm",
|
||||
"TMOO": "mood",
|
||||
"TCOP": "copyright",
|
||||
"TPUB": "publisher",
|
||||
"TLAN": "language",
|
||||
}
|
||||
|
||||
|
||||
# TXXX-style ID3 frames carry user-defined keys via their `desc`
|
||||
# attribute. We pick known descriptions out of those.
|
||||
_KNOWN_TXXX_DESCS = {
|
||||
"MusicBrainz Album Id": "musicbrainz_albumid",
|
||||
"MusicBrainz Artist Id": "musicbrainz_artistid",
|
||||
"MusicBrainz Album Artist Id": "musicbrainz_albumartistid",
|
||||
"MusicBrainz Release Group Id": "musicbrainz_releasegroupid",
|
||||
"MusicBrainz Release Track Id": "musicbrainz_releasetrackid",
|
||||
"MusicBrainz Track Id": "musicbrainz_trackid",
|
||||
"Spotify Track Id": "spotify_track_id",
|
||||
"Spotify Artist Id": "spotify_artist_id",
|
||||
"Spotify Album Id": "spotify_album_id",
|
||||
"Tidal Track Id": "tidal_track_id",
|
||||
"Tidal Artist Id": "tidal_artist_id",
|
||||
"Tidal Album Id": "tidal_album_id",
|
||||
"Deezer Track Id": "deezer_track_id",
|
||||
"Deezer Artist Id": "deezer_artist_id",
|
||||
"Deezer Album Id": "deezer_album_id",
|
||||
"AudioDB Track Id": "audiodb_track_id",
|
||||
"AudioDB Artist Id": "audiodb_artist_id",
|
||||
"AudioDB Album Id": "audiodb_album_id",
|
||||
"iTunes Track Id": "itunes_track_id",
|
||||
"iTunes Artist Id": "itunes_artist_id",
|
||||
"iTunes Album Id": "itunes_album_id",
|
||||
"Genius Track Id": "genius_track_id",
|
||||
"Genius Url": "genius_url",
|
||||
"LastFm Url": "lastfm_url",
|
||||
"ASIN": "asin",
|
||||
"BARCODE": "barcode",
|
||||
"CATALOGNUMBER": "catalognumber",
|
||||
"ISRC": "isrc",
|
||||
"ORIGINALDATE": "originaldate",
|
||||
"RELEASECOUNTRY": "releasecountry",
|
||||
"RELEASESTATUS": "releasestatus",
|
||||
"RELEASETYPE": "releasetype",
|
||||
"SCRIPT": "script",
|
||||
"MEDIA": "media",
|
||||
"TOTALDISCS": "totaldiscs",
|
||||
"TOTALTRACKS": "tracktotal",
|
||||
"STYLE": "style",
|
||||
"QUALITY": "quality",
|
||||
"Artists": "artists",
|
||||
"replaygain_track_gain": "replaygain_track_gain",
|
||||
"replaygain_track_peak": "replaygain_track_peak",
|
||||
"replaygain_album_gain": "replaygain_album_gain",
|
||||
"replaygain_album_peak": "replaygain_album_peak",
|
||||
}
|
||||
|
||||
|
||||
# Vorbis (FLAC/OGG/OPUS) tag keys map 1:1 with our friendly names —
|
||||
# Vorbis is the most permissive container, every key is just a
|
||||
# string. mutagen surfaces them as lowercase by convention.
|
||||
# This passlist filters out the noise (encoder, comment, ...) and
|
||||
# whitelists everything we want to show.
|
||||
_VORBIS_ALLOWED_KEYS = frozenset({
|
||||
"title", "artist", "albumartist", "album_artist", "album",
|
||||
"date", "year", "genre", "tracknumber", "discnumber",
|
||||
"tracktotal", "totaltracks", "totaldiscs", "bpm", "mood",
|
||||
"copyright", "publisher", "language", "style", "quality",
|
||||
"isrc", "barcode", "catalognumber", "asin", "script",
|
||||
"media", "originaldate", "releasecountry", "releasestatus",
|
||||
"releasetype", "artists", "composer", "performer",
|
||||
"musicbrainz_albumid", "musicbrainz_artistid",
|
||||
"musicbrainz_albumartistid", "musicbrainz_releasegroupid",
|
||||
"musicbrainz_releasetrackid", "musicbrainz_trackid",
|
||||
"spotify_track_id", "spotify_artist_id", "spotify_album_id",
|
||||
"tidal_track_id", "tidal_artist_id", "tidal_album_id",
|
||||
"deezer_track_id", "deezer_artist_id", "deezer_album_id",
|
||||
"audiodb_track_id", "audiodb_artist_id", "audiodb_album_id",
|
||||
"itunes_track_id", "itunes_artist_id", "itunes_album_id",
|
||||
"genius_track_id", "genius_url", "lastfm_url",
|
||||
"replaygain_track_gain", "replaygain_track_peak",
|
||||
"replaygain_album_gain", "replaygain_album_peak",
|
||||
"lyrics", "unsyncedlyrics",
|
||||
})
|
||||
|
||||
|
||||
def read_embedded_tags(file_path: str) -> Dict[str, Any]:
|
||||
"""Read embedded tags from an audio file via mutagen.
|
||||
|
||||
Returns a dict with one of two shapes:
|
||||
|
||||
- ``{"available": True, "format": "...", "bitrate": ..., "tags": {...}, "has_picture": bool}``
|
||||
on success. ``tags`` is a flat dict of lowercase friendly key →
|
||||
string value (lists joined with ', '). Long fields like
|
||||
``lyrics`` are returned in full — caller decides how to display.
|
||||
|
||||
- ``{"available": False, "reason": "..."}`` when the file doesn't
|
||||
exist, isn't readable, or mutagen can't recognise the format.
|
||||
|
||||
Never raises. Caller surfaces ``reason`` to the user verbatim.
|
||||
"""
|
||||
if not file_path or not isinstance(file_path, str):
|
||||
return {"available": False, "reason": "No file path on this row."}
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
return {
|
||||
"available": False,
|
||||
"reason": f"File no longer exists at: {file_path}",
|
||||
}
|
||||
|
||||
symbols = get_mutagen_symbols()
|
||||
if symbols is None:
|
||||
return {"available": False, "reason": "Mutagen is unavailable."}
|
||||
|
||||
try:
|
||||
audio = symbols.File(file_path)
|
||||
except Exception as exc:
|
||||
logger.debug("Mutagen open failed for %s: %s", file_path, exc)
|
||||
return {
|
||||
"available": False,
|
||||
"reason": f"Could not open file: {exc}",
|
||||
}
|
||||
|
||||
if audio is None:
|
||||
return {
|
||||
"available": False,
|
||||
"reason": "File format not recognised by mutagen.",
|
||||
}
|
||||
|
||||
fmt = type(audio).__name__
|
||||
bitrate = 0
|
||||
duration = 0.0
|
||||
try:
|
||||
if getattr(audio, "info", None) is not None:
|
||||
bitrate = int(getattr(audio.info, "bitrate", 0) or 0)
|
||||
duration = float(getattr(audio.info, "length", 0) or 0)
|
||||
except Exception as exc: # noqa: S110 — optional info, missing is fine
|
||||
logger.debug("audio info read failed: %s", exc)
|
||||
|
||||
has_picture = _detect_picture(audio, symbols)
|
||||
tags = _extract_tags(audio, symbols)
|
||||
|
||||
return {
|
||||
"available": True,
|
||||
"format": fmt,
|
||||
"bitrate": bitrate,
|
||||
"duration": duration,
|
||||
"has_picture": has_picture,
|
||||
"tags": tags,
|
||||
}
|
||||
|
||||
|
||||
def _detect_picture(audio: Any, symbols: Any) -> bool:
|
||||
"""True when the file has at least one embedded cover-art picture."""
|
||||
# FLAC / OGG-Vorbis expose pictures via `audio.pictures` list.
|
||||
pictures = getattr(audio, "pictures", None)
|
||||
if pictures:
|
||||
return True
|
||||
# ID3 stores pictures as APIC frames.
|
||||
tags = getattr(audio, "tags", None)
|
||||
if tags is None:
|
||||
return False
|
||||
try:
|
||||
if hasattr(tags, "getall"):
|
||||
apics = tags.getall("APIC")
|
||||
if apics:
|
||||
return True
|
||||
# MP4 covers under 'covr' key.
|
||||
if "covr" in tags and tags["covr"]:
|
||||
return True
|
||||
# Vorbis embedded base64 picture frame.
|
||||
if "metadata_block_picture" in tags:
|
||||
return True
|
||||
except Exception as exc: # noqa: S110 — optional probe, missing is fine
|
||||
logger.debug("picture detect failed: %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
def _extract_tags(audio: Any, symbols: Any) -> Dict[str, str]:
|
||||
"""Flatten the audio file's tag store to a {key: string} dict.
|
||||
|
||||
Handles the three container families we ship: ID3 (MP3),
|
||||
Vorbis-like (FLAC/OGG/OPUS), and MP4. Everything else falls
|
||||
through to a generic key/value dump.
|
||||
"""
|
||||
out: Dict[str, str] = {}
|
||||
tags = getattr(audio, "tags", None)
|
||||
if tags is None:
|
||||
return out
|
||||
|
||||
# ID3 path.
|
||||
if isinstance(tags, symbols.ID3):
|
||||
for code, label in _ID3_TEXT_FRAMES.items():
|
||||
frame = tags.get(code)
|
||||
if frame is not None:
|
||||
val = _stringify(frame)
|
||||
if val:
|
||||
out[label] = val
|
||||
# TXXX user-defined frames (most of our extra IDs / replay
|
||||
# gain / source IDs live here).
|
||||
try:
|
||||
for frame in tags.getall("TXXX"):
|
||||
desc = getattr(frame, "desc", "")
|
||||
if not desc:
|
||||
continue
|
||||
# mutagen's TXXX comparison is case-sensitive; the
|
||||
# dict lookup matches the exact desc string.
|
||||
key = _KNOWN_TXXX_DESCS.get(desc) or desc.lower().replace(" ", "_")
|
||||
val = _stringify(frame)
|
||||
if val:
|
||||
out[key] = val
|
||||
except Exception as exc: # noqa: S110 — optional TXXX walk
|
||||
logger.debug("ID3 TXXX walk failed: %s", exc)
|
||||
# USLT (unsynchronised lyrics).
|
||||
try:
|
||||
for frame in tags.getall("USLT"):
|
||||
val = _stringify(frame)
|
||||
if val:
|
||||
out.setdefault("lyrics", val)
|
||||
except Exception as exc: # noqa: S110 — optional USLT walk
|
||||
logger.debug("ID3 USLT walk failed: %s", exc)
|
||||
return out
|
||||
|
||||
# MP4 path.
|
||||
if isinstance(audio, symbols.MP4):
|
||||
_MP4_MAP = {
|
||||
"\xa9nam": "title",
|
||||
"\xa9ART": "artist",
|
||||
"aART": "album_artist",
|
||||
"\xa9alb": "album",
|
||||
"\xa9day": "date",
|
||||
"\xa9gen": "genre",
|
||||
"trkn": "tracknumber",
|
||||
"disk": "discnumber",
|
||||
"\xa9lyr": "lyrics",
|
||||
"tmpo": "bpm",
|
||||
"cprt": "copyright",
|
||||
}
|
||||
for key, label in _MP4_MAP.items():
|
||||
if key in tags:
|
||||
val = _stringify(tags[key])
|
||||
if val:
|
||||
out[label] = val
|
||||
# Freeform MP4 atoms — prefix ----:com.apple.iTunes:
|
||||
for k in tags.keys():
|
||||
if not isinstance(k, str) or not k.startswith("----"):
|
||||
continue
|
||||
label = k.split(":")[-1].lower()
|
||||
val = _stringify(tags[k])
|
||||
if val:
|
||||
out[label] = val
|
||||
return out
|
||||
|
||||
# Vorbis-like (FLAC, OGG, OPUS): tags acts dict-like, values are
|
||||
# lists of strings.
|
||||
try:
|
||||
for raw_key in tags.keys():
|
||||
if not isinstance(raw_key, str):
|
||||
continue
|
||||
lower = raw_key.lower()
|
||||
if lower not in _VORBIS_ALLOWED_KEYS:
|
||||
# Pass through anything that looks like a known
|
||||
# source/ID-style key even if not in the allowed
|
||||
# set — covers `*_id`, `*_url` shapes we didn't
|
||||
# explicitly list.
|
||||
if not (lower.endswith("_id") or lower.endswith("_url") or lower.startswith("musicbrainz_")):
|
||||
continue
|
||||
val = _stringify(tags[raw_key])
|
||||
if val:
|
||||
out[lower] = val
|
||||
except Exception as exc: # noqa: S110 — optional vorbis walk
|
||||
logger.debug("Vorbis tag walk failed: %s", exc)
|
||||
return out
|
||||
|
||||
|
||||
def _stringify(value: Any) -> str:
|
||||
"""Coerce a mutagen tag value into a human-readable string.
|
||||
|
||||
mutagen returns various shapes depending on the container —
|
||||
bare strings, lists of strings, frame objects with `.text` or
|
||||
`.data` attributes, MP4Cover objects, integer tuples (trkn,
|
||||
disk), etc. Best-effort flatten.
|
||||
"""
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
return value.strip()
|
||||
if isinstance(value, (int, float)):
|
||||
return str(value)
|
||||
if isinstance(value, (list, tuple)):
|
||||
parts = []
|
||||
for item in value:
|
||||
if isinstance(item, tuple):
|
||||
# (track_num, total) shape from MP4 trkn / disk.
|
||||
if len(item) >= 1 and item[0]:
|
||||
if len(item) >= 2 and item[1]:
|
||||
parts.append(f"{item[0]}/{item[1]}")
|
||||
else:
|
||||
parts.append(str(item[0]))
|
||||
continue
|
||||
s = _stringify(item)
|
||||
if s:
|
||||
parts.append(s)
|
||||
return ", ".join(parts)
|
||||
# mutagen frame objects: prefer .text, then .data, then str().
|
||||
text = getattr(value, "text", None)
|
||||
if text is not None and text is not value:
|
||||
return _stringify(text)
|
||||
data = getattr(value, "data", None)
|
||||
if isinstance(data, (str, bytes)):
|
||||
try:
|
||||
return data.decode("utf-8", errors="replace").strip() if isinstance(data, bytes) else data.strip()
|
||||
except Exception:
|
||||
return ""
|
||||
try:
|
||||
return str(value).strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
__all__ = ["read_embedded_tags"]
|
||||
354
tests/library/test_file_tags.py
Normal file
354
tests/library/test_file_tags.py
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
"""Pin `read_embedded_tags` — pure mutagen reader backing the audit
|
||||
trail's "Embedded Tags" section.
|
||||
|
||||
Tests use mock mutagen objects to verify the extraction logic
|
||||
without needing real audio fixtures checked in. The reader handles
|
||||
three container families:
|
||||
|
||||
- ID3 (MP3): text frames keyed by 4-letter codes + TXXX user-defined
|
||||
frames keyed by `desc`.
|
||||
- Vorbis-like (FLAC, OGG, OPUS): dict-like tags, lowercase keys,
|
||||
list-of-strings values.
|
||||
- MP4: dict-like with weird atom keys including the iTunes
|
||||
``----:com.apple.iTunes:`` freeform atoms.
|
||||
|
||||
Every test pins ONE behavior — easier to debug when one regresses.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Boundary cases — bad inputs, missing files, mutagen returns None
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_returns_unavailable_for_empty_path():
|
||||
from core.library.file_tags import read_embedded_tags
|
||||
result = read_embedded_tags('')
|
||||
assert result['available'] is False
|
||||
assert 'No file path' in result['reason']
|
||||
|
||||
|
||||
def test_returns_unavailable_for_none():
|
||||
from core.library.file_tags import read_embedded_tags
|
||||
result = read_embedded_tags(None) # type: ignore[arg-type]
|
||||
assert result['available'] is False
|
||||
|
||||
|
||||
def test_returns_unavailable_when_file_missing(tmp_path):
|
||||
from core.library.file_tags import read_embedded_tags
|
||||
fake = tmp_path / 'gone.mp3'
|
||||
result = read_embedded_tags(str(fake))
|
||||
assert result['available'] is False
|
||||
assert 'no longer exists' in result['reason']
|
||||
|
||||
|
||||
def test_returns_unavailable_when_mutagen_returns_none(tmp_path):
|
||||
"""File exists but mutagen can't recognise the format — should
|
||||
fall through to a clear `available: false` rather than raising."""
|
||||
real = tmp_path / 'garbage.txt'
|
||||
real.write_bytes(b'not audio')
|
||||
from core.library import file_tags as ft
|
||||
with patch.object(ft, 'get_mutagen_symbols') as g:
|
||||
symbols = MagicMock()
|
||||
symbols.File.return_value = None
|
||||
g.return_value = symbols
|
||||
result = ft.read_embedded_tags(str(real))
|
||||
assert result['available'] is False
|
||||
assert 'not recognised' in result['reason']
|
||||
|
||||
|
||||
def test_mutagen_open_exception_swallowed(tmp_path):
|
||||
"""Mutagen raises on a malformed file — caller still gets a
|
||||
clean error dict, no propagated exception."""
|
||||
real = tmp_path / 'malformed.mp3'
|
||||
real.write_bytes(b'not really an mp3')
|
||||
from core.library import file_tags as ft
|
||||
with patch.object(ft, 'get_mutagen_symbols') as g:
|
||||
symbols = MagicMock()
|
||||
symbols.File.side_effect = RuntimeError('mutagen blew up')
|
||||
g.return_value = symbols
|
||||
result = ft.read_embedded_tags(str(real))
|
||||
assert result['available'] is False
|
||||
assert 'Could not open file' in result['reason']
|
||||
assert 'mutagen blew up' in result['reason']
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ID3 path (MP3) — TIT2/TPE1/TALB + TXXX user-defined frames
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_id3_audio(symbols, frames, txxx_frames=None, pictures=False):
|
||||
"""Helper to build a fake mutagen ID3 audio object.
|
||||
|
||||
`frames` is a dict of {code: text}. `txxx_frames` is a list of
|
||||
(desc, text) tuples for user-defined ID3 frames.
|
||||
"""
|
||||
tags = MagicMock()
|
||||
tags.__class__ = symbols.ID3
|
||||
frame_map = {}
|
||||
for code, text in frames.items():
|
||||
f = SimpleNamespace(text=[text])
|
||||
frame_map[code] = f
|
||||
tags.get.side_effect = lambda code: frame_map.get(code)
|
||||
|
||||
def _getall(code):
|
||||
if code == 'TXXX':
|
||||
return [SimpleNamespace(desc=d, text=[t]) for d, t in (txxx_frames or [])]
|
||||
if code == 'USLT':
|
||||
return []
|
||||
if code == 'APIC':
|
||||
return [object()] if pictures else []
|
||||
return []
|
||||
|
||||
tags.getall.side_effect = _getall
|
||||
audio = MagicMock()
|
||||
audio.tags = tags
|
||||
audio.info = SimpleNamespace(bitrate=320000, length=204.5)
|
||||
type(audio).__name__ = 'MP3'
|
||||
return audio
|
||||
|
||||
|
||||
def test_id3_extracts_core_text_frames(tmp_path):
|
||||
real = tmp_path / 't.mp3'
|
||||
real.write_bytes(b'\x00')
|
||||
from core.library import file_tags as ft
|
||||
with patch.object(ft, 'get_mutagen_symbols') as g:
|
||||
symbols = MagicMock()
|
||||
symbols.ID3 = MagicMock # isinstance check uses this
|
||||
audio = _build_id3_audio(symbols, frames={
|
||||
'TIT2': 'Without Me',
|
||||
'TPE1': 'Eminem',
|
||||
'TPE2': 'Eminem',
|
||||
'TALB': 'The Eminem Show',
|
||||
'TDRC': '2002',
|
||||
'TCON': 'Hip-Hop',
|
||||
'TRCK': '10/20',
|
||||
'TPOS': '1',
|
||||
})
|
||||
symbols.MP4 = type('MP4', (), {}) # not an MP4
|
||||
symbols.File.return_value = audio
|
||||
g.return_value = symbols
|
||||
result = ft.read_embedded_tags(str(real))
|
||||
|
||||
assert result['available'] is True
|
||||
assert result['tags']['title'] == 'Without Me'
|
||||
assert result['tags']['artist'] == 'Eminem'
|
||||
assert result['tags']['album_artist'] == 'Eminem'
|
||||
assert result['tags']['album'] == 'The Eminem Show'
|
||||
assert result['tags']['date'] == '2002'
|
||||
assert result['tags']['genre'] == 'Hip-Hop'
|
||||
assert result['tags']['tracknumber'] == '10/20'
|
||||
assert result['tags']['discnumber'] == '1'
|
||||
|
||||
|
||||
def test_id3_extracts_txxx_known_descriptions(tmp_path):
|
||||
"""Source IDs land in TXXX frames keyed by description. Reader
|
||||
maps known descs to friendly snake_case keys."""
|
||||
real = tmp_path / 't.mp3'
|
||||
real.write_bytes(b'\x00')
|
||||
from core.library import file_tags as ft
|
||||
with patch.object(ft, 'get_mutagen_symbols') as g:
|
||||
symbols = MagicMock()
|
||||
symbols.ID3 = MagicMock
|
||||
symbols.MP4 = type('MP4', (), {})
|
||||
audio = _build_id3_audio(symbols, frames={'TIT2': 'X'}, txxx_frames=[
|
||||
('Spotify Track Id', 'sp_abc'),
|
||||
('MusicBrainz Release Group Id', 'mb_def'),
|
||||
('replaygain_track_gain', '-9.90 dB'),
|
||||
('replaygain_track_peak', '1.161449'),
|
||||
])
|
||||
symbols.File.return_value = audio
|
||||
g.return_value = symbols
|
||||
result = ft.read_embedded_tags(str(real))
|
||||
|
||||
assert result['tags']['spotify_track_id'] == 'sp_abc'
|
||||
assert result['tags']['musicbrainz_releasegroupid'] == 'mb_def'
|
||||
assert result['tags']['replaygain_track_gain'] == '-9.90 dB'
|
||||
assert result['tags']['replaygain_track_peak'] == '1.161449'
|
||||
|
||||
|
||||
def test_id3_unknown_txxx_desc_falls_back_to_snake_case(tmp_path):
|
||||
real = tmp_path / 't.mp3'
|
||||
real.write_bytes(b'\x00')
|
||||
from core.library import file_tags as ft
|
||||
with patch.object(ft, 'get_mutagen_symbols') as g:
|
||||
symbols = MagicMock()
|
||||
symbols.ID3 = MagicMock
|
||||
symbols.MP4 = type('MP4', (), {})
|
||||
audio = _build_id3_audio(symbols, frames={'TIT2': 'X'}, txxx_frames=[
|
||||
('Custom Vendor Field', 'foo'),
|
||||
])
|
||||
symbols.File.return_value = audio
|
||||
g.return_value = symbols
|
||||
result = ft.read_embedded_tags(str(real))
|
||||
# Unknown desc → lowercased + underscored
|
||||
assert result['tags']['custom_vendor_field'] == 'foo'
|
||||
|
||||
|
||||
def test_id3_detects_apic_cover_art(tmp_path):
|
||||
real = tmp_path / 't.mp3'
|
||||
real.write_bytes(b'\x00')
|
||||
from core.library import file_tags as ft
|
||||
with patch.object(ft, 'get_mutagen_symbols') as g:
|
||||
symbols = MagicMock()
|
||||
symbols.ID3 = MagicMock
|
||||
symbols.MP4 = type('MP4', (), {})
|
||||
audio = _build_id3_audio(symbols, frames={'TIT2': 'X'}, pictures=True)
|
||||
symbols.File.return_value = audio
|
||||
g.return_value = symbols
|
||||
result = ft.read_embedded_tags(str(real))
|
||||
assert result['has_picture'] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Vorbis-like (FLAC) — dict-style lowercase keys, list values
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_vorbis_passes_through_whitelisted_keys(tmp_path):
|
||||
real = tmp_path / 't.flac'
|
||||
real.write_bytes(b'\x00')
|
||||
from core.library import file_tags as ft
|
||||
with patch.object(ft, 'get_mutagen_symbols') as g:
|
||||
symbols = MagicMock()
|
||||
# Not ID3, not MP4 — falls through to the vorbis branch.
|
||||
symbols.ID3 = type('ID3', (), {})
|
||||
symbols.MP4 = type('MP4', (), {})
|
||||
tags = {
|
||||
'title': ['Teenage Dream'],
|
||||
'artist': ['Katy Perry'],
|
||||
'album': ['Teenage Dream'],
|
||||
'date': ['2010'],
|
||||
'isrc': ['USCA21001255'],
|
||||
'musicbrainz_albumid': ['mb-album-id'],
|
||||
'tidal_track_id': ['14165831'],
|
||||
'unrelated_internal_key': ['skip-me'],
|
||||
}
|
||||
audio = MagicMock()
|
||||
audio.tags = tags
|
||||
audio.info = SimpleNamespace(bitrate=900000, length=180.0)
|
||||
audio.pictures = []
|
||||
type(audio).__name__ = 'FLAC'
|
||||
symbols.File.return_value = audio
|
||||
g.return_value = symbols
|
||||
result = ft.read_embedded_tags(str(real))
|
||||
|
||||
assert result['tags']['title'] == 'Teenage Dream'
|
||||
assert result['tags']['artist'] == 'Katy Perry'
|
||||
assert result['tags']['isrc'] == 'USCA21001255'
|
||||
assert result['tags']['musicbrainz_albumid'] == 'mb-album-id'
|
||||
assert result['tags']['tidal_track_id'] == '14165831'
|
||||
# Non-whitelisted, non-_id/_url keys are dropped.
|
||||
assert 'unrelated_internal_key' not in result['tags']
|
||||
|
||||
|
||||
def test_vorbis_pass_through_for_unknown_id_url_keys(tmp_path):
|
||||
"""Vendor-prefixed `*_id` / `*_url` keys should pass through even
|
||||
if they're not in the explicit whitelist — covers future
|
||||
enrichment workers we haven't anticipated."""
|
||||
real = tmp_path / 't.flac'
|
||||
real.write_bytes(b'\x00')
|
||||
from core.library import file_tags as ft
|
||||
with patch.object(ft, 'get_mutagen_symbols') as g:
|
||||
symbols = MagicMock()
|
||||
symbols.ID3 = type('ID3', (), {})
|
||||
symbols.MP4 = type('MP4', (), {})
|
||||
tags = {
|
||||
'title': ['X'],
|
||||
'beatport_track_id': ['bp_xyz'],
|
||||
'songkick_url': ['https://...'],
|
||||
}
|
||||
audio = MagicMock()
|
||||
audio.tags = tags
|
||||
audio.info = SimpleNamespace(bitrate=900000, length=1.0)
|
||||
audio.pictures = []
|
||||
symbols.File.return_value = audio
|
||||
g.return_value = symbols
|
||||
result = ft.read_embedded_tags(str(real))
|
||||
assert result['tags']['beatport_track_id'] == 'bp_xyz'
|
||||
assert result['tags']['songkick_url'] == 'https://...'
|
||||
|
||||
|
||||
def test_vorbis_detects_pictures(tmp_path):
|
||||
real = tmp_path / 't.flac'
|
||||
real.write_bytes(b'\x00')
|
||||
from core.library import file_tags as ft
|
||||
with patch.object(ft, 'get_mutagen_symbols') as g:
|
||||
symbols = MagicMock()
|
||||
symbols.ID3 = type('ID3', (), {})
|
||||
symbols.MP4 = type('MP4', (), {})
|
||||
audio = MagicMock()
|
||||
audio.tags = {'title': ['X']}
|
||||
audio.info = SimpleNamespace(bitrate=900000, length=1.0)
|
||||
audio.pictures = [object()] # one embedded image
|
||||
symbols.File.return_value = audio
|
||||
g.return_value = symbols
|
||||
result = ft.read_embedded_tags(str(real))
|
||||
assert result['has_picture'] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Format + bitrate metadata
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_returns_format_and_bitrate(tmp_path):
|
||||
real = tmp_path / 't.mp3'
|
||||
real.write_bytes(b'\x00')
|
||||
from core.library import file_tags as ft
|
||||
with patch.object(ft, 'get_mutagen_symbols') as g:
|
||||
symbols = MagicMock()
|
||||
symbols.ID3 = MagicMock
|
||||
symbols.MP4 = type('MP4', (), {})
|
||||
audio = _build_id3_audio(symbols, frames={'TIT2': 'X'})
|
||||
type(audio).__name__ = 'MP3' # mutagen exposes class name
|
||||
audio.info = SimpleNamespace(bitrate=320000, length=204.5)
|
||||
symbols.File.return_value = audio
|
||||
g.return_value = symbols
|
||||
result = ft.read_embedded_tags(str(real))
|
||||
assert result['format'] == 'MP3'
|
||||
assert result['bitrate'] == 320000
|
||||
assert result['duration'] == pytest.approx(204.5)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stringify defensive cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStringify:
|
||||
def test_list_of_strings_joined(self):
|
||||
from core.library.file_tags import _stringify
|
||||
assert _stringify(['a', 'b', 'c']) == 'a, b, c'
|
||||
|
||||
def test_tuple_pair_joined_with_slash(self):
|
||||
"""MP4 trkn / disk values come as (current, total) tuples."""
|
||||
from core.library.file_tags import _stringify
|
||||
assert _stringify([(10, 20)]) == '10/20'
|
||||
|
||||
def test_int_coerced_to_string(self):
|
||||
from core.library.file_tags import _stringify
|
||||
assert _stringify(42) == '42'
|
||||
|
||||
def test_none_returns_empty(self):
|
||||
from core.library.file_tags import _stringify
|
||||
assert _stringify(None) == ''
|
||||
|
||||
def test_frame_with_text_attribute_unwrapped(self):
|
||||
"""mutagen frames expose `.text` as a list of strings."""
|
||||
from core.library.file_tags import _stringify
|
||||
frame = SimpleNamespace(text=['Title Here'])
|
||||
assert _stringify(frame) == 'Title Here'
|
||||
|
||||
def test_whitespace_stripped(self):
|
||||
from core.library.file_tags import _stringify
|
||||
assert _stringify(' spaced ') == 'spaced'
|
||||
|
|
@ -8416,6 +8416,41 @@ def get_library_history():
|
|||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/library/history/<int:history_id>/file-tags')
|
||||
def get_library_history_file_tags(history_id: int):
|
||||
"""Read embedded tags from the actual audio file for one library
|
||||
history row. Backs the Audit Trail modal's "Embedded Tags" section.
|
||||
|
||||
The file is the single source of truth — persisted snapshot
|
||||
columns drift the moment a background worker writes more tags.
|
||||
`read_embedded_tags` returns a uniform dict; we pass through.
|
||||
"""
|
||||
try:
|
||||
db = get_database()
|
||||
entries, _total = db.get_library_history(event_type=None, page=1, limit=200)
|
||||
entry = next((e for e in entries if e.get('id') == history_id), None)
|
||||
if entry is None:
|
||||
# Wider lookup — pagination above may not have caught older rows.
|
||||
conn = db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM library_history WHERE id = ?", (history_id,))
|
||||
row = cursor.fetchone()
|
||||
entry = dict(row) if row else None
|
||||
if entry is None:
|
||||
return jsonify({'success': False, 'error': 'history row not found'}), 404
|
||||
|
||||
raw_path = entry.get('file_path') or ''
|
||||
resolved = _resolve_library_file_path(raw_path) if raw_path else None
|
||||
target_path = resolved or raw_path
|
||||
|
||||
from core.library.file_tags import read_embedded_tags
|
||||
result = read_embedded_tags(target_path)
|
||||
return jsonify({'success': True, **result})
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading file tags for history {history_id}: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/library/artists')
|
||||
def get_library_artists():
|
||||
"""Get artists for the library page with search, filtering, and pagination"""
|
||||
|
|
|
|||
|
|
@ -2176,9 +2176,12 @@
|
|||
<div class="adl-batch-panel" id="adl-batch-panel">
|
||||
<div class="adl-batch-panel-header">
|
||||
<h3 class="adl-batch-panel-title">Batches</h3>
|
||||
<button class="adl-batch-panel-collapse" id="adl-batch-collapse" onclick="adlToggleBatchPanel()" title="Toggle batch panel">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</button>
|
||||
<div class="adl-batch-panel-header-actions">
|
||||
<button class="library-history-btn" onclick="openLibraryHistoryModal()" title="View full download + import history">Download History</button>
|
||||
<button class="adl-batch-panel-collapse" id="adl-batch-collapse" onclick="adlToggleBatchPanel()" title="Toggle batch panel">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="adl-batch-active" id="adl-batch-active">
|
||||
<!-- Active batch cards rendered by JS -->
|
||||
|
|
@ -2186,7 +2189,10 @@
|
|||
<div class="adl-batch-history-section" id="adl-batch-history-section" style="display:none">
|
||||
<div class="adl-batch-history-header" onclick="adlToggleBatchHistory()">
|
||||
<span>Recent History</span>
|
||||
<svg class="adl-batch-history-chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
<div class="adl-batch-history-header-actions">
|
||||
<button class="library-history-btn" onclick="event.stopPropagation();openLibraryHistoryModal()" title="View full download + import history">Download History</button>
|
||||
<svg class="adl-batch-history-chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="adl-batch-history-list" id="adl-batch-history-list">
|
||||
<!-- Completed batch history rendered by JS -->
|
||||
|
|
@ -7639,6 +7645,16 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Download Audit Trail Modal -->
|
||||
<div class="modal-overlay hidden" id="download-audit-overlay" onclick="if(event.target===this)closeDownloadAuditModal()">
|
||||
<div class="download-audit-modal">
|
||||
<button class="download-audit-close" onclick="closeDownloadAuditModal()" title="Close">×</button>
|
||||
<div class="download-audit-hero" id="download-audit-hero"></div>
|
||||
<div class="download-audit-tabs" id="download-audit-tabs"></div>
|
||||
<div class="download-audit-body" id="download-audit-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sync History Modal -->
|
||||
<div class="modal-overlay hidden" id="sync-history-overlay" onclick="if(event.target===this)closeSyncHistoryModal()">
|
||||
<div class="sync-history-modal">
|
||||
|
|
|
|||
|
|
@ -3416,7 +3416,8 @@ const WHATS_NEW = {
|
|||
'2.5.2': [
|
||||
// --- post-release patch work on the 2.5.2 line — entries hidden by _getLatestWhatsNewVersion until the build version bumps ---
|
||||
{ date: 'Unreleased — 2.5.2 patch work' },
|
||||
{ title: '$albumtype Folder Template Now Splits EPs / Singles For Non-Spotify Sources', desc: 'discord report (cal): downloading an artist\'s discography with `$albumtype` in the path template put every release under `Album/` regardless of actual type — eps, singles, all dumped into the album folder. trace: the legacy duck-typed album-info builder at `core/metadata/album_tracks.py:_build_album_info_legacy` only checked the `album_type` key. spotify uses `album_type` (lowercase) so spotify discographies worked. but deezer\'s api uses `record_type`, tidal uses `type` (uppercase ALBUM/EP/SINGLE), and some flattened musicbrainz shapes use `primary-type` — none of those matched, all defaulted to `album`. fix: widen the legacy lookup to check `album_type` / `record_type` / `type` / `primary-type` and route the value through a new pure `_normalize_album_type` helper that lowercases + validates against the canonical token set (`album` / `single` / `ep` / `compilation`) and falls back to `album` for unknowns. typed-converter path for spotify / deezer / itunes / discogs / musicbrainz / hydrabase / qobuz unchanged — they were already correct. tidal users were the main offender (no typed converter for dict-shaped tidal data). 25 new tests pin: case-insensitive normalization for each canonical type, compilation preserved (spotify supports it), unknown values default to album, defensive against none / empty / non-string inputs, multi-key precedence (`album_type` wins over `record_type`), each known source shape produces correct token, generic `type='track'` / `type='artist'` collision case defaults to album rather than poisoning the path.', page: 'tools' },
|
||||
{ title: 'Library History: Per-Download Audit Trail Modal', desc: 'each download row in library history now has an "audit" button that opens a second modal visualizing the download lifecycle as a vertical chain of decision blocks: request → source selected → source match → verification → post processing → final placement. each step has a status (complete / partial / unknown / error) with a color-coded node, plus a card showing what was decided and the supporting metadata. post-processing step infers observable changes from source-vs-final state (format conversion, file rename via tag template, title/artist rewrite, folder template). new "embedded tags" section below the flow reads the audio file live via mutagen at audit-open time and surfaces every tag actually on the file — title / artist / album / album artist / date / genre / track # / disc # / bpm / mood / style / copyright / publisher / release type+status+country / barcode / catalog # / asin / isrc / replaygain values / cover-art status / lyrics / every source id (spotify, tidal, deezer, musicbrainz, audiodb, lastfm, genius, itunes, beatport ...). file is the single source of truth — a persisted snapshot would drift the moment a background enrichment worker writes more tags. clean fallback when file is missing or unreadable. 19 tests pin the pure mutagen reader: id3 path (TIT2/TPE1/TALB + TXXX user-defined frames + USLT + APIC cover-art), vorbis path (FLAC dict-style + pass-through for unknown _id / _url keys), mp4 stub, format+bitrate+duration metadata, defensive paths (empty path, missing file, mutagen returns None, mutagen raises), stringify edge cases (list / tuple / int / frame-with-text / whitespace). files: core/library/file_tags.py (new mutagen reader), web_server.py (new GET /api/library/history/<id>/file-tags endpoint), webui/index.html (audit-overlay modal), webui/static/wishlist-tools.js (renderer + async fetch + tag-grid render), webui/static/style.css (flow + tags section + lyrics block styles).', page: 'wishlist' },
|
||||
{ title: '$albumtype Folder Template Now Splits EPs / Singles For Non-Spotify Sources', desc: 'discord report (cal): downloading an artist\'s discography with `$albumtype` in the path template put every release under `Album/` regardless of actual type — eps, singles, all dumped into the album folder. trace: the legacy duck-typed album-info builder at `core/metadata/album_tracks.py:_build_album_info_legacy` only checked the `album_type` key. spotify uses `album_type` (lowercase) so spotify discographies worked. but deezer\'s api uses `record_type`, tidal uses `type` (uppercase ALBUM/EP/SINGLE), and some flattened musicbrainz shapes use `primary-type` — none of those matched, all defaulted to `album`. fix: widen the legacy lookup to check `album_type` / `record_type` / `type` / `primary-type` and route the value through a new pure `_normalize_album_type` helper that lowercases + validates against the canonical token set (`album` / `single` / `ep` / `compilation`) and falls back to `album` for unknowns. typed-converter path for spotify / deezer / itunes / discogs / musicbrainz / hydrabase / qobuz unchanged — they were already correct. tidal users were the main offender (no typed converter for dict-shaped tidal data). 25 new tests pin: case-insensitive normalization for each canonical type, compilation preserved (spotify supports it), unknown values default to album, defensive against none / empty / non-string inputs, multi-key precedence (`album_type` wins over `record_type`), each known source shape produces correct token, generic `type=track` / `type=artist` collision case defaults to album rather than poisoning the path.', page: 'tools' },
|
||||
],
|
||||
'2.5.1': [
|
||||
// --- May 12, 2026 — 2.5.1 release ---
|
||||
|
|
|
|||
|
|
@ -10172,6 +10172,491 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
padding: 0 8px;
|
||||
}
|
||||
|
||||
/* ── Download Audit Trail Modal ─────────────────────────── */
|
||||
.lh-audit-btn {
|
||||
background: rgba(var(--accent-rgb), 0.12);
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.35);
|
||||
color: rgb(var(--accent-rgb));
|
||||
border-radius: 6px;
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
margin-left: 8px;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.lh-audit-btn:hover {
|
||||
background: rgba(var(--accent-rgb), 0.22);
|
||||
border-color: rgba(var(--accent-rgb), 0.6);
|
||||
}
|
||||
|
||||
.download-audit-modal {
|
||||
width: 1040px;
|
||||
max-width: 96vw;
|
||||
max-height: 88vh;
|
||||
background: linear-gradient(180deg, rgba(20, 20, 22, 0.98), rgba(12, 12, 14, 0.99));
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 32px 96px rgba(0, 0, 0, 0.7);
|
||||
position: relative;
|
||||
}
|
||||
.download-audit-close {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
z-index: 2;
|
||||
}
|
||||
.download-audit-close:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Hero header — album art + title + meta + status pills */
|
||||
.download-audit-hero {
|
||||
display: grid;
|
||||
grid-template-columns: 88px 1fr;
|
||||
gap: 18px;
|
||||
align-items: center;
|
||||
padding: 28px 32px 24px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.download-audit-hero-art {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
border-radius: 10px;
|
||||
object-fit: cover;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.download-audit-hero-art-placeholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 36px;
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
.download-audit-hero-text {
|
||||
min-width: 0;
|
||||
}
|
||||
.download-audit-hero-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: #fff;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.download-audit-hero-meta {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
font-size: 13.5px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.download-audit-hero-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.download-audit-hero-pill {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.download-audit-hero-pill-source {
|
||||
background: rgba(var(--accent-rgb), 0.14);
|
||||
border-color: rgba(var(--accent-rgb), 0.3);
|
||||
color: rgb(var(--accent-rgb));
|
||||
}
|
||||
.download-audit-hero-pill-pass,
|
||||
.download-audit-hero-pill-verify.download-audit-hero-pill-pass {
|
||||
background: rgba(74, 222, 128, 0.12);
|
||||
border-color: rgba(74, 222, 128, 0.3);
|
||||
color: #4ade80;
|
||||
}
|
||||
.download-audit-hero-pill-fail,
|
||||
.download-audit-hero-pill-error {
|
||||
background: rgba(239, 83, 80, 0.12);
|
||||
border-color: rgba(239, 83, 80, 0.3);
|
||||
color: #ef5350;
|
||||
}
|
||||
|
||||
/* Tab bar */
|
||||
.download-audit-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 0 32px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.download-audit-tab {
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
padding: 12px 14px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.download-audit-tab:hover {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
.download-audit-tab.active {
|
||||
color: #fff;
|
||||
border-bottom-color: rgb(var(--accent-rgb));
|
||||
}
|
||||
|
||||
.download-audit-body {
|
||||
overflow-y: auto;
|
||||
padding: 24px 32px 28px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Horizontal stepper (Lifecycle tab) */
|
||||
.download-audit-stepper {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
margin-bottom: 22px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
.download-audit-stepper-node {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 0;
|
||||
flex: 0 0 auto;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.download-audit-stepper-node:hover { color: rgba(255, 255, 255, 0.85); }
|
||||
.download-audit-stepper-node.active { color: #fff; }
|
||||
.download-audit-stepper-line {
|
||||
flex: 1 1 auto;
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
margin-top: 14px;
|
||||
}
|
||||
.download-audit-stepper-circle {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 13px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1.5px solid rgba(255, 255, 255, 0.12);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.download-audit-stepper-node.active .download-audit-stepper-circle {
|
||||
transform: scale(1.08);
|
||||
box-shadow: 0 0 0 4px rgba(var(--accent-rgb), 0.18);
|
||||
}
|
||||
.download-audit-stepper-complete .download-audit-stepper-circle {
|
||||
color: #4ade80;
|
||||
border-color: rgba(74, 222, 128, 0.45);
|
||||
background: rgba(74, 222, 128, 0.12);
|
||||
}
|
||||
.download-audit-stepper-partial .download-audit-stepper-circle {
|
||||
color: #facc15;
|
||||
border-color: rgba(250, 204, 21, 0.45);
|
||||
background: rgba(250, 204, 21, 0.12);
|
||||
}
|
||||
.download-audit-stepper-error .download-audit-stepper-circle {
|
||||
color: #ef5350;
|
||||
border-color: rgba(239, 83, 80, 0.45);
|
||||
background: rgba(239, 83, 80, 0.12);
|
||||
}
|
||||
.download-audit-stepper-label {
|
||||
text-transform: uppercase;
|
||||
font-size: 9.5px;
|
||||
letter-spacing: 0.06em;
|
||||
font-weight: 600;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Detail card for the selected step */
|
||||
.download-audit-step-card {
|
||||
border-radius: 12px;
|
||||
padding: 18px 20px;
|
||||
margin-bottom: 16px;
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.download-audit-step-card-title {
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.download-audit-step-detail {
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.download-audit-step-meta {
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
word-break: break-word;
|
||||
}
|
||||
.download-audit-step-card-complete { border-color: rgba(74, 222, 128, 0.18); }
|
||||
.download-audit-step-card-partial { border-color: rgba(250, 204, 21, 0.18); }
|
||||
.download-audit-step-card-error { border-color: rgba(239, 83, 80, 0.18); }
|
||||
|
||||
/* Final path strip */
|
||||
.download-audit-final-path {
|
||||
margin-top: 4px;
|
||||
padding: 14px 16px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.download-audit-final-path-label {
|
||||
text-transform: uppercase;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.download-audit-final-path code {
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
font-family: ui-monospace, "Cascadia Mono", Menlo, monospace;
|
||||
}
|
||||
|
||||
/* Chips used in old layout — kept for the file-info strip in Tags */
|
||||
.download-audit-chip {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ── Mobile responsive ───────────────────────────────────── */
|
||||
@media (max-width: 760px) {
|
||||
.download-audit-modal {
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
}
|
||||
.download-audit-hero {
|
||||
grid-template-columns: 64px 1fr;
|
||||
gap: 14px;
|
||||
padding: 20px 18px 16px;
|
||||
}
|
||||
.download-audit-hero-art {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
.download-audit-hero-title {
|
||||
font-size: 17px;
|
||||
white-space: normal; /* allow wrap on narrow screens */
|
||||
}
|
||||
.download-audit-hero-meta {
|
||||
font-size: 12.5px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.download-audit-tabs {
|
||||
padding: 0 12px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none; /* hide on Firefox */
|
||||
}
|
||||
.download-audit-tabs::-webkit-scrollbar { display: none; }
|
||||
.download-audit-tab {
|
||||
padding: 10px 12px;
|
||||
font-size: 12.5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.download-audit-body {
|
||||
padding: 16px 16px 24px;
|
||||
}
|
||||
/* Stepper: scroll horizontally instead of cramming all 6 nodes.
|
||||
Connector lines look weird when scrolling so hide them. */
|
||||
.download-audit-stepper {
|
||||
overflow-x: auto;
|
||||
flex-wrap: nowrap;
|
||||
scrollbar-width: none;
|
||||
gap: 18px;
|
||||
padding: 0 4px 4px;
|
||||
}
|
||||
.download-audit-stepper::-webkit-scrollbar { display: none; }
|
||||
.download-audit-stepper-line { display: none; }
|
||||
.download-audit-stepper-node { min-width: 56px; }
|
||||
.download-audit-step-card {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.download-audit-step-card-title {
|
||||
font-size: 14px;
|
||||
}
|
||||
.download-audit-close {
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.download-audit-hero {
|
||||
grid-template-columns: 1fr; /* stack art above text */
|
||||
text-align: left;
|
||||
justify-items: start;
|
||||
}
|
||||
.download-audit-hero-art {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
}
|
||||
}
|
||||
/* Tags tab — chips strip + 2-column metadata + source sub-cards */
|
||||
.download-audit-tags-empty {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
line-height: 1.5;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.download-audit-tags-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.download-audit-tags-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.download-audit-tags-grid { grid-template-columns: 1fr; gap: 16px; }
|
||||
}
|
||||
.download-audit-tag-group {
|
||||
/* No box — just spacing + title */
|
||||
}
|
||||
.download-audit-tag-group-title {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
/* Stacked label-above-value pattern so long values (MBIDs, URLs,
|
||||
joined genre lists) never get squeezed by a fixed key column. */
|
||||
.download-audit-tag-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 7px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.download-audit-tag-row:last-child { border-bottom: none; }
|
||||
.download-audit-tag-key {
|
||||
color: rgba(255, 255, 255, 0.42);
|
||||
font-size: 10.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-weight: 500;
|
||||
}
|
||||
.download-audit-tag-value {
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.download-audit-tags-body {
|
||||
min-height: 60px;
|
||||
}
|
||||
.download-audit-sources {
|
||||
margin-top: 8px;
|
||||
}
|
||||
/* Cap at 2 cols max so long values (MBIDs ~36 chars) have room.
|
||||
Drops to 1 col below tablet width. */
|
||||
.download-audit-source-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.download-audit-source-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
.download-audit-source-card {
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
min-width: 0; /* allow flex/grid children to shrink + wrap */
|
||||
}
|
||||
.download-audit-source-card-title {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: rgb(var(--accent-rgb));
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.download-audit-source-card .download-audit-tag-row {
|
||||
padding: 5px 0;
|
||||
border-bottom-color: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
.download-audit-source-card .download-audit-tag-value {
|
||||
font-family: ui-monospace, "Cascadia Mono", Menlo, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Lyrics tab — full-height transcript with dimmed timecodes */
|
||||
.download-audit-lyrics-body {
|
||||
min-height: 60px;
|
||||
}
|
||||
.download-audit-lyrics-text {
|
||||
font-size: 13.5px;
|
||||
line-height: 1.7;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.download-audit-lyric-timecode {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
font-size: 11px;
|
||||
margin-right: 8px;
|
||||
font-family: ui-monospace, "Cascadia Mono", Menlo, monospace;
|
||||
}
|
||||
|
||||
/* ── Sync History Modal ─────────────────────────────────── */
|
||||
.sync-history-modal {
|
||||
width: 750px;
|
||||
|
|
@ -57885,6 +58370,12 @@ body.reduce-effects *::after {
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
.adl-batch-panel-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Collapse toggle */
|
||||
.adl-batch-panel-collapse {
|
||||
background: none;
|
||||
|
|
@ -58222,6 +58713,12 @@ body.reduce-effects *::after {
|
|||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.adl-batch-history-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.adl-batch-history-section.expanded .adl-batch-history-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3080,6 +3080,10 @@ async function loadMetadataCacheStats() {
|
|||
|
||||
// ── Library History Modal ────────────────────────────────────────────
|
||||
let _libraryHistoryState = { tab: 'download', page: 1, limit: 50 };
|
||||
// id → entry cache so the per-row audit button can look up the
|
||||
// full entry without round-tripping JSON through DOM data attrs
|
||||
// (escape headaches when titles / file paths contain quotes).
|
||||
const _libraryHistoryEntryCache = {};
|
||||
|
||||
function openLibraryHistoryModal() {
|
||||
const overlay = document.getElementById('library-history-overlay');
|
||||
|
|
@ -3147,6 +3151,10 @@ async function loadLibraryHistory() {
|
|||
return;
|
||||
}
|
||||
|
||||
// Cache entries by id for the per-row audit button lookup.
|
||||
data.entries.forEach(e => {
|
||||
if (e && e.id != null) _libraryHistoryEntryCache[e.id] = e;
|
||||
});
|
||||
list.innerHTML = data.entries.map(renderHistoryEntry).join('');
|
||||
renderHistoryPagination(data.total, page, limit);
|
||||
} catch (err) {
|
||||
|
|
@ -3217,6 +3225,16 @@ function renderHistoryEntry(entry) {
|
|||
const hasDetails = sourceDetail || acoustidBadge;
|
||||
const expandIndicator = hasDetails ? `<span class="lh-expand-btn">▾</span>` : '';
|
||||
|
||||
// Audit button — only for download events (import rows have no
|
||||
// source/match/verify decisions to trace). stopPropagation keeps
|
||||
// the click from triggering the row-expand toggle. Inline onclick
|
||||
// dispatches through `openDownloadAuditModalById` (a function so
|
||||
// the script-split-integrity test sees it) rather than touching
|
||||
// the cache directly.
|
||||
const auditBtn = entry.event_type === 'download' && entry.id != null
|
||||
? `<button class="lh-audit-btn" title="View audit trail" onclick="event.stopPropagation();openDownloadAuditModalById(${entry.id})">Audit</button>`
|
||||
: '';
|
||||
|
||||
return `<div class="library-history-entry${hasDetails ? ' lh-expandable' : ''}" ${hasDetails ? 'onclick="this.classList.toggle(\'lh-expanded\')"' : ''}>
|
||||
${thumb}
|
||||
<div class="library-history-entry-content">
|
||||
|
|
@ -3227,6 +3245,7 @@ function renderHistoryEntry(entry) {
|
|||
</div>
|
||||
<div class="library-history-entry-badges">${badge}</div>
|
||||
<div class="library-history-entry-time">${formatHistoryTime(entry.created_at)}</div>
|
||||
${auditBtn}
|
||||
${expandIndicator}
|
||||
</div>
|
||||
${hasDetails ? `<div class="library-history-entry-details">
|
||||
|
|
@ -3259,6 +3278,655 @@ function formatHistoryTime(isoStr) {
|
|||
} catch { return ''; }
|
||||
}
|
||||
|
||||
// ── Download Audit Trail Modal ──────────────────────────────────────
|
||||
//
|
||||
// First-pass implementation per spec: builds a "summary audit" from
|
||||
// fields already on the library_history row. Steps the backend doesn't
|
||||
// yet capture (per-source plan, per-candidate scoring, post-processing
|
||||
// substeps) render as 'Not captured yet' rather than fabricating
|
||||
// details — preserves trust until the backend event recorder lands.
|
||||
|
||||
let _downloadAuditEntry = null;
|
||||
let _downloadAuditActiveTab = 'lifecycle';
|
||||
let _downloadAuditActiveStep = 'request';
|
||||
|
||||
function openDownloadAuditModalById(historyId) {
|
||||
const entry = _libraryHistoryEntryCache[historyId];
|
||||
if (!entry) return;
|
||||
openDownloadAuditModal(entry);
|
||||
}
|
||||
|
||||
function openDownloadAuditModal(entry) {
|
||||
if (!entry) return;
|
||||
_downloadAuditEntry = entry;
|
||||
_downloadAuditActiveTab = 'lifecycle';
|
||||
_downloadAuditActiveStep = 'request';
|
||||
|
||||
const overlay = document.getElementById('download-audit-overlay');
|
||||
const hero = document.getElementById('download-audit-hero');
|
||||
const tabs = document.getElementById('download-audit-tabs');
|
||||
const body = document.getElementById('download-audit-body');
|
||||
if (!overlay || !hero || !tabs || !body) return;
|
||||
|
||||
hero.innerHTML = renderDownloadAuditHero(entry);
|
||||
tabs.innerHTML = renderDownloadAuditTabs(_downloadAuditActiveTab);
|
||||
body.innerHTML = renderDownloadAuditTabPanel(_downloadAuditActiveTab, entry);
|
||||
overlay.classList.remove('hidden');
|
||||
|
||||
// Live tag read for the Tags tab. Fire-and-forget on open so the
|
||||
// data is ready by the time the user switches to the Tags tab.
|
||||
if (entry.id != null) {
|
||||
fetchAndRenderEmbeddedTags(entry.id);
|
||||
}
|
||||
}
|
||||
|
||||
function switchAuditTab(tabName) {
|
||||
if (!_downloadAuditEntry) return;
|
||||
if (!['lifecycle', 'tags', 'lyrics'].includes(tabName)) return;
|
||||
_downloadAuditActiveTab = tabName;
|
||||
const tabs = document.getElementById('download-audit-tabs');
|
||||
const body = document.getElementById('download-audit-body');
|
||||
if (tabs) tabs.innerHTML = renderDownloadAuditTabs(tabName);
|
||||
if (body) body.innerHTML = renderDownloadAuditTabPanel(tabName, _downloadAuditEntry);
|
||||
// Re-fire the live fetch when switching to Tags (in case it
|
||||
// raced past first mount before the user got there).
|
||||
if (tabName === 'tags' && _downloadAuditEntry.id != null) {
|
||||
fetchAndRenderEmbeddedTags(_downloadAuditEntry.id);
|
||||
}
|
||||
if (tabName === 'lyrics' && _downloadAuditEntry.id != null) {
|
||||
fetchAndRenderEmbeddedTags(_downloadAuditEntry.id);
|
||||
}
|
||||
}
|
||||
window.switchAuditTab = switchAuditTab;
|
||||
|
||||
function selectAuditStep(stepKey) {
|
||||
if (!_downloadAuditEntry) return;
|
||||
_downloadAuditActiveStep = stepKey;
|
||||
const body = document.getElementById('download-audit-body');
|
||||
if (body) body.innerHTML = renderDownloadAuditTabPanel('lifecycle', _downloadAuditEntry);
|
||||
}
|
||||
window.selectAuditStep = selectAuditStep;
|
||||
|
||||
function closeDownloadAuditModal() {
|
||||
const overlay = document.getElementById('download-audit-overlay');
|
||||
if (overlay) overlay.classList.add('hidden');
|
||||
_downloadAuditEntry = null;
|
||||
}
|
||||
|
||||
function renderDownloadAuditHero(entry) {
|
||||
const title = entry.title || 'Unknown Track';
|
||||
const artist = entry.artist_name || 'Unknown Artist';
|
||||
const album = entry.album_name || '';
|
||||
const year = (entry.created_at || '').slice(0, 4); // fallback to history year
|
||||
// Album-art thumb from row when present; placeholder otherwise.
|
||||
const hasValidThumb = entry.thumb_url && (entry.thumb_url.startsWith('http://') || entry.thumb_url.startsWith('https://'));
|
||||
const art = hasValidThumb
|
||||
? `<img src="${escapeHtml(entry.thumb_url)}" class="download-audit-hero-art" loading="lazy">`
|
||||
: `<div class="download-audit-hero-art download-audit-hero-art-placeholder">♪</div>`;
|
||||
const metaParts = [];
|
||||
if (artist) metaParts.push(escapeHtml(artist));
|
||||
if (album) metaParts.push(escapeHtml(album));
|
||||
if (year && /^\d{4}$/.test(year)) metaParts.push(escapeHtml(year));
|
||||
const meta = metaParts.join(' · ');
|
||||
|
||||
const pills = [];
|
||||
if (entry.download_source) pills.push(_auditHeroPill('source', entry.download_source));
|
||||
if (entry.quality) pills.push(_auditHeroPill('quality', entry.quality));
|
||||
if (entry.acoustid_result) pills.push(_auditHeroPill('verify', formatAcoustidLabel(entry.acoustid_result), entry.acoustid_result));
|
||||
|
||||
return `
|
||||
${art}
|
||||
<div class="download-audit-hero-text">
|
||||
<div class="download-audit-hero-title">${escapeHtml(title)}</div>
|
||||
<div class="download-audit-hero-meta">${meta}</div>
|
||||
<div class="download-audit-hero-pills">${pills.join('')}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function _auditHeroPill(kind, label, statusHint) {
|
||||
const cls = statusHint ? ` download-audit-hero-pill-${escapeHtml(statusHint)}` : '';
|
||||
return `<span class="download-audit-hero-pill download-audit-hero-pill-${kind}${cls}">${escapeHtml(label)}</span>`;
|
||||
}
|
||||
|
||||
function renderDownloadAuditTabs(active) {
|
||||
const tabs = [
|
||||
{ key: 'lifecycle', label: 'Lifecycle' },
|
||||
{ key: 'tags', label: 'Tags' },
|
||||
{ key: 'lyrics', label: 'Lyrics' },
|
||||
];
|
||||
return tabs.map(t =>
|
||||
`<button class="download-audit-tab${t.key === active ? ' active' : ''}" onclick="switchAuditTab('${t.key}')">${t.label}</button>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
function renderDownloadAuditTabPanel(tabName, entry) {
|
||||
if (tabName === 'tags') return renderDownloadAuditTagsTab(entry);
|
||||
if (tabName === 'lyrics') return renderDownloadAuditLyricsTab(entry);
|
||||
return renderDownloadAuditLifecycleTab(entry);
|
||||
}
|
||||
|
||||
function renderDownloadAuditLifecycleTab(entry) {
|
||||
const steps = buildDownloadAuditSteps(entry);
|
||||
const activeStep = steps.find(s => s.key === _downloadAuditActiveStep) || steps[0];
|
||||
|
||||
// Horizontal stepper — compact nodes + short labels under each.
|
||||
const stepperNodes = steps.map((step, i) => {
|
||||
const isActive = step.key === activeStep.key;
|
||||
const icon = _auditNodeIcon(step.status || 'unknown');
|
||||
const connector = i < steps.length - 1 ? '<span class="download-audit-stepper-line"></span>' : '';
|
||||
return `<button class="download-audit-stepper-node download-audit-stepper-${step.status || 'unknown'}${isActive ? ' active' : ''}" onclick="selectAuditStep('${step.key}')">
|
||||
<span class="download-audit-stepper-circle">${icon}</span>
|
||||
<span class="download-audit-stepper-label">${escapeHtml(_auditStepShortLabel(step.key))}</span>
|
||||
</button>${connector}`;
|
||||
}).join('');
|
||||
|
||||
// Detail card for the selected step.
|
||||
const detail = activeStep.detail ? `<div class="download-audit-step-detail">${escapeHtml(activeStep.detail)}</div>` : '';
|
||||
const meta = (activeStep.meta && activeStep.meta.length > 0)
|
||||
? `<div class="download-audit-step-meta">${activeStep.meta.map(m => `<div>${escapeHtml(String(m))}</div>`).join('')}</div>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="download-audit-stepper">${stepperNodes}</div>
|
||||
<div class="download-audit-step-card download-audit-step-card-${activeStep.status || 'unknown'}">
|
||||
<div class="download-audit-step-card-title">${escapeHtml(activeStep.title || 'Step')}</div>
|
||||
${detail}
|
||||
${meta}
|
||||
</div>
|
||||
<div class="download-audit-final-path">
|
||||
<span class="download-audit-final-path-label">Final path</span>
|
||||
<code>${escapeHtml(entry.file_path || 'Not captured yet')}</code>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function _auditStepShortLabel(key) {
|
||||
return ({
|
||||
request: 'Request',
|
||||
source: 'Source',
|
||||
match: 'Match',
|
||||
verify: 'Verify',
|
||||
process: 'Process',
|
||||
transfer: 'Place',
|
||||
})[key] || key;
|
||||
}
|
||||
|
||||
function renderDownloadAuditTagsTab(entry) {
|
||||
// Live tags get filled in by fetchAndRenderEmbeddedTags via the
|
||||
// slot id below. Until the fetch resolves, show a loading state
|
||||
// so the panel isn't blank.
|
||||
return `<div class="download-audit-tags-body" id="download-audit-tags-body">
|
||||
<div class="download-audit-tags-empty">Reading from file…</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderDownloadAuditLyricsTab(entry) {
|
||||
return `<div class="download-audit-lyrics-body" id="download-audit-lyrics-body">
|
||||
<div class="download-audit-tags-empty">Reading from file…</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderEmbeddedTagsSection(entry) {
|
||||
// Legacy entry point retained for any other caller — now unused
|
||||
// by the modal directly (Tags tab owns the live render).
|
||||
return renderDownloadAuditTagsTab(entry);
|
||||
}
|
||||
|
||||
async function fetchAndRenderEmbeddedTags(historyId) {
|
||||
const tagsSlot = document.getElementById('download-audit-tags-body');
|
||||
const lyricsSlot = document.getElementById('download-audit-lyrics-body');
|
||||
if (!tagsSlot && !lyricsSlot) return;
|
||||
const renderError = (msg) => {
|
||||
const html = `<div class="download-audit-tags-empty">${escapeHtml(msg)}</div>`;
|
||||
if (tagsSlot) tagsSlot.innerHTML = html;
|
||||
if (lyricsSlot) lyricsSlot.innerHTML = html;
|
||||
};
|
||||
try {
|
||||
const resp = await fetch(`/api/library/history/${historyId}/file-tags`);
|
||||
if (!resp.ok) { renderError(`Could not read file tags (HTTP ${resp.status}).`); return; }
|
||||
const data = await resp.json();
|
||||
if (!data.success) { renderError(data.error || 'Could not read file tags.'); return; }
|
||||
if (data.available === false) { renderError(data.reason || 'File tags not available.'); return; }
|
||||
if (tagsSlot) tagsSlot.innerHTML = _renderEmbeddedTagsGrid(data);
|
||||
if (lyricsSlot) lyricsSlot.innerHTML = _renderLyricsBody(data);
|
||||
} catch (e) {
|
||||
renderError(`Could not read file tags: ${String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function _renderLyricsBody(data) {
|
||||
const tags = data.tags || {};
|
||||
const text = (tags.lyrics || tags.unsyncedlyrics || '').toString();
|
||||
if (!text.trim()) {
|
||||
return `<div class="download-audit-tags-empty">No lyrics embedded in this file.</div>`;
|
||||
}
|
||||
// Highlight the timecodes at the start of each line in dim color
|
||||
// so the body reads like a clean transcript. Pure CSS via a span
|
||||
// wrapper around each match.
|
||||
const html = escapeHtml(text)
|
||||
.replace(/^(\[\d{1,2}:\d{2}(?:\.\d{1,3})?\])/gm, '<span class="download-audit-lyric-timecode">$1</span>')
|
||||
.replace(/\n/g, '<br>');
|
||||
return `<div class="download-audit-lyrics-text">${html}</div>`;
|
||||
}
|
||||
|
||||
// Friendly labels for tag keys (lowercased canonical key →
|
||||
// human-readable). Keys not in the map render with title-case
|
||||
// fallback applied at render time.
|
||||
const _AUDIT_TAG_LABELS = {
|
||||
title: 'Title',
|
||||
artist: 'Artist',
|
||||
artists: 'All Artists',
|
||||
albumartist: 'Album Artist',
|
||||
album_artist: 'Album Artist',
|
||||
album: 'Album',
|
||||
date: 'Date',
|
||||
year: 'Year',
|
||||
originaldate: 'Original Date',
|
||||
genre: 'Genre',
|
||||
mood: 'Mood',
|
||||
style: 'Style',
|
||||
tracknumber: 'Track #',
|
||||
tracktotal: 'Total Tracks',
|
||||
discnumber: 'Disc #',
|
||||
totaldiscs: 'Total Discs',
|
||||
bpm: 'BPM',
|
||||
isrc: 'ISRC',
|
||||
barcode: 'Barcode',
|
||||
catalognumber: 'Catalog #',
|
||||
asin: 'ASIN',
|
||||
copyright: 'Copyright',
|
||||
publisher: 'Publisher',
|
||||
language: 'Language',
|
||||
script: 'Script',
|
||||
media: 'Media',
|
||||
releasetype: 'Release Type',
|
||||
releasestatus: 'Release Status',
|
||||
releasecountry: 'Country',
|
||||
composer: 'Composer',
|
||||
performer: 'Performer',
|
||||
quality: 'Quality',
|
||||
replaygain_track_gain: 'Track Gain',
|
||||
replaygain_track_peak: 'Track Peak',
|
||||
replaygain_album_gain: 'Album Gain',
|
||||
replaygain_album_peak: 'Album Peak',
|
||||
};
|
||||
|
||||
// Source-ID services. Each service has a key-prefix matcher and a
|
||||
// per-key label map. Order matters for display (MusicBrainz first
|
||||
// since it's the canonical identity layer).
|
||||
const _AUDIT_SOURCE_SERVICES = [
|
||||
{
|
||||
name: 'MusicBrainz',
|
||||
prefix: 'musicbrainz_',
|
||||
labels: {
|
||||
musicbrainz_trackid: 'Track',
|
||||
musicbrainz_releasetrackid: 'Recording',
|
||||
musicbrainz_albumid: 'Album',
|
||||
musicbrainz_artistid: 'Artist',
|
||||
musicbrainz_albumartistid: 'Album Artist',
|
||||
musicbrainz_releasegroupid: 'Release Group',
|
||||
},
|
||||
},
|
||||
{ name: 'Spotify', prefix: 'spotify_', labels: { spotify_track_id: 'Track', spotify_artist_id: 'Artist', spotify_album_id: 'Album' } },
|
||||
{ name: 'Tidal', prefix: 'tidal_', labels: { tidal_track_id: 'Track', tidal_artist_id: 'Artist', tidal_album_id: 'Album' } },
|
||||
{ name: 'Deezer', prefix: 'deezer_', labels: { deezer_track_id: 'Track', deezer_artist_id: 'Artist', deezer_album_id: 'Album' } },
|
||||
{ name: 'AudioDB', prefix: 'audiodb_', labels: { audiodb_track_id: 'Track', audiodb_artist_id: 'Artist', audiodb_album_id: 'Album' } },
|
||||
{ name: 'iTunes', prefix: 'itunes_', labels: { itunes_track_id: 'Track', itunes_artist_id: 'Artist', itunes_album_id: 'Album' } },
|
||||
{ name: 'Genius', prefix: 'genius_', labels: { genius_track_id: 'Track', genius_url: 'URL' } },
|
||||
{ name: 'Last.fm', prefix: 'lastfm_', labels: { lastfm_url: 'URL' } },
|
||||
{ name: 'Beatport', prefix: 'beatport_', labels: { beatport_track_id: 'Track' } },
|
||||
];
|
||||
|
||||
function _auditFriendlyLabel(key, fallbackToTitleCase = true) {
|
||||
if (_AUDIT_TAG_LABELS[key]) return _AUDIT_TAG_LABELS[key];
|
||||
if (!fallbackToTitleCase) return key;
|
||||
// Snake-case → Title Case
|
||||
return key.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
||||
}
|
||||
|
||||
function _renderEmbeddedTagsGrid(data) {
|
||||
const tags = data.tags || {};
|
||||
const fmt = data.format || '';
|
||||
const bitrate = data.bitrate || 0;
|
||||
const duration = data.duration || 0;
|
||||
const hasPicture = data.has_picture === true;
|
||||
|
||||
// Categorize keys. Anything not matched and not a Source ID
|
||||
// falls under "Other" — but we drop "Other" entirely when its
|
||||
// only contents are keys already shown elsewhere (like `quality`,
|
||||
// which is already a chip on the modal status strip).
|
||||
const TRACK_KEYS = ['title', 'artist', 'artists', 'tracknumber', 'tracktotal', 'discnumber', 'totaldiscs', 'bpm', 'isrc'];
|
||||
const ALBUM_KEYS = ['album', 'album_artist', 'albumartist', 'date', 'year', 'originaldate', 'genre', 'mood', 'style', 'copyright', 'publisher', 'language', 'script', 'media', 'releasetype', 'releasestatus', 'releasecountry', 'barcode', 'catalognumber', 'asin'];
|
||||
const REPLAYGAIN_KEYS = ['replaygain_track_gain', 'replaygain_track_peak', 'replaygain_album_gain', 'replaygain_album_peak'];
|
||||
const LYRICS_KEYS = ['lyrics', 'unsyncedlyrics'];
|
||||
// Duplicates of top-bar chips that should NOT appear in the
|
||||
// "Other" bucket (already visible elsewhere in the modal).
|
||||
const DUPLICATE_KEYS = new Set(['quality']);
|
||||
const isSourceKey = (k) => /(_id|_url)$/.test(k) || k.startsWith('musicbrainz_');
|
||||
|
||||
const buckets = { track: [], album: [], source: {}, replaygain: [], lyrics: [], other: [] };
|
||||
Object.keys(tags).sort().forEach(key => {
|
||||
const val = tags[key];
|
||||
if (!val) return;
|
||||
if (DUPLICATE_KEYS.has(key)) return;
|
||||
if (TRACK_KEYS.includes(key)) buckets.track.push([key, val]);
|
||||
else if (ALBUM_KEYS.includes(key)) buckets.album.push([key, val]);
|
||||
else if (REPLAYGAIN_KEYS.includes(key)) buckets.replaygain.push([key, val]);
|
||||
else if (LYRICS_KEYS.includes(key)) buckets.lyrics.push([key, val]);
|
||||
else if (isSourceKey(key)) {
|
||||
// Slot into the matching service. Unmatched IDs land in
|
||||
// an "Other Sources" bucket.
|
||||
const svc = _AUDIT_SOURCE_SERVICES.find(s => key.startsWith(s.prefix));
|
||||
const slot = svc ? svc.name : 'Other Sources';
|
||||
if (!buckets.source[slot]) buckets.source[slot] = [];
|
||||
buckets.source[slot].push([key, val, svc]);
|
||||
} else {
|
||||
buckets.other.push([key, val]);
|
||||
}
|
||||
});
|
||||
|
||||
// Horizontal file-info chip strip at the top.
|
||||
const fileChips = [];
|
||||
if (fmt) fileChips.push(`<span class="download-audit-chip">${escapeHtml(fmt)}</span>`);
|
||||
if (bitrate) fileChips.push(`<span class="download-audit-chip">${Math.round(bitrate / 1000)} kbps</span>`);
|
||||
if (duration > 0) fileChips.push(`<span class="download-audit-chip">${_formatDuration(duration)}</span>`);
|
||||
fileChips.push(`<span class="download-audit-chip">Cover ${hasPicture ? '✓' : '—'}</span>`);
|
||||
const fileStrip = `<div class="download-audit-tags-chips">${fileChips.join('')}</div>`;
|
||||
|
||||
// Pretty key-value rows using friendly labels.
|
||||
const renderRow = ([key, val], labelOverride) => _auditTagRow(
|
||||
labelOverride || _auditFriendlyLabel(key),
|
||||
val,
|
||||
);
|
||||
|
||||
const sections = [];
|
||||
if (buckets.track.length > 0) {
|
||||
sections.push(_renderTagGroup('Track', buckets.track.map(p => renderRow(p)).join('')));
|
||||
}
|
||||
if (buckets.album.length > 0) {
|
||||
sections.push(_renderTagGroup('Album', buckets.album.map(p => renderRow(p)).join('')));
|
||||
}
|
||||
if (buckets.replaygain.length > 0) {
|
||||
sections.push(_renderTagGroup('ReplayGain', buckets.replaygain.map(p => renderRow(p)).join('')));
|
||||
}
|
||||
if (buckets.other.length > 0) {
|
||||
sections.push(_renderTagGroup('Other', buckets.other.map(p => renderRow(p)).join('')));
|
||||
}
|
||||
|
||||
// Source IDs section — sub-card per service so the user can
|
||||
// scan instead of reading a 14-row dump.
|
||||
let sourcesBlock = '';
|
||||
const sourceServiceNames = Object.keys(buckets.source);
|
||||
if (sourceServiceNames.length > 0) {
|
||||
const orderedNames = _AUDIT_SOURCE_SERVICES
|
||||
.map(s => s.name)
|
||||
.filter(n => buckets.source[n])
|
||||
.concat(sourceServiceNames.filter(n => !_AUDIT_SOURCE_SERVICES.find(s => s.name === n)));
|
||||
const subCards = orderedNames.map(name => {
|
||||
const rows = buckets.source[name].map(([key, val, svc]) => {
|
||||
const label = svc?.labels[key] || _auditFriendlyLabel(key.replace(/^[a-z]+_/, ''));
|
||||
return _auditTagRow(label, val);
|
||||
});
|
||||
return `<div class="download-audit-source-card">
|
||||
<div class="download-audit-source-card-title">${escapeHtml(name)}</div>
|
||||
${rows.join('')}
|
||||
</div>`;
|
||||
});
|
||||
sourcesBlock = `<div class="download-audit-tag-group download-audit-sources">
|
||||
<div class="download-audit-tag-group-title">Source IDs</div>
|
||||
<div class="download-audit-source-grid">${subCards.join('')}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Lyrics live in their own tab now — don't render them inline.
|
||||
|
||||
if (sections.length === 0 && !sourcesBlock && fileChips.length === 0) {
|
||||
return `<div class="download-audit-tags-empty">No tags were found on this file.</div>`;
|
||||
}
|
||||
|
||||
return `${fileStrip}
|
||||
<div class="download-audit-tags-grid">${sections.join('')}</div>
|
||||
${sourcesBlock}`;
|
||||
}
|
||||
|
||||
function _renderTagGroup(title, rowsHtml) {
|
||||
return `<div class="download-audit-tag-group">
|
||||
<div class="download-audit-tag-group-title">${escapeHtml(title)}</div>
|
||||
${rowsHtml}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _formatDuration(seconds) {
|
||||
const s = Math.round(seconds || 0);
|
||||
const m = Math.floor(s / 60);
|
||||
const rem = s % 60;
|
||||
return `${m}:${rem.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function _auditTagRow(label, value) {
|
||||
return `<div class="download-audit-tag-row">
|
||||
<span class="download-audit-tag-key">${escapeHtml(label)}</span>
|
||||
<span class="download-audit-tag-value">${escapeHtml(String(value))}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderAuditChip(label) {
|
||||
return `<span class="download-audit-chip">${escapeHtml(label)}</span>`;
|
||||
}
|
||||
|
||||
function renderAuditStep(step, index, total) {
|
||||
const status = step.status || 'unknown';
|
||||
const detail = step.detail ? `<div class="download-audit-card-detail">${escapeHtml(step.detail)}</div>` : '';
|
||||
const meta = (step.meta && step.meta.length > 0)
|
||||
? `<div class="download-audit-card-meta">${step.meta.map(m => `<div>${escapeHtml(String(m))}</div>`).join('')}</div>`
|
||||
: '';
|
||||
const nodeIcon = _auditNodeIcon(status);
|
||||
return `
|
||||
<div class="download-audit-step ${escapeHtml(status)}">
|
||||
<div class="download-audit-node">${nodeIcon}</div>
|
||||
<div class="download-audit-card">
|
||||
<div class="download-audit-card-title">${escapeHtml(step.title || 'Step')}</div>
|
||||
${detail}
|
||||
${meta}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function _auditNodeIcon(status) {
|
||||
switch (status) {
|
||||
case 'complete': return '✓'; // ✓
|
||||
case 'partial': return '○'; // ○ (open circle = partial signal)
|
||||
case 'error': return '✕'; // ✕
|
||||
default: return '—'; // — (unknown / not captured)
|
||||
}
|
||||
}
|
||||
|
||||
function buildDownloadAuditExplanation(entry) {
|
||||
const source = entry.download_source || 'an unknown source';
|
||||
const quality = entry.quality ? ` at ${entry.quality}` : '';
|
||||
const trackPart = entry.title ? `"${entry.title}"` : 'this track';
|
||||
const artistPart = entry.artist_name ? ` by ${entry.artist_name}` : '';
|
||||
return `SoulSync downloaded ${trackPart}${artistPart} from ${source}${quality}. Detailed source-by-source decisions, candidate scoring, and post-processing steps are not captured yet — older entries show a summary built from the recorded history fields.`;
|
||||
}
|
||||
|
||||
function buildDownloadAuditSteps(entry) {
|
||||
return [
|
||||
{
|
||||
key: 'request',
|
||||
title: 'Request Created',
|
||||
status: 'complete',
|
||||
detail: `${entry.title || 'Unknown track'}${entry.artist_name ? ` by ${entry.artist_name}` : ''}`,
|
||||
meta: entry.album_name ? [`Album: ${entry.album_name}`] : [],
|
||||
},
|
||||
{
|
||||
key: 'source',
|
||||
title: 'Source Selected',
|
||||
status: entry.download_source ? 'complete' : 'unknown',
|
||||
detail: entry.download_source
|
||||
? `Downloaded via ${entry.download_source}`
|
||||
: 'Download source was not captured.',
|
||||
meta: entry.quality ? [`Quality: ${entry.quality}`] : [],
|
||||
},
|
||||
{
|
||||
key: 'match',
|
||||
title: 'Source Match',
|
||||
status: (entry.source_track_title || entry.source_filename) ? 'complete' : 'partial',
|
||||
detail: buildSourceMatchDetail(entry),
|
||||
meta: buildSourceMatchMeta(entry),
|
||||
},
|
||||
{
|
||||
key: 'verify',
|
||||
title: 'Verification',
|
||||
status: auditStatusFromAcoustid(entry.acoustid_result),
|
||||
detail: buildAcoustidDetail(entry.acoustid_result),
|
||||
meta: [],
|
||||
},
|
||||
(() => {
|
||||
const inferences = inferPostProcessingDetails(entry);
|
||||
// Library_history rows are written AFTER post-processing
|
||||
// finishes, so post-processing is provably DONE — the only
|
||||
// question is which specific steps we can observe from
|
||||
// before/after state. When any inference fires, status =
|
||||
// complete and we list the observed changes. Otherwise
|
||||
// partial — file landed (final placement complete) but
|
||||
// source vs final state look identical, so we can't claim
|
||||
// any specific step ran.
|
||||
return {
|
||||
key: 'process',
|
||||
title: 'Post Processing',
|
||||
status: inferences.length > 0 ? 'complete' : 'partial',
|
||||
detail: inferences.length > 0
|
||||
? 'Observed changes between source and final state:'
|
||||
: 'No observable changes between source and final state.',
|
||||
meta: inferences,
|
||||
};
|
||||
})(),
|
||||
{
|
||||
key: 'transfer',
|
||||
title: 'Final Placement',
|
||||
status: entry.file_path ? 'complete' : 'unknown',
|
||||
detail: entry.file_path
|
||||
? 'File was finalized and recorded in library history.'
|
||||
: 'Final path not captured.',
|
||||
meta: entry.file_path ? [entry.file_path] : [],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function inferPostProcessingDetails(entry) {
|
||||
// Diff observable fields on the history row to surface what post-
|
||||
// processing demonstrably did. No fabrication — every bullet here
|
||||
// is provable from the before/after state recorded on the row.
|
||||
// ReplayGain / lyrics / per-field tag substitutions have no field
|
||||
// we can observe, so they don't appear here at all.
|
||||
const out = [];
|
||||
const src = entry.source_filename || '';
|
||||
const dst = entry.file_path || '';
|
||||
|
||||
const srcExt = _auditExtractExt(src);
|
||||
const dstExt = _auditExtractExt(dst);
|
||||
if (srcExt && dstExt && srcExt !== dstExt) {
|
||||
out.push(`Format conversion: ${srcExt} → ${dstExt}`);
|
||||
}
|
||||
|
||||
const srcBase = _auditBasename(src);
|
||||
const dstBase = _auditBasename(dst);
|
||||
if (srcBase && dstBase && srcBase !== dstBase) {
|
||||
out.push(`File renamed via tag template: ${srcBase} → ${dstBase}`);
|
||||
}
|
||||
|
||||
if (entry.source_track_title && entry.title
|
||||
&& entry.source_track_title.trim().toLowerCase() !== entry.title.trim().toLowerCase()) {
|
||||
out.push(`Title rewritten from source tags: "${entry.source_track_title}" → "${entry.title}"`);
|
||||
}
|
||||
|
||||
if (entry.source_artist && entry.artist_name
|
||||
&& entry.source_artist.trim().toLowerCase() !== entry.artist_name.trim().toLowerCase()) {
|
||||
out.push(`Artist rewritten from source tags: "${entry.source_artist}" → "${entry.artist_name}"`);
|
||||
}
|
||||
|
||||
// Folder template inference: counts non-empty path segments above
|
||||
// the filename. A flat `/downloads/file.mp3` (1 dir segment) means
|
||||
// no template ran; `/library/Artist/[Year] Album/01 - Title.mp3`
|
||||
// (3+ segments) means a multi-level template was applied.
|
||||
if (dst) {
|
||||
const normalized = dst.replace(/\\/g, '/');
|
||||
const segments = normalized.split('/').filter(s => s.length > 0);
|
||||
// Last segment is the file; count directory segments above it.
|
||||
if (segments.length >= 4) {
|
||||
out.push('Folder template applied (multi-level path)');
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function _auditExtractExt(path) {
|
||||
if (!path) return '';
|
||||
const lastDot = path.lastIndexOf('.');
|
||||
const lastSlash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'));
|
||||
if (lastDot <= lastSlash || lastDot === -1) return '';
|
||||
return path.substring(lastDot).toLowerCase();
|
||||
}
|
||||
|
||||
function _auditBasename(path) {
|
||||
if (!path) return '';
|
||||
const normalized = path.replace(/\\/g, '/');
|
||||
const slash = normalized.lastIndexOf('/');
|
||||
return slash === -1 ? normalized : normalized.substring(slash + 1);
|
||||
}
|
||||
|
||||
function buildSourceMatchDetail(entry) {
|
||||
const srcTitle = entry.source_track_title || '';
|
||||
const srcArtist = entry.source_artist || '';
|
||||
if (srcTitle || srcArtist) {
|
||||
return `Matched to ${srcTitle || '?'} by ${srcArtist || '?'}`;
|
||||
}
|
||||
if (entry.source_filename) {
|
||||
return `Matched to ${entry.source_filename}`;
|
||||
}
|
||||
return 'Source-side match details were not captured.';
|
||||
}
|
||||
|
||||
function buildSourceMatchMeta(entry) {
|
||||
const out = [];
|
||||
if (entry.source_filename) out.push(`File: ${entry.source_filename}`);
|
||||
if (entry.source_track_id) out.push(`Source ID: ${entry.source_track_id}`);
|
||||
return out;
|
||||
}
|
||||
|
||||
function auditStatusFromAcoustid(result) {
|
||||
if (!result) return 'unknown';
|
||||
if (result === 'pass') return 'complete';
|
||||
if (result === 'fail' || result === 'error') return 'error';
|
||||
return 'partial'; // skip / disabled / anything else
|
||||
}
|
||||
|
||||
function buildAcoustidDetail(result) {
|
||||
if (!result) return 'AcoustID verification was not captured for this download.';
|
||||
const labels = {
|
||||
pass: 'AcoustID fingerprint matched the expected track.',
|
||||
fail: 'AcoustID fingerprint did NOT match — track was flagged.',
|
||||
skip: 'AcoustID verification was skipped for this download.',
|
||||
disabled: 'AcoustID verification is disabled in settings.',
|
||||
error: 'AcoustID verification errored out.',
|
||||
};
|
||||
return labels[result] || `AcoustID result: ${result}`;
|
||||
}
|
||||
|
||||
function formatAcoustidLabel(result) {
|
||||
const labels = { pass: 'AcoustID Verified', fail: 'AcoustID Failed', skip: 'AcoustID Skipped', disabled: 'AcoustID Off', error: 'AcoustID Error' };
|
||||
return labels[result] || `AcoustID: ${result}`;
|
||||
}
|
||||
|
||||
// Expose for onclick="" handlers wired in renderHistoryEntry.
|
||||
window.openDownloadAuditModalById = openDownloadAuditModalById;
|
||||
window.openDownloadAuditModal = openDownloadAuditModal;
|
||||
window.closeDownloadAuditModal = closeDownloadAuditModal;
|
||||
|
||||
|
||||
function renderHistoryPagination(total, page, limit) {
|
||||
const pagination = document.getElementById('library-history-pagination');
|
||||
if (!pagination) return;
|
||||
|
|
|
|||
Loading…
Reference in a new issue