diff --git a/core/library/reorganize_tag_source.py b/core/library/reorganize_tag_source.py new file mode 100644 index 00000000..de83e9f2 --- /dev/null +++ b/core/library/reorganize_tag_source.py @@ -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', +] diff --git a/core/library_reorganize.py b/core/library_reorganize.py index 36bbf763..2aea45bb 100644 --- a/core/library_reorganize.py +++ b/core/library_reorganize.py @@ -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' " diff --git a/core/reorganize_queue.py b/core/reorganize_queue.py index 6b50afb0..a86a638c 100644 --- a/core/reorganize_queue.py +++ b/core/reorganize_queue.py @@ -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 diff --git a/core/reorganize_runner.py b/core/reorganize_runner.py index 8ef7817b..1b78e932 100644 --- a/core/reorganize_runner.py +++ b/core/reorganize_runner.py @@ -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 diff --git a/tests/test_reorganize_tag_source.py b/tests/test_reorganize_tag_source.py new file mode 100644 index 00000000..9eee1ff5 --- /dev/null +++ b/tests/test_reorganize_tag_source.py @@ -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 diff --git a/web_server.py b/web_server.py index 792a6eb7..4d2f136a 100644 --- a/web_server.py +++ b/web_server.py @@ -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({ diff --git a/webui/static/helper.js b/webui/static/helper.js index f8a7566d..53f96b55 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -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.", diff --git a/webui/static/library.js b/webui/static/library.js index cacc2ba6..2d050e1d 100644 --- a/webui/static/library.js +++ b/webui/static/library.js @@ -6418,10 +6418,24 @@ async function showReorganizeModal(albumId) { let html = '