Reorganize: optional embedded-tag mode (closes #592)

Adds an opt-in alternative metadata source for reorganize. The
existing API path (query Spotify / iTunes / Deezer / Discogs /
Hydrabase for the canonical tracklist) stays the default and is
unchanged. The new tag mode reads each file's embedded tags as the
source of truth instead -- useful for well-enriched libraries where
API drift can produce inconsistent renames, and avoids API calls
entirely.

- New pure helper `core/library/reorganize_tag_source.py` adapts the
  output of `read_embedded_tags` (the same mutagen path the audit-
  trail modal uses) to the `api_album` / `api_track` shapes that
  `_build_post_process_context` already consumes. Handles ID3-style
  "5/12" track + disc shapes, multi-value Artists tags, year
  normalization across 5 date formats, releasetype canonical tokens,
  multi-artist string splits across 9 separators.
- `plan_album_reorganize` accepts `metadata_source: 'api' | 'tags'`
  (default 'api') and `resolve_file_path_fn`. Tag mode branches into
  a new `_plan_from_tags` that reads each track's file and produces
  per-item `api_album` + `api_track` instead of a shared one.
- `_run_post_process_for_track` accepts a per-item `api_album`
  override so each file's own album metadata flows through post-
  process (not a single shared dict).
- `total_discs` in tag mode honors the `totaldiscs` tag and the
  trailing `/N` of an ID3 `discnumber = "1/2"`. Partial-album
  reorganize still routes into the correct `Disc N/` subfolder when
  the tag knows the total even if not all discs are present locally.
- Bare `discnumber = "1"` no longer poisons `total_discs` -- it
  carries no total signal.
- `reorganize_album` surfaces a tag-mode-specific error when no
  files are readable, instead of the API-mode "run enrichment first"
  message which would mislead in tag mode.
- `QueueItem.metadata_source` field, `enqueue` / `enqueue_many`
  pass-through, runner injects `item.metadata_source` into
  `reorganize_album`.
- `web_server.py` endpoints accept `mode` body param. Falls back to
  the `library.reorganize_metadata_source` config setting, then to
  'api'. Strict allowlist (api / tags) -- anything else falls back.
- Frontend: per-album modal + reorganize-all modal both grow a new
  "Metadata Mode" dropdown above the source picker. Tag mode hides
  the source picker (irrelevant). Choice persisted in localStorage.
  Both preview + execute fetches send `mode` in body.

Tests:
- 49 boundary tests on the pure helper pin every shape: ID3 "5/12",
  multi-artist split, year normalization, releasetype validation,
  total_discs precedence, defensive paths.
- 6 planner-level integration tests pin the wiring: tag-mode with
  good tags, partial-disc with totaldiscs tag, file missing,
  some-match-some-fail, defensive resolve_file_path_fn=None,
  API-mode regression guard.
- All 3171 tests pass; 52 existing reorganize tests unchanged.
This commit is contained in:
Broque Thomas 2026-05-15 07:56:18 -07:00
parent 1f6d439c13
commit b05ba5d498
8 changed files with 1203 additions and 19 deletions

View file

@ -0,0 +1,327 @@
"""Build reorganize-planning metadata from a file's embedded tags
instead of from a live metadata-source API call.
Issue #592 (tacobell444): when a library has been carefully enriched
+ tagged, doing a fresh API lookup at reorganize time can introduce
inconsistencies (provider naming drift, version-mismatches, missing
album-level metadata for niche releases). The user's own embedded
tags are usually the most stable source of truth for an enriched
library and using them costs zero API calls.
This module is the pure tag-to-context adapter. It turns the dict
that ``core.library.file_tags.read_embedded_tags`` returns into the
``api_album`` / ``api_track`` shapes that
``library_reorganize._build_post_process_context`` already consumes.
That keeps the downstream pipeline path-builder, post-process
helpers, AcoustID, etc.) completely unchanged: tag-mode just produces
the same input shape via a different upstream route.
Pure helpers no IO inside the extractors so every shape is
test-pinnable. The wrapper :func:`read_album_track_from_file` does
the file IO via ``read_embedded_tags`` and then routes through the
extractors.
Returns ``None`` (extractors) / ``(None, None, reason)`` (wrapper)
when the embedded tags are missing fields essential for reorganize
(track title, album name, or track artist). The plan layer surfaces
that as an unmatched item with a clear reason same UX as when the
metadata-API call returns no candidate. No silent degradation."""
from __future__ import annotations
import os
import re
from typing import Any, Dict, List, Optional, Tuple
# Tokens we accept as valid `releasetype` / `albumtype` values.
# Mirrors the canonical set the rest of the metadata pipeline uses
# (`core/metadata/album_tracks.py:_normalize_album_type`).
_VALID_ALBUM_TYPES = frozenset({'album', 'single', 'ep', 'compilation'})
# Match a 4-digit year anywhere in a date-like string ("2020",
# "2020-01-15", "2020/01/15", "Jan 5, 2020", etc.).
_YEAR_RE = re.compile(r'(\d{4})')
# Separators we split a single artist field on to recover a list.
# Mirrors the same separator set ``core/metadata/artist_resolution.py``
# uses when normalizing soulseek matched-download artist strings.
_ARTIST_SPLIT_RE = re.compile(
r'\s*(?:,|;|/|&| feat\. | feat | ft\. | ft | featuring | x | with )\s*',
re.IGNORECASE,
)
def _stringify(value: Any) -> str:
"""Coerce an embedded-tag value into a clean string."""
if value is None:
return ''
return str(value).strip()
def _parse_int_first(value: Any) -> Optional[int]:
"""Parse a track/disc number that may arrive as ``"5"``, ``"5/12"``,
``5``, ``5.0`` or even ``"05"``. Returns the leading integer, or
``None`` when no integer is recoverable.
Defensive against the trailing-``/N`` shape ID3 stores: ``TRCK =
"5/12"`` means "track 5 of 12", and we want ``5``."""
if value is None:
return None
if isinstance(value, (int,)):
return value
if isinstance(value, float):
return int(value)
s = _stringify(value)
if not s:
return None
head = s.split('/', 1)[0].strip()
try:
return int(head)
except (TypeError, ValueError):
try:
return int(float(head))
except (TypeError, ValueError):
return None
def _parse_int_total(value: Any) -> Optional[int]:
"""Parse the trailing ``N`` of an ID3-style ``"5/12"`` value, or
return the parsed value when it's a plain integer string."""
if value is None:
return None
if isinstance(value, int):
return value
s = _stringify(value)
if not s:
return None
if '/' in s:
tail = s.split('/', 1)[1].strip()
try:
return int(tail)
except (TypeError, ValueError):
return None
try:
return int(s)
except (TypeError, ValueError):
return None
def _normalize_year(value: Any) -> str:
"""Extract a 4-digit year from a date-like field. Returns '' when
no year is extractable. Reorganize templates only use the year
portion of release dates, so we don't need to preserve the full
date string."""
s = _stringify(value)
if not s:
return ''
m = _YEAR_RE.search(s)
return m.group(1) if m else ''
def _normalize_album_type(value: Any) -> str:
"""Lowercase + validate the ``releasetype`` tag against the canonical
token set. Returns '' for unknown values so the downstream path
builder falls back to its default."""
s = _stringify(value).lower()
if s in _VALID_ALBUM_TYPES:
return s
return ''
def _split_artists(value: Any) -> List[str]:
"""Split an artist-string field into a list. Handles common
separators (``,``, ``;``, ``/``, ``&``, ``feat``, ``ft``, ``x``,
``with``). Strips whitespace, drops empties, dedupes (case-
insensitive) while preserving order."""
s = _stringify(value)
if not s:
return []
parts = _ARTIST_SPLIT_RE.split(s)
seen: set = set()
out: List[str] = []
for p in parts:
cleaned = p.strip()
if not cleaned:
continue
key = cleaned.lower()
if key in seen:
continue
seen.add(key)
out.append(cleaned)
return out
def _resolve_track_artists(tags: Dict[str, Any]) -> List[str]:
"""Resolve the per-track artist list from embedded tags. Prefers a
multi-value ``artists`` tag (TXXX:Artists / Vorbis ``artists``)
over splitting the single-string ``artist`` tag, which is exactly
the precedence the post-download enrichment uses."""
artists_value = tags.get('artists')
if artists_value:
# Multi-value tag readers may already have joined with ', '.
# Re-split to recover the list.
parts = _split_artists(artists_value)
if parts:
return parts
return _split_artists(tags.get('artist') or '')
def extract_track_meta_from_tags(tags: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Build an ``api_track``-shaped dict from embedded tags.
Returns ``None`` if essential fields are missing (title or
artist). Caller surfaces that as an unmatched plan item.
Output shape matches what ``library_reorganize._build_post_process_context``
consumes (``name`` / ``track_number`` / ``disc_number`` /
``artists`` / ``duration_ms`` / ``id``)."""
if not isinstance(tags, dict) or not tags:
return None
title = _stringify(tags.get('title'))
if not title:
return None
artists = _resolve_track_artists(tags)
if not artists:
return None
track_number = _parse_int_first(tags.get('tracknumber')) or 1
disc_number = _parse_int_first(tags.get('discnumber')) or 1
return {
'name': title,
'title': title, # belt-and-braces — both keys are read downstream
'track_number': track_number,
'disc_number': disc_number,
'artists': [{'name': a} for a in artists],
'duration_ms': 0, # not derivable from tags alone; set later from `duration`
'id': '', # tag-mode has no source ID; reorganize doesn't need one
'uri': '',
}
def extract_album_meta_from_tags(tags: Dict[str, Any]) -> Dict[str, Any]:
"""Build an ``api_album``-shaped dict from embedded tags.
Falls back to empty / zero values when fields are missing the
path builder accepts those and uses its own defaults. The album
name is the only field we can't fall back on; if missing the
caller should treat the track as unmatched (handled by
:func:`read_album_track_from_file`)."""
if not isinstance(tags, dict):
tags = {}
album_name = _stringify(tags.get('album'))
album_artist = _stringify(tags.get('albumartist') or tags.get('album_artist'))
release_date = _normalize_year(tags.get('date') or tags.get('year') or tags.get('originaldate'))
total_tracks = (
_parse_int_total(tags.get('totaltracks'))
or _parse_int_total(tags.get('tracktotal'))
or _parse_int_total(tags.get('tracknumber')) # may be "5/12"
or 0
)
album_type = _normalize_album_type(tags.get('releasetype'))
# `total_discs` only comes from explicit total signals: a
# `totaldiscs` tag, or the trailing `/N` of an ID3-style
# `discnumber = "1/2"`. A bare `discnumber = "1"` carries no total
# and must NOT be treated as one (else single-disc albums would
# claim total=1 and the path builder would still skip the
# subfolder, but partial-album cases would underreport).
total_discs = _parse_int_total(tags.get('totaldiscs')) or 0
discnumber_raw = _stringify(tags.get('discnumber'))
if '/' in discnumber_raw:
explicit_total = _parse_int_total(discnumber_raw)
if explicit_total:
total_discs = max(total_discs, explicit_total)
return {
'id': '',
'album_id': '',
'name': album_name,
'title': album_name,
'release_date': release_date,
'total_tracks': total_tracks,
'total_discs': total_discs,
'image_url': '',
'images': [],
'album_artist': album_artist,
'album_type': album_type,
}
def read_album_track_from_file(
file_path: str,
*,
read_embedded_tags_fn=None,
) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]], Optional[str]]:
"""Read embedded tags from ``file_path`` and produce
``(album_meta, track_meta, error_reason)``.
Returns ``(None, None, reason)`` when the file can't be opened,
has no recognisable tags, or is missing essential fields (title
or artist). The reason string is human-readable and suitable for
surfacing directly in the reorganize preview/error UI.
Args:
file_path: Resolved on-disk path to the audio file.
read_embedded_tags_fn: Optional override for the tag reader,
used by tests to avoid real mutagen IO. Defaults to
``core.library.file_tags.read_embedded_tags``."""
if not file_path or not isinstance(file_path, str):
return None, None, 'No file path on track row.'
if read_embedded_tags_fn is None:
from core.library.file_tags import read_embedded_tags as _real_reader
read_embedded_tags_fn = _real_reader
result = read_embedded_tags_fn(file_path)
if not isinstance(result, dict) or not result.get('available'):
reason = (result or {}).get('reason') if isinstance(result, dict) else None
return None, None, reason or 'Could not read embedded tags from file.'
tags = result.get('tags') or {}
track_meta = extract_track_meta_from_tags(tags)
if track_meta is None:
return None, None, 'Embedded tags missing required title or artist.'
album_meta = extract_album_meta_from_tags(tags)
if not album_meta.get('name'):
return None, None, 'Embedded tags missing album name.'
# Promote duration from the file-info block onto the track meta
# so the path builder has a non-zero value if a downstream
# consumer wants it.
duration_seconds = result.get('duration') or 0
try:
track_meta['duration_ms'] = int(float(duration_seconds) * 1000)
except (TypeError, ValueError):
track_meta['duration_ms'] = 0
return album_meta, track_meta, None
def normalize_resolved_path(file_path: Optional[str]) -> Optional[str]:
"""Defensive wrapper: returns the input only when it points at a
real file. Saves the caller from another ``os.path.exists`` check
in already-noisy code paths."""
if not file_path:
return None
try:
if not os.path.exists(file_path):
return None
except OSError:
return None
return file_path
__all__ = [
'extract_track_meta_from_tags',
'extract_album_meta_from_tags',
'read_album_track_from_file',
'normalize_resolved_path',
]

View file

@ -549,17 +549,139 @@ def load_album_and_tracks(db, album_id):
pass
def _plan_from_tags(
album_data: dict,
tracks: List[dict],
resolve_file_path_fn: Optional[Callable[[Optional[str]], Optional[str]]],
) -> dict:
"""Tag-mode planner: build per-track ``api_track`` shapes from each
file's own embedded metadata instead of a live source API call.
Per-track behavior:
- File missing on disk unmatched with reason.
- Tags missing essentials (title / artist / album) unmatched
with reason.
- Otherwise matched with the per-file extracted ``api_track`` and
a per-file ``api_album``. The plan stores the FIRST matched
track's album dict on the top-level ``api_album`` field for
backward compatibility with downstream callers; downstream
consumers that need the per-track album shape read it off
``items[i]['api_album']``.
Returns the same status / source / api_album / total_discs / items
shape as :func:`plan_album_reorganize`. ``source`` is the literal
string ``'tags'`` so callers can distinguish from API sources."""
if resolve_file_path_fn is None:
# Without the file-path resolver we can't read anything off
# disk. Return an unmatched plan so callers surface a clear
# error instead of silently returning empty.
reason = 'Tag-mode reorganize requires the file path resolver.'
return {
'status': 'no_source_id', 'source': None, 'api_album': None,
'total_discs': 1,
'items': [{
'track': t, 'api_track': None, 'matched': False,
'reason': reason,
} for t in tracks],
}
from core.library.reorganize_tag_source import read_album_track_from_file
items: List[dict] = []
first_album_meta: Optional[dict] = None
max_disc = 1
for track in tracks:
db_path = track.get('file_path')
resolved = resolve_file_path_fn(db_path) if db_path else None
if not resolved:
items.append({
'track': track, 'api_track': None, 'api_album': None,
'matched': False,
'reason': 'File no longer exists on disk for this track.',
})
continue
album_meta, track_meta, err = read_album_track_from_file(resolved)
if err is not None or track_meta is None or album_meta is None:
items.append({
'track': track, 'api_track': None, 'api_album': None,
'matched': False,
'reason': err or 'Could not extract metadata from embedded tags.',
})
continue
if first_album_meta is None:
first_album_meta = album_meta
try:
disc = int(track_meta.get('disc_number') or 1)
except (TypeError, ValueError):
disc = 1
if disc > max_disc:
max_disc = disc
# Respect an explicit `totaldiscs` tag (or "1/2" disc-number
# form) so a partial-album reorganize (only disc 1 present
# locally) still routes into `Disc 1/` when the file's tags
# know there are 2 discs total.
try:
tagged_total = int(album_meta.get('total_discs') or 0)
except (TypeError, ValueError):
tagged_total = 0
if tagged_total > max_disc:
max_disc = tagged_total
items.append({
'track': track,
'api_track': track_meta,
'api_album': album_meta,
'matched': True,
'reason': None,
})
if not any(it['matched'] for it in items):
return {
'status': 'no_source_id',
'source': 'tags',
'api_album': None,
'total_discs': 1,
'items': items,
}
return {
'status': 'planned',
'source': 'tags',
'api_album': first_album_meta or {},
'total_discs': max_disc,
'items': items,
}
def plan_album_reorganize(
album_data: dict,
tracks: List[dict],
primary_source: Optional[str] = None,
strict_source: bool = False,
metadata_source: str = 'api',
resolve_file_path_fn: Optional[Callable[[Optional[str]], Optional[str]]] = None,
) -> dict:
"""Compute the per-track plan for an album reorganize without doing
any file IO. Both the actual reorganize orchestrator and the preview
endpoint share this so the preview is guaranteed to match what would
happen on apply.
``metadata_source``:
- ``'api'`` (default): query the configured metadata source(s)
for the canonical tracklist (existing behavior). Issues an
API call.
- ``'tags'``: read each file's embedded tags as the source of
truth (issue #592). Zero API calls; trusts the user's
enriched library.
When ``metadata_source='tags'``, ``resolve_file_path_fn`` MUST be
provided (the planner needs to read the actual files). The
``primary_source`` and ``strict_source`` params are ignored in
tag mode.
Returns:
``{'status': 'planned' | 'no_source_id' | 'no_tracks',
'source': str | None,
@ -581,6 +703,9 @@ def plan_album_reorganize(
'total_discs': 1, 'items': [],
}
if metadata_source == 'tags':
return _plan_from_tags(album_data, tracks, resolve_file_path_fn)
if primary_source is None:
try:
primary_source = get_primary_source()
@ -720,6 +845,7 @@ def preview_album_reorganize(
build_final_path_fn: Callable,
primary_source: Optional[str] = None,
strict_source: bool = False,
metadata_source: str = 'api',
) -> dict:
"""Compute the planned destination paths for a reorganize WITHOUT
moving any files. The preview UI uses this to show users what the
@ -775,6 +901,8 @@ def preview_album_reorganize(
plan = plan_album_reorganize(
album_data, tracks,
primary_source=primary_source, strict_source=strict_source,
metadata_source=metadata_source,
resolve_file_path_fn=resolve_file_path_fn,
)
artist_name = album_data.get('artist_name') or 'Unknown Artist'
album_title = album_data.get('title') or 'Unknown Album'
@ -836,8 +964,12 @@ def preview_album_reorganize(
item['disc_number'] = int(api_track.get('disc_number') or 1)
# Build the same context the orchestrator builds so the path
# builder produces the same destination it would on apply.
# Tag-mode plan items carry per-item album metadata; fall back
# to the shared api_album in API mode (where every plan item
# shares the same one).
per_item_album = plan_item.get('api_album') or api_album
context = _build_post_process_context(
api_album, api_track, artist_name, album_title, total_discs
per_item_album, api_track, artist_name, album_title, total_discs
)
# `_build_final_path_for_track` switches between ALBUM and SINGLE
# modes based on `album_info.get('is_album')` — must be passed,
@ -1034,13 +1166,18 @@ def _stage_track(ctx: _RunContext, track_id, title, resolved_src) -> Optional[st
return staging_file
def _run_post_process_for_track(ctx: _RunContext, track_id, title, api_track, staging_file) -> Optional[str]:
def _run_post_process_for_track(ctx: _RunContext, track_id, title, api_track, staging_file, *, per_item_api_album=None) -> Optional[str]:
"""Build the per-track context, hand it to post-processing, and
return the final on-disk path it produced. Returns None on any
failure (exception, AcoustID rejection, internal skip); the caller
leaves the original file alone."""
leaves the original file alone.
``per_item_api_album`` overrides ``ctx.api_album`` for this track
used in tag-mode reorganize where each file may carry its own
embedded album metadata."""
api_album = per_item_api_album if per_item_api_album else ctx.api_album
context = _build_post_process_context(
ctx.api_album, api_track, ctx.artist_name, ctx.album_title, ctx.total_discs
api_album, api_track, ctx.artist_name, ctx.album_title, ctx.total_discs
)
context_key = f"reorganize_{ctx.album_id}_{track_id}_{uuid.uuid4().hex[:8]}"
try:
@ -1138,7 +1275,10 @@ def _process_one_track(ctx: _RunContext, plan_item: dict) -> None:
if staging_file is None:
return
new_path = _run_post_process_for_track(ctx, track_id, title, plan_item['api_track'], staging_file)
new_path = _run_post_process_for_track(
ctx, track_id, title, plan_item['api_track'], staging_file,
per_item_api_album=plan_item.get('api_album'),
)
if new_path is None:
return
@ -1180,6 +1320,7 @@ def reorganize_album(
primary_source: Optional[str] = None,
strict_source: bool = False,
stop_check: Optional[Callable[[], bool]] = None,
metadata_source: str = 'api',
) -> dict:
"""Run a single album through the post-processing pipeline.
@ -1257,10 +1398,19 @@ def reorganize_album(
plan = plan_album_reorganize(
album_data, tracks,
primary_source=primary_source, strict_source=strict_source,
metadata_source=metadata_source,
resolve_file_path_fn=resolve_file_path_fn,
)
if plan['status'] == 'no_source_id':
summary['status'] = 'no_source_id'
if _is_unknown_artist(album_data.get('artist_name')) or _looks_like_album_id_title(album_data.get('title')):
summary['source'] = plan.get('source') # 'tags' or None
if plan.get('source') == 'tags':
err_text = (
f"No tracks of '{album_data.get('title', '?')}' have readable "
"embedded tags (missing title / artist / album, or file unreadable). "
"Switch back to API mode or fix the embedded tags first."
)
elif _is_unknown_artist(album_data.get('artist_name')) or _looks_like_album_id_title(album_data.get('title')):
err_text = (
f"Album '{album_data.get('title', '?')}' has placeholder metadata "
"(Unknown Artist or numeric title) — run the 'Fix Unknown Artists' "

View file

@ -66,6 +66,10 @@ class QueueItem:
artist_name: str # captured at enqueue time for UI display
source: Optional[str] # the user's per-modal pick (None = auto)
enqueued_at: float
# 'api' (default) = query metadata source per album_data IDs.
# 'tags' = read each file's embedded tags as the source
# of truth (issue #592). Zero API calls.
metadata_source: str = 'api'
status: str = 'queued' # queued | running | done | failed | cancelled
started_at: Optional[float] = None
finished_at: Optional[float] = None
@ -91,6 +95,7 @@ class QueueItem:
'artist_id': self.artist_id,
'artist_name': self.artist_name,
'source': self.source,
'metadata_source': self.metadata_source,
'enqueued_at': self.enqueued_at,
'started_at': self.started_at,
'finished_at': self.finished_at,
@ -155,6 +160,7 @@ class ReorganizeQueue:
artist_id: Optional[str],
artist_name: str,
source: Optional[str] = None,
metadata_source: str = 'api',
) -> dict:
"""Add an album to the queue. Returns a result dict:
@ -183,6 +189,7 @@ class ReorganizeQueue:
artist_name=artist_name,
source=source,
enqueued_at=time.time(),
metadata_source=metadata_source or 'api',
)
self._items.append(item)
position = sum(1 for i in self._items if i.status == 'queued')
@ -190,7 +197,8 @@ class ReorganizeQueue:
self._cond.notify_all()
logger.info(
f"[Queue] Enqueued '{album_title}' (album_id={album_id}, "
f"queue_id={item.queue_id}, position={position}, source={source or 'auto'})"
f"queue_id={item.queue_id}, position={position}, "
f"source={source or 'auto'}, metadata={item.metadata_source})"
)
return {
'queued': True,
@ -240,6 +248,7 @@ class ReorganizeQueue:
artist_name=raw.get('artist_name') or 'Unknown Artist',
source=raw.get('source'),
enqueued_at=time.time(),
metadata_source=raw.get('metadata_source') or 'api',
)
self._items.append(item)
enqueued += 1

View file

@ -118,6 +118,7 @@ def build_runner(
primary_source=item.source,
strict_source=bool(item.source),
stop_check=is_shutting_down_fn,
metadata_source=getattr(item, 'metadata_source', 'api') or 'api',
)
return runner

View file

@ -0,0 +1,602 @@
"""Boundary tests for ``core.library.reorganize_tag_source``.
Pin every shape the embedded-tag reorganize-context adapter has to
handle so future drift fails here instead of at runtime against a
real library: empty / missing essentials, multi-value vs single-string
artist tags, ID3-style ``"5/12"`` track-number values, year
normalization across date shapes, releasetype validation, multi-disc
parsing, defensive paths against bad input.
The wrapper :func:`read_album_track_from_file` is tested against a
fake ``read_embedded_tags_fn`` so no real mutagen IO happens here.
"""
from __future__ import annotations
import sys
import types
from typing import Any, Dict
import pytest
# ── stubs (other tests rely on these too — keep the shape consistent) ──
if 'utils.logging_config' not in sys.modules:
utils_mod = types.ModuleType('utils')
logging_mod = types.ModuleType('utils.logging_config')
logging_mod.get_logger = lambda name: type('L', (), {
'debug': lambda *a, **k: None,
'info': lambda *a, **k: None,
'warning': lambda *a, **k: None,
'error': lambda *a, **k: None,
})()
sys.modules['utils'] = utils_mod
sys.modules['utils.logging_config'] = logging_mod
from core.library.reorganize_tag_source import (
extract_album_meta_from_tags,
extract_track_meta_from_tags,
read_album_track_from_file,
normalize_resolved_path,
)
# ─── extract_track_meta_from_tags ─────────────────────────────────────
class TestExtractTrackMeta:
def test_full_set_returns_canonical_shape(self):
out = extract_track_meta_from_tags({
'title': 'HUMBLE.',
'artist': 'Kendrick Lamar',
'tracknumber': '4',
'discnumber': '1',
})
assert out is not None
assert out['name'] == 'HUMBLE.'
assert out['title'] == 'HUMBLE.'
assert out['track_number'] == 4
assert out['disc_number'] == 1
assert out['artists'] == [{'name': 'Kendrick Lamar'}]
assert out['duration_ms'] == 0
assert out['id'] == ''
assert out['uri'] == ''
def test_missing_title_returns_none(self):
assert extract_track_meta_from_tags({'artist': 'foo'}) is None
assert extract_track_meta_from_tags({'title': '', 'artist': 'foo'}) is None
assert extract_track_meta_from_tags({'title': ' ', 'artist': 'foo'}) is None
def test_missing_artist_returns_none(self):
assert extract_track_meta_from_tags({'title': 'Song'}) is None
assert extract_track_meta_from_tags({'title': 'Song', 'artist': ''}) is None
def test_multi_value_artists_field_takes_precedence(self):
out = extract_track_meta_from_tags({
'title': 'Collab',
'artist': 'Foo Bar',
'artists': 'Foo, Bar, Baz', # multi-value tag joined by reader
})
assert out is not None
assert out['artists'] == [{'name': 'Foo'}, {'name': 'Bar'}, {'name': 'Baz'}]
def test_artist_string_split_on_known_separators(self):
for sep_input, expected in [
('Foo, Bar', ['Foo', 'Bar']),
('Foo & Bar', ['Foo', 'Bar']),
('Foo feat. Bar', ['Foo', 'Bar']),
('Foo ft Bar', ['Foo', 'Bar']),
('Foo featuring Bar', ['Foo', 'Bar']),
('Foo / Bar', ['Foo', 'Bar']),
('Foo; Bar', ['Foo', 'Bar']),
('Foo x Bar', ['Foo', 'Bar']),
('Foo with Bar', ['Foo', 'Bar']),
]:
out = extract_track_meta_from_tags({'title': 't', 'artist': sep_input})
assert out is not None
names = [a['name'] for a in out['artists']]
assert names == expected, f"failed for {sep_input!r}: got {names}"
def test_artist_dedup_case_insensitive(self):
out = extract_track_meta_from_tags({
'title': 't',
'artists': 'Foo, foo, FOO, Bar',
})
assert out is not None
names = [a['name'] for a in out['artists']]
assert names == ['Foo', 'Bar']
def test_id3_track_total_shape(self):
# ID3 stores TRCK as "5/12" — caller must use the head only.
out = extract_track_meta_from_tags({
'title': 't', 'artist': 'a',
'tracknumber': '5/12',
})
assert out['track_number'] == 5
def test_disc_total_shape(self):
out = extract_track_meta_from_tags({
'title': 't', 'artist': 'a',
'discnumber': '2/2',
})
assert out['disc_number'] == 2
def test_track_number_default_to_one(self):
out = extract_track_meta_from_tags({
'title': 't', 'artist': 'a',
})
assert out['track_number'] == 1
assert out['disc_number'] == 1
def test_track_number_zero_or_negative_falls_back_to_one(self):
out = extract_track_meta_from_tags({
'title': 't', 'artist': 'a', 'tracknumber': '0',
})
assert out['track_number'] == 1 # or-default of 0 → 1
def test_track_number_unparseable_falls_back_to_one(self):
out = extract_track_meta_from_tags({
'title': 't', 'artist': 'a',
'tracknumber': 'side-a-2',
})
assert out['track_number'] == 1
def test_int_track_number(self):
out = extract_track_meta_from_tags({
'title': 't', 'artist': 'a',
'tracknumber': 5,
'discnumber': 2,
})
assert out['track_number'] == 5
assert out['disc_number'] == 2
def test_float_track_number_truncated(self):
out = extract_track_meta_from_tags({
'title': 't', 'artist': 'a',
'tracknumber': 5.7,
})
assert out['track_number'] == 5
def test_zero_padded_track_number(self):
out = extract_track_meta_from_tags({
'title': 't', 'artist': 'a',
'tracknumber': '03',
})
assert out['track_number'] == 3
def test_non_dict_input(self):
assert extract_track_meta_from_tags(None) is None
assert extract_track_meta_from_tags([]) is None
assert extract_track_meta_from_tags('') is None
def test_empty_dict(self):
assert extract_track_meta_from_tags({}) is None
# ─── extract_album_meta_from_tags ─────────────────────────────────────
class TestExtractAlbumMeta:
def test_full_set(self):
out = extract_album_meta_from_tags({
'album': 'DAMN.',
'albumartist': 'Kendrick Lamar',
'date': '2017-04-14',
'totaltracks': '14',
'releasetype': 'Album',
})
assert out['name'] == 'DAMN.'
assert out['title'] == 'DAMN.'
assert out['album_artist'] == 'Kendrick Lamar'
assert out['release_date'] == '2017'
assert out['total_tracks'] == 14
assert out['album_type'] == 'album'
assert out['image_url'] == ''
assert out['id'] == ''
def test_year_normalization_from_full_date(self):
for date_input, expected_year in [
('2020-01-15', '2020'),
('2020', '2020'),
('2020-01', '2020'),
('Jan 5, 2020', '2020'),
('1999/12/31', '1999'),
]:
out = extract_album_meta_from_tags({'album': 'a', 'date': date_input})
assert out['release_date'] == expected_year, f"date={date_input!r}"
def test_year_falls_back_to_year_field(self):
out = extract_album_meta_from_tags({'album': 'a', 'year': '2018'})
assert out['release_date'] == '2018'
def test_year_falls_back_to_originaldate(self):
out = extract_album_meta_from_tags({'album': 'a', 'originaldate': '2010'})
assert out['release_date'] == '2010'
def test_year_missing_returns_empty(self):
out = extract_album_meta_from_tags({'album': 'a'})
assert out['release_date'] == ''
def test_totaltracks_from_id3_shape(self):
# ID3 may store track_number as "5/12" — use the trailing 12.
out = extract_album_meta_from_tags({
'album': 'a', 'tracknumber': '5/12',
})
assert out['total_tracks'] == 12
def test_totaltracks_explicit_field_wins(self):
out = extract_album_meta_from_tags({
'album': 'a', 'totaltracks': '14', 'tracknumber': '5/12',
})
assert out['total_tracks'] == 14
def test_totaltracks_tracktotal_alias(self):
out = extract_album_meta_from_tags({'album': 'a', 'tracktotal': '8'})
assert out['total_tracks'] == 8
def test_releasetype_canonical(self):
for input_val, expected in [
('album', 'album'), ('Album', 'album'),
('single', 'single'), ('Single', 'single'),
('ep', 'ep'), ('EP', 'ep'),
('compilation', 'compilation'),
('soundtrack', ''), # not in canonical set
('mixtape', ''),
('', ''),
]:
out = extract_album_meta_from_tags({
'album': 'a', 'releasetype': input_val,
})
assert out['album_type'] == expected, f"releasetype={input_val!r}"
def test_total_discs_explicit_field(self):
out = extract_album_meta_from_tags({
'album': 'a', 'totaldiscs': '2',
})
assert out['total_discs'] == 2
def test_total_discs_from_id3_disc_form(self):
out = extract_album_meta_from_tags({
'album': 'a', 'discnumber': '1/2',
})
assert out['total_discs'] == 2
def test_total_discs_explicit_wins_over_disc_form(self):
# When both present, take the larger (defensive against drift).
out = extract_album_meta_from_tags({
'album': 'a', 'discnumber': '1/2', 'totaldiscs': '3',
})
assert out['total_discs'] == 3
def test_total_discs_missing_zero(self):
out = extract_album_meta_from_tags({
'album': 'a', 'discnumber': '1',
})
assert out['total_discs'] == 0 # caller defaults via max() with disc count
def test_album_artist_underscore_alias(self):
out = extract_album_meta_from_tags({
'album': 'a', 'album_artist': 'Foo',
})
assert out['album_artist'] == 'Foo'
def test_missing_album_returns_empty_name(self):
out = extract_album_meta_from_tags({})
assert out['name'] == ''
# All other fields should still be present (zero/empty), so the
# caller's downstream consumer doesn't KeyError.
assert 'release_date' in out
assert 'total_tracks' in out
def test_non_dict_input_safe(self):
out = extract_album_meta_from_tags(None) # type: ignore
assert out['name'] == ''
# ─── read_album_track_from_file ───────────────────────────────────────
class TestReadAlbumTrackFromFile:
def test_unavailable_result_returns_reason(self):
def fake_reader(_p):
return {'available': False, 'reason': 'No file.'}
a, t, err = read_album_track_from_file('fake', read_embedded_tags_fn=fake_reader)
assert a is None and t is None
assert err == 'No file.'
def test_unavailable_no_reason_falls_back(self):
def fake_reader(_p):
return {'available': False}
_, _, err = read_album_track_from_file('fake', read_embedded_tags_fn=fake_reader)
assert 'Could not read embedded tags' in (err or '')
def test_non_dict_result_safe(self):
def fake_reader(_p):
return None
a, t, err = read_album_track_from_file('fake', read_embedded_tags_fn=fake_reader)
assert a is None and t is None
assert err
def test_essentials_missing_track_returns_reason(self):
# Title missing → unmatched.
def fake_reader(_p):
return {'available': True, 'tags': {'artist': 'a', 'album': 'b'}}
a, t, err = read_album_track_from_file('fake', read_embedded_tags_fn=fake_reader)
assert a is None and t is None
assert 'title' in (err or '').lower()
def test_essentials_missing_album_returns_reason(self):
# Album missing → unmatched even if track meta extracted.
def fake_reader(_p):
return {'available': True, 'tags': {'title': 't', 'artist': 'a'}}
a, t, err = read_album_track_from_file('fake', read_embedded_tags_fn=fake_reader)
assert a is None and t is None
assert 'album' in (err or '').lower()
def test_full_extraction(self):
def fake_reader(_p):
return {
'available': True,
'duration': 234.5,
'tags': {
'title': 'HUMBLE.',
'artist': 'Kendrick Lamar',
'album': 'DAMN.',
'albumartist': 'Kendrick Lamar',
'tracknumber': '4/14',
'discnumber': '1/1',
'date': '2017-04-14',
'releasetype': 'Album',
},
}
album, track, err = read_album_track_from_file(
'/fake.flac', read_embedded_tags_fn=fake_reader,
)
assert err is None
assert track is not None and album is not None
assert track['name'] == 'HUMBLE.'
assert track['track_number'] == 4
assert track['disc_number'] == 1
assert track['duration_ms'] == 234500
assert album['name'] == 'DAMN.'
assert album['release_date'] == '2017'
assert album['total_tracks'] == 14
assert album['album_type'] == 'album'
def test_duration_zero_when_missing(self):
def fake_reader(_p):
return {
'available': True,
'tags': {
'title': 't', 'artist': 'a', 'album': 'b',
},
}
_, track, err = read_album_track_from_file('fake', read_embedded_tags_fn=fake_reader)
assert err is None
assert track['duration_ms'] == 0
def test_duration_unparseable_zero(self):
def fake_reader(_p):
return {
'available': True,
'duration': 'banana',
'tags': {'title': 't', 'artist': 'a', 'album': 'b'},
}
_, track, err = read_album_track_from_file('fake', read_embedded_tags_fn=fake_reader)
assert err is None
assert track['duration_ms'] == 0
def test_empty_path(self):
a, t, err = read_album_track_from_file('')
assert a is None and t is None
assert err
def test_non_string_path(self):
a, t, err = read_album_track_from_file(None) # type: ignore
assert a is None and t is None
assert err
# ─── normalize_resolved_path ──────────────────────────────────────────
class TestNormalizeResolvedPath:
def test_returns_path_when_exists(self, tmp_path):
f = tmp_path / 'x.flac'
f.write_bytes(b'')
assert normalize_resolved_path(str(f)) == str(f)
def test_none_when_missing(self, tmp_path):
assert normalize_resolved_path(str(tmp_path / 'no.flac')) is None
def test_empty_input_safe(self):
assert normalize_resolved_path('') is None
assert normalize_resolved_path(None) is None
# ─── plan_album_reorganize (tag-mode integration) ────────────────────
#
# Pin the wiring between the planner branch and the tag-source helper
# so the additive-and-optional contract holds: API mode unchanged,
# tag mode produces matched plan items shaped like API mode (so
# downstream post-process treats them identically).
def _stub_metadata_service(monkeypatch):
"""Inject a minimal `core.metadata_service` so `library_reorganize`
imports cleanly even in the test process where the real metadata
clients aren't wired."""
if 'core' not in sys.modules:
sys.modules['core'] = types.ModuleType('core')
if 'core.metadata_service' in sys.modules:
return
fake = types.ModuleType('core.metadata_service')
fake.get_album_for_source = lambda *a, **k: {}
fake.get_album_tracks_for_source = lambda *a, **k: []
fake.get_client_for_source = lambda *a, **k: None
fake.get_primary_source = lambda: 'deezer'
fake.get_source_priority = lambda primary=None: ['deezer', 'spotify', 'itunes']
sys.modules['core.metadata_service'] = fake
class TestPlannerTagModeIntegration:
def test_tag_mode_planner_matches_every_track_with_good_tags(self, monkeypatch):
_stub_metadata_service(monkeypatch)
from core import library_reorganize as lr
per_path = {
'/a/track1.flac': {
'available': True, 'duration': 200,
'tags': {
'title': 'Song A', 'artist': 'Foo', 'album': 'AlbumX',
'tracknumber': '1/3', 'discnumber': '1/1',
'date': '2020', 'releasetype': 'Album',
},
},
'/a/track2.flac': {
'available': True, 'duration': 230,
'tags': {
'title': 'Song B', 'artist': 'Foo', 'album': 'AlbumX',
'tracknumber': '2/3', 'discnumber': '1/1',
'date': '2020', 'releasetype': 'Album',
},
},
}
monkeypatch.setattr(
'core.library.file_tags.read_embedded_tags',
lambda p: per_path.get(p, {'available': False, 'reason': 'missing'}),
)
plan = lr.plan_album_reorganize(
album_data={'artist_name': 'Foo', 'title': 'AlbumX'},
tracks=[
{'id': 't1', 'title': 'Song A', 'track_number': 1, 'file_path': '/a/track1.flac'},
{'id': 't2', 'title': 'Song B', 'track_number': 2, 'file_path': '/a/track2.flac'},
],
metadata_source='tags',
resolve_file_path_fn=lambda p: p,
)
assert plan['status'] == 'planned'
assert plan['source'] == 'tags'
assert plan['total_discs'] == 1
assert len(plan['items']) == 2
for it in plan['items']:
assert it['matched'] is True
assert it['api_track']['name'] in ('Song A', 'Song B')
assert it['api_album']['name'] == 'AlbumX'
assert it['api_album']['album_type'] == 'album'
def test_tag_mode_partial_disc_uses_tagged_total_discs(self, monkeypatch):
# User has only disc 2 of a 2-disc album; tags say so.
# max_disc must reflect tagged total so path builder still
# routes into the multi-disc subfolder.
_stub_metadata_service(monkeypatch)
from core import library_reorganize as lr
monkeypatch.setattr(
'core.library.file_tags.read_embedded_tags',
lambda p: {
'available': True,
'tags': {
'title': 'Song A', 'artist': 'Foo', 'album': 'Y',
'tracknumber': '1/8', 'discnumber': '2/2',
'totaldiscs': '2',
},
},
)
plan = lr.plan_album_reorganize(
album_data={'artist_name': 'Foo', 'title': 'Y'},
tracks=[{'id': 't1', 'title': 'Song A', 'track_number': 1, 'file_path': '/a.flac'}],
metadata_source='tags',
resolve_file_path_fn=lambda p: p,
)
assert plan['status'] == 'planned'
assert plan['total_discs'] == 2
def test_tag_mode_file_missing_unmatched_with_reason(self, monkeypatch):
_stub_metadata_service(monkeypatch)
from core import library_reorganize as lr
plan = lr.plan_album_reorganize(
album_data={'artist_name': 'Foo', 'title': 'X'},
tracks=[{'id': 't1', 'title': 'Song', 'track_number': 1, 'file_path': '/missing.flac'}],
metadata_source='tags',
resolve_file_path_fn=lambda p: None, # always missing
)
# All tracks unmatched → no_source_id status, source='tags'.
assert plan['status'] == 'no_source_id'
assert plan['source'] == 'tags'
assert plan['items'][0]['matched'] is False
assert 'no longer exists' in plan['items'][0]['reason'].lower()
def test_tag_mode_some_match_some_unreadable(self, monkeypatch):
_stub_metadata_service(monkeypatch)
from core import library_reorganize as lr
per_path = {
'/good.flac': {
'available': True,
'tags': {'title': 'Good', 'artist': 'A', 'album': 'X', 'tracknumber': '1/2'},
},
'/bad.flac': {'available': False, 'reason': 'unreadable'},
}
monkeypatch.setattr(
'core.library.file_tags.read_embedded_tags',
lambda p: per_path.get(p, {'available': False, 'reason': 'missing'}),
)
plan = lr.plan_album_reorganize(
album_data={'artist_name': 'A', 'title': 'X'},
tracks=[
{'id': 'g', 'title': 'Good', 'track_number': 1, 'file_path': '/good.flac'},
{'id': 'b', 'title': 'Bad', 'track_number': 2, 'file_path': '/bad.flac'},
],
metadata_source='tags',
resolve_file_path_fn=lambda p: p,
)
assert plan['status'] == 'planned'
assert plan['source'] == 'tags'
matched = [it for it in plan['items'] if it['matched']]
unmatched = [it for it in plan['items'] if not it['matched']]
assert len(matched) == 1
assert len(unmatched) == 1
assert unmatched[0]['reason'] == 'unreadable'
def test_tag_mode_without_resolver_returns_no_source_id(self, monkeypatch):
# Defensive: caller forgot to pass resolve_file_path_fn.
_stub_metadata_service(monkeypatch)
from core import library_reorganize as lr
plan = lr.plan_album_reorganize(
album_data={'artist_name': 'A', 'title': 'X'},
tracks=[{'id': 't1', 'title': 'Song', 'track_number': 1, 'file_path': '/a.flac'}],
metadata_source='tags',
resolve_file_path_fn=None,
)
assert plan['status'] == 'no_source_id'
assert plan['items'][0]['matched'] is False
assert 'requires the file path resolver' in plan['items'][0]['reason']
def test_api_mode_unchanged_default(self, monkeypatch):
# Regression guard: omitting metadata_source preserves the API
# path — calls _resolve_source which calls our stubbed
# metadata_service. Should land in 'no_source_id' since stubs
# return empty.
_stub_metadata_service(monkeypatch)
from core import library_reorganize as lr
plan = lr.plan_album_reorganize(
album_data={'artist_name': 'Foo', 'title': 'Bar'},
tracks=[{'id': 't1', 'title': 'Song', 'track_number': 1, 'file_path': '/a.flac'}],
)
# No metadata_source param → defaults to 'api' → empty stubs
# produce no_source_id.
assert plan['status'] == 'no_source_id'
assert plan['source'] is None # never reached the tags branch

View file

@ -11095,12 +11095,20 @@ def reorganize_album_preview(album_id):
the apply endpoint, so the preview is guaranteed to match what
apply would actually produce.
Optional body param ``source``: when provided, only that metadata
source is queried (no fallback chain)."""
Optional body params:
source: when provided, only that metadata source is queried
(no fallback chain).
mode: 'api' (default query metadata source) or 'tags' (read
embedded file tags as the source of truth, issue #592)."""
try:
from core.library_reorganize import preview_album_reorganize
data = request.get_json() or {}
chosen_source = data.get('source') or None
metadata_source = data.get('mode') or config_manager.get(
'library.reorganize_metadata_source', 'api'
) or 'api'
if metadata_source not in ('api', 'tags'):
metadata_source = 'api'
transfer_dir = docker_resolve_path(config_manager.get('soulseek.transfer_path', './Transfer'))
result = preview_album_reorganize(
album_id=album_id,
@ -11110,6 +11118,7 @@ def reorganize_album_preview(album_id):
build_final_path_fn=_build_final_path_for_track,
primary_source=chosen_source,
strict_source=bool(chosen_source),
metadata_source=metadata_source,
)
if result.get('status') == 'no_album':
return jsonify({"success": False, "error": "Album not found"}), 404
@ -11132,11 +11141,21 @@ def reorganize_album_files(album_id):
source (optional): per-album source pick (Spotify / iTunes /
Deezer / Discogs / Hydrabase). When omitted, the
orchestrator uses the configured primary with fallback.
mode (optional): 'api' (default query metadata source) or
'tags' (read embedded file tags as the source of truth,
issue #592). When omitted, falls back to the
``library.reorganize_metadata_source`` config setting,
then to 'api'.
"""
try:
from core.reorganize_queue import get_queue
data = request.get_json() or {}
chosen_source = data.get('source') or None
metadata_source = data.get('mode') or config_manager.get(
'library.reorganize_metadata_source', 'api'
) or 'api'
if metadata_source not in ('api', 'tags'):
metadata_source = 'api'
# Capture display fields at enqueue time so the status panel
# can render them without a DB lookup later.
@ -11150,6 +11169,7 @@ def reorganize_album_files(album_id):
artist_id=meta['artist_id'],
artist_name=meta['artist_name'],
source=chosen_source,
metadata_source=metadata_source,
)
return jsonify({"success": True, **result})
except Exception as e:
@ -11167,20 +11187,29 @@ def reorganize_all_artist_albums(artist_id):
source (optional): same pick applied to every album. Per-album
overrides aren't supported here — use the per-album modal
for that.
mode (optional): 'api' or 'tags' applied to every album, same
shape as the per-album endpoint.
"""
try:
from core.reorganize_queue import get_queue
data = request.get_json() or {}
chosen_source = data.get('source') or None
metadata_source = data.get('mode') or config_manager.get(
'library.reorganize_metadata_source', 'api'
) or 'api'
if metadata_source not in ('api', 'tags'):
metadata_source = 'api'
albums = get_database().get_artist_albums_for_reorganize(artist_id)
if not albums:
return jsonify({"success": False, "error": "No albums found for this artist"}), 404
# Apply the user's chosen source to every album, then hand off
# to the queue's bulk-enqueue helper which owns the loop+tally.
# Apply the user's chosen source + mode to every album, then
# hand off to the queue's bulk-enqueue helper which owns the
# loop+tally.
for album in albums:
album['source'] = chosen_source
album['metadata_source'] = metadata_source
result = get_queue().enqueue_many(albums)
return jsonify({

View file

@ -3416,6 +3416,7 @@ const WHATS_NEW = {
'2.5.2': [
// --- May 13, 2026 — 2.5.2 release ---
{ date: 'May 13, 2026 — 2.5.2 release' },
{ title: 'Reorganize: Read Embedded Tags Instead Of Metadata API', desc: 'github issue #592: optional reorganize mode that uses each file\'s embedded tags as the canonical source of truth instead of doing a fresh metadata-source api lookup. useful for well-tagged libraries where api drift can produce inconsistent renames. zero api calls. opt-in via new "metadata mode" dropdown in the per-album reorganize modal + bulk reorganize-all modal — default stays "api" so existing pipelines are untouched. tag-mode reads via the same mutagen path the audit-trail modal uses (id3 / vorbis / mp4 all covered). honors id3-style "5/12" track and disc shapes, multi-value Artists tags, year normalization across 5 date formats, releasetype canonical tokens (album/single/ep/compilation). partial-album reorganize respects "totaldiscs" tag so disc 1 of a 2-disc set still routes into "Disc 1/" subfolder. plan items carry per-item `api_album` so each file\'s own album metadata flows through post-process, not a shared one. logic lifted to pure helper `core/library/reorganize_tag_source.py` with 49 boundary tests + 6 planner-level integration tests pinning every shape: missing essentials, multi-artist split across 9 separators, defensive paths, api-mode regression guard. 3171 tests pass — no regression.', page: 'tools' },
{ title: 'Dashboard Cursor-Following Accent Blob + Darker Cards', desc: 'subtle two-layer accent blob that follows your cursor across the bento. soft halo with cursor lag for a liquid trailing feel + brighter inner core that screen-blends on top. both layers gently pulse on different rhythms (5.5s halo, 3.7s core) so it feels alive. mouse leaves a card or sits in a gap → blob freezes for 1.5s then drifts back to grid center. card backgrounds darkened to near-black with stronger borders for contrast. respects the existing reduce visual effects setting (settings → ui) — blob fully disabled when on. performant: rAF-only-while-moving, single layout flush per frame, batched read/write of getBoundingClientRect.', page: 'home' },
{ title: 'Dashboard Bento Redesign', desc: 'rebuilt the dashboard as a bento grid. every section now lives in its own card with an accent-tinted glow that follows your theme. cards fade up on first paint with a staggered reveal. layout adapts: 3-col on desktop (≥1500px), 2-col on laptop, 2-col tighter on tablet, single-column on mobile (<700px). enrichment service gauges ride a single 10-tile row at desktop and wrap to 5 / 4 / 3 / 2 as space tightens. system stats render 3-up across 2 rows so all 6 metrics fit without scrolling. recent syncs stack vertically inside their card. service status, library, tools, recent activity all slot into the grid. every existing button + id preserved — pure visual + responsive overhaul.', page: 'home' },
{ title: 'Retag No Longer Strips LYRICS Tag Without Rewriting', desc: 'discord report (netti93): retag tool was clearing the LYRICS / USLT tag and never rewriting it, while the download flow correctly embeds lyrics. asymmetry trace: download pipeline (`core/imports/pipeline.py`) calls `enhance_file_metadata` (clears all tags) then `generate_lrc_file` (writes .lrc sidecar + embeds USLT). retag (`core/library/retag.py`) only called the first half — `enhance_file_metadata` cleared USLT and there was no follow-up to restore it. fix 1: retag now calls `generate_lrc_file` after `enhance_file_metadata`, mirroring the download flow. injectable via `RetagDeps.generate_lrc_file` (optional default for backward compat). fix 2: `lyrics_client.create_lrc_file` used to short-circuit when an .lrc/.txt sidecar already existed (the typical retag case — sidecar moved alongside the audio). pre-fix: returned True without re-embedding USLT. post-fix: reads the existing sidecar and re-embeds the USLT tag. download flow unaffected (no sidecar at fetch time → original LRClib path runs). 7 boundary tests pin: existing .lrc triggers re-embed, existing .txt triggers re-embed, empty sidecar skips embed, unreadable sidecar swallows error, no sidecar falls through to LRClib (download path), `RetagDeps.generate_lrc_file` field accepted + optional for backward compat.', page: 'tools' },
@ -3839,6 +3840,20 @@ const WHATS_NEW = {
// Section shape: { title, description, features: [bullet strings],
// usage_note?: 'optional hint shown at the bottom' }
const VERSION_MODAL_SECTIONS = [
{
title: "Reorganize Can Now Use Your Embedded File Tags Instead Of An API Lookup",
description: "github issue #592: for users with a well-enriched, well-tagged library, doing a fresh metadata-source api lookup at reorganize time can drift from what's already on the file — provider naming inconsistencies, missing album-level metadata for niche releases. new optional mode reads each file's embedded tags as the source of truth instead. zero api calls.",
features: [
"• new \"metadata mode\" dropdown on the per-album reorganize modal + bulk reorganize-all modal: 'API metadata' (default, current behavior) vs 'Embedded file tags'",
"• tag-mode reads via the same mutagen path the audit-trail modal uses — id3 / vorbis / mp4 all covered",
"• handles id3-style \"5/12\" track + disc shapes, multi-value Artists tags, year normalization across 5 date formats, releasetype canonical tokens (album / single / ep / compilation)",
"• partial-album reorganize respects the \"totaldiscs\" tag — disc 1 of a 2-disc set still routes into Disc 1/ subfolder",
"• each track's own album metadata flows through post-process (not a shared one), so per-file enrichment differences are preserved",
"• default stays 'API metadata' so existing reorganize pipelines are completely untouched — opt-in only",
"• per-track unmatched reasons surface in the preview when a file's tags are missing essentials, so you can see exactly which tracks need attention",
],
usage_note: "artist detail → album → reorganize → 'metadata mode' dropdown; or pass `mode: 'tags'` to the api endpoint",
},
{
title: "Dashboard Cursor-Following Accent Blob",
description: "the bento dashboard now has a subtle two-layer accent glow that follows your cursor as you sweep across cards. card backgrounds are darker too for better contrast.",

View file

@ -6418,10 +6418,24 @@ async function showReorganizeModal(albumId) {
let html = '<div class="reorganize-content">';
// Metadata MODE picker — API call (default) vs read embedded tags.
// Tag-mode (#592) trusts the user's enriched library and issues
// zero API calls.
html += '<div class="reorganize-source-section">';
html += '<label class="reorganize-label">Metadata Mode</label>';
html += '<div class="reorganize-template-hint">"API" queries your metadata source for the canonical tracklist. "Embedded tags" reads each file\'s own tags as the source of truth — useful for well-tagged libraries and avoids API calls.</div>';
html += '<select id="reorganize-mode-select" class="reorganize-template-input" onchange="_onReorganizeModeChange()">';
html += '<option value="api">API metadata (default)</option>';
html += '<option value="tags">Embedded file tags</option>';
html += '</select>';
html += '</div>';
// Metadata source picker — populated from /reorganize/sources.
// Empty value = use configured primary (with fallback chain).
// Specific source = strict mode, that source only.
html += '<div class="reorganize-source-section">';
// Hidden when mode = 'tags' since the source picker is irrelevant
// (tags are read straight off the file).
html += '<div class="reorganize-source-section" id="reorganize-source-section">';
html += '<label class="reorganize-label">Metadata Source</label>';
html += '<div class="reorganize-template-hint">Pick which source to read the album\'s tracklist from. Defaults to your configured primary. Reorganize uses your global download template, same as fresh downloads.</div>';
html += '<select id="reorganize-source-select" class="reorganize-template-input">';
@ -6445,6 +6459,21 @@ async function showReorganizeModal(albumId) {
// Populate source picker after the modal mounts
setTimeout(() => _populateReorganizeSources(_reorganizeAlbumId), 50);
// Apply user's saved default mode if any
try {
const savedMode = localStorage.getItem('soulsync-reorganize-mode') || 'api';
const sel = document.getElementById('reorganize-mode-select');
if (sel) sel.value = savedMode;
_onReorganizeModeChange();
} catch (e) { /* localStorage unavailable, ignore */ }
}
function _onReorganizeModeChange() {
const mode = document.getElementById('reorganize-mode-select')?.value || 'api';
const srcSection = document.getElementById('reorganize-source-section');
if (srcSection) srcSection.style.display = (mode === 'tags') ? 'none' : '';
try { localStorage.setItem('soulsync-reorganize-mode', mode); } catch (e) {}
}
async function _populateReorganizeSources(albumId) {
@ -6496,10 +6525,11 @@ async function loadReorganizePreview() {
try {
const chosenSource = document.getElementById('reorganize-source-select')?.value || '';
const chosenMode = document.getElementById('reorganize-mode-select')?.value || 'api';
const response = await fetch(`/api/library/album/${_reorganizeAlbumId}/reorganize/preview`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source: chosenSource })
body: JSON.stringify({ source: chosenSource, mode: chosenMode })
});
const result = await response.json();
if (!result.success) {
@ -6596,10 +6626,11 @@ async function executeReorganize() {
try {
const chosenSource = document.getElementById('reorganize-source-select')?.value || '';
const chosenMode = document.getElementById('reorganize-mode-select')?.value || 'api';
const response = await fetch(`/api/library/album/${_reorganizeAlbumId}/reorganize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source: chosenSource })
body: JSON.stringify({ source: chosenSource, mode: chosenMode })
});
const result = await response.json();
if (!result.success) throw new Error(result.error);
@ -6690,10 +6721,21 @@ async function _showReorganizeAllModal() {
let html = '<div class="reorganize-content">';
// Source picker — applies to ALL albums in this run. Albums without
// an ID for the chosen source will be skipped at the backend with
// a clear status. Auto = use configured primary with fallback chain.
// Mode picker — applies to ALL albums.
html += '<div class="reorganize-source-section">';
html += '<label class="reorganize-label">Metadata Mode</label>';
html += '<div class="reorganize-template-hint">"API" queries your metadata source for the canonical tracklist. "Embedded tags" reads each file\'s own tags as the source of truth — useful for well-tagged libraries and avoids API calls.</div>';
html += '<select id="reorganize-mode-select" class="reorganize-template-input" onchange="_onReorganizeModeChange()">';
html += '<option value="api">API metadata (default)</option>';
html += '<option value="tags">Embedded file tags</option>';
html += '</select>';
html += '</div>';
// Source picker — applies to ALL albums in this run. Hidden when
// mode = 'tags'. Albums without an ID for the chosen source will
// be skipped at the backend with a clear status. Auto = use
// configured primary with fallback chain.
html += '<div class="reorganize-source-section" id="reorganize-source-section">';
html += '<label class="reorganize-label">Metadata Source (applies to all albums)</label>';
html += '<div class="reorganize-template-hint">Pick which source to read tracklists from. Albums without an ID for that source will be skipped. Reorganize uses your global download template, same as fresh downloads.</div>';
html += '<select id="reorganize-source-select" class="reorganize-template-input">';
@ -6743,6 +6785,14 @@ async function _showReorganizeAllModal() {
console.error('Failed to load reorganize sources:', err);
}
}, 50);
// Apply user's saved default mode if any
try {
const savedMode = localStorage.getItem('soulsync-reorganize-mode') || 'api';
const sel = document.getElementById('reorganize-mode-select');
if (sel) sel.value = savedMode;
_onReorganizeModeChange();
} catch (e) { /* localStorage unavailable, ignore */ }
}
async function _executeReorganizeAll() {
@ -6766,14 +6816,15 @@ async function _executeReorganizeAll() {
const overlay = document.getElementById('reorganize-overlay');
if (overlay) overlay.classList.add('hidden');
// One source pick applies to every album in the batch.
// One source + mode pick applies to every album in the batch.
const chosenSource = document.getElementById('reorganize-source-select')?.value || '';
const chosenMode = document.getElementById('reorganize-mode-select')?.value || 'api';
try {
const resp = await fetch(`/api/library/artist/${artistId}/reorganize-all`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source: chosenSource }),
body: JSON.stringify({ source: chosenSource, mode: chosenMode }),
});
const result = await resp.json();
if (!result.success) throw new Error(result.error || 'Queue request failed');