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).
pull/573/head
Broque Thomas 3 days ago
parent 253c7676d6
commit 6ce185491d

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

@ -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">&times;</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">&#x25BE;</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 '&#x2713;'; // ✓
case 'partial': return '&#x25CB;'; // ○ (open circle = partial signal)
case 'error': return '&#x2715;'; // ✕
default: return '&#x2014;'; // — (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…
Cancel
Save