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:
parent
1f6d439c13
commit
b05ba5d498
8 changed files with 1203 additions and 19 deletions
327
core/library/reorganize_tag_source.py
Normal file
327
core/library/reorganize_tag_source.py
Normal 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',
|
||||
]
|
||||
|
|
@ -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' "
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
602
tests/test_reorganize_tag_source.py
Normal file
602
tests/test_reorganize_tag_source.py
Normal 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
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue