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
|
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(
|
def plan_album_reorganize(
|
||||||
album_data: dict,
|
album_data: dict,
|
||||||
tracks: List[dict],
|
tracks: List[dict],
|
||||||
primary_source: Optional[str] = None,
|
primary_source: Optional[str] = None,
|
||||||
strict_source: bool = False,
|
strict_source: bool = False,
|
||||||
|
metadata_source: str = 'api',
|
||||||
|
resolve_file_path_fn: Optional[Callable[[Optional[str]], Optional[str]]] = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Compute the per-track plan for an album reorganize without doing
|
"""Compute the per-track plan for an album reorganize without doing
|
||||||
any file IO. Both the actual reorganize orchestrator and the preview
|
any file IO. Both the actual reorganize orchestrator and the preview
|
||||||
endpoint share this so the preview is guaranteed to match what would
|
endpoint share this so the preview is guaranteed to match what would
|
||||||
happen on apply.
|
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:
|
Returns:
|
||||||
``{'status': 'planned' | 'no_source_id' | 'no_tracks',
|
``{'status': 'planned' | 'no_source_id' | 'no_tracks',
|
||||||
'source': str | None,
|
'source': str | None,
|
||||||
|
|
@ -581,6 +703,9 @@ def plan_album_reorganize(
|
||||||
'total_discs': 1, 'items': [],
|
'total_discs': 1, 'items': [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if metadata_source == 'tags':
|
||||||
|
return _plan_from_tags(album_data, tracks, resolve_file_path_fn)
|
||||||
|
|
||||||
if primary_source is None:
|
if primary_source is None:
|
||||||
try:
|
try:
|
||||||
primary_source = get_primary_source()
|
primary_source = get_primary_source()
|
||||||
|
|
@ -720,6 +845,7 @@ def preview_album_reorganize(
|
||||||
build_final_path_fn: Callable,
|
build_final_path_fn: Callable,
|
||||||
primary_source: Optional[str] = None,
|
primary_source: Optional[str] = None,
|
||||||
strict_source: bool = False,
|
strict_source: bool = False,
|
||||||
|
metadata_source: str = 'api',
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Compute the planned destination paths for a reorganize WITHOUT
|
"""Compute the planned destination paths for a reorganize WITHOUT
|
||||||
moving any files. The preview UI uses this to show users what the
|
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(
|
plan = plan_album_reorganize(
|
||||||
album_data, tracks,
|
album_data, tracks,
|
||||||
primary_source=primary_source, strict_source=strict_source,
|
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'
|
artist_name = album_data.get('artist_name') or 'Unknown Artist'
|
||||||
album_title = album_data.get('title') or 'Unknown Album'
|
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)
|
item['disc_number'] = int(api_track.get('disc_number') or 1)
|
||||||
# Build the same context the orchestrator builds so the path
|
# Build the same context the orchestrator builds so the path
|
||||||
# builder produces the same destination it would on apply.
|
# 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(
|
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
|
# `_build_final_path_for_track` switches between ALBUM and SINGLE
|
||||||
# modes based on `album_info.get('is_album')` — must be passed,
|
# 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
|
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
|
"""Build the per-track context, hand it to post-processing, and
|
||||||
return the final on-disk path it produced. Returns None on any
|
return the final on-disk path it produced. Returns None on any
|
||||||
failure (exception, AcoustID rejection, internal skip); the caller
|
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(
|
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]}"
|
context_key = f"reorganize_{ctx.album_id}_{track_id}_{uuid.uuid4().hex[:8]}"
|
||||||
try:
|
try:
|
||||||
|
|
@ -1138,7 +1275,10 @@ def _process_one_track(ctx: _RunContext, plan_item: dict) -> None:
|
||||||
if staging_file is None:
|
if staging_file is None:
|
||||||
return
|
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:
|
if new_path is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -1180,6 +1320,7 @@ def reorganize_album(
|
||||||
primary_source: Optional[str] = None,
|
primary_source: Optional[str] = None,
|
||||||
strict_source: bool = False,
|
strict_source: bool = False,
|
||||||
stop_check: Optional[Callable[[], bool]] = None,
|
stop_check: Optional[Callable[[], bool]] = None,
|
||||||
|
metadata_source: str = 'api',
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Run a single album through the post-processing pipeline.
|
"""Run a single album through the post-processing pipeline.
|
||||||
|
|
||||||
|
|
@ -1257,10 +1398,19 @@ def reorganize_album(
|
||||||
plan = plan_album_reorganize(
|
plan = plan_album_reorganize(
|
||||||
album_data, tracks,
|
album_data, tracks,
|
||||||
primary_source=primary_source, strict_source=strict_source,
|
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':
|
if plan['status'] == 'no_source_id':
|
||||||
summary['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 = (
|
err_text = (
|
||||||
f"Album '{album_data.get('title', '?')}' has placeholder metadata "
|
f"Album '{album_data.get('title', '?')}' has placeholder metadata "
|
||||||
"(Unknown Artist or numeric title) — run the 'Fix Unknown Artists' "
|
"(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
|
artist_name: str # captured at enqueue time for UI display
|
||||||
source: Optional[str] # the user's per-modal pick (None = auto)
|
source: Optional[str] # the user's per-modal pick (None = auto)
|
||||||
enqueued_at: float
|
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
|
status: str = 'queued' # queued | running | done | failed | cancelled
|
||||||
started_at: Optional[float] = None
|
started_at: Optional[float] = None
|
||||||
finished_at: Optional[float] = None
|
finished_at: Optional[float] = None
|
||||||
|
|
@ -91,6 +95,7 @@ class QueueItem:
|
||||||
'artist_id': self.artist_id,
|
'artist_id': self.artist_id,
|
||||||
'artist_name': self.artist_name,
|
'artist_name': self.artist_name,
|
||||||
'source': self.source,
|
'source': self.source,
|
||||||
|
'metadata_source': self.metadata_source,
|
||||||
'enqueued_at': self.enqueued_at,
|
'enqueued_at': self.enqueued_at,
|
||||||
'started_at': self.started_at,
|
'started_at': self.started_at,
|
||||||
'finished_at': self.finished_at,
|
'finished_at': self.finished_at,
|
||||||
|
|
@ -155,6 +160,7 @@ class ReorganizeQueue:
|
||||||
artist_id: Optional[str],
|
artist_id: Optional[str],
|
||||||
artist_name: str,
|
artist_name: str,
|
||||||
source: Optional[str] = None,
|
source: Optional[str] = None,
|
||||||
|
metadata_source: str = 'api',
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Add an album to the queue. Returns a result dict:
|
"""Add an album to the queue. Returns a result dict:
|
||||||
|
|
||||||
|
|
@ -183,6 +189,7 @@ class ReorganizeQueue:
|
||||||
artist_name=artist_name,
|
artist_name=artist_name,
|
||||||
source=source,
|
source=source,
|
||||||
enqueued_at=time.time(),
|
enqueued_at=time.time(),
|
||||||
|
metadata_source=metadata_source or 'api',
|
||||||
)
|
)
|
||||||
self._items.append(item)
|
self._items.append(item)
|
||||||
position = sum(1 for i in self._items if i.status == 'queued')
|
position = sum(1 for i in self._items if i.status == 'queued')
|
||||||
|
|
@ -190,7 +197,8 @@ class ReorganizeQueue:
|
||||||
self._cond.notify_all()
|
self._cond.notify_all()
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[Queue] Enqueued '{album_title}' (album_id={album_id}, "
|
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 {
|
return {
|
||||||
'queued': True,
|
'queued': True,
|
||||||
|
|
@ -240,6 +248,7 @@ class ReorganizeQueue:
|
||||||
artist_name=raw.get('artist_name') or 'Unknown Artist',
|
artist_name=raw.get('artist_name') or 'Unknown Artist',
|
||||||
source=raw.get('source'),
|
source=raw.get('source'),
|
||||||
enqueued_at=time.time(),
|
enqueued_at=time.time(),
|
||||||
|
metadata_source=raw.get('metadata_source') or 'api',
|
||||||
)
|
)
|
||||||
self._items.append(item)
|
self._items.append(item)
|
||||||
enqueued += 1
|
enqueued += 1
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,7 @@ def build_runner(
|
||||||
primary_source=item.source,
|
primary_source=item.source,
|
||||||
strict_source=bool(item.source),
|
strict_source=bool(item.source),
|
||||||
stop_check=is_shutting_down_fn,
|
stop_check=is_shutting_down_fn,
|
||||||
|
metadata_source=getattr(item, 'metadata_source', 'api') or 'api',
|
||||||
)
|
)
|
||||||
|
|
||||||
return runner
|
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
|
the apply endpoint, so the preview is guaranteed to match what
|
||||||
apply would actually produce.
|
apply would actually produce.
|
||||||
|
|
||||||
Optional body param ``source``: when provided, only that metadata
|
Optional body params:
|
||||||
source is queried (no fallback chain)."""
|
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:
|
try:
|
||||||
from core.library_reorganize import preview_album_reorganize
|
from core.library_reorganize import preview_album_reorganize
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
chosen_source = data.get('source') or None
|
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'))
|
transfer_dir = docker_resolve_path(config_manager.get('soulseek.transfer_path', './Transfer'))
|
||||||
result = preview_album_reorganize(
|
result = preview_album_reorganize(
|
||||||
album_id=album_id,
|
album_id=album_id,
|
||||||
|
|
@ -11110,6 +11118,7 @@ def reorganize_album_preview(album_id):
|
||||||
build_final_path_fn=_build_final_path_for_track,
|
build_final_path_fn=_build_final_path_for_track,
|
||||||
primary_source=chosen_source,
|
primary_source=chosen_source,
|
||||||
strict_source=bool(chosen_source),
|
strict_source=bool(chosen_source),
|
||||||
|
metadata_source=metadata_source,
|
||||||
)
|
)
|
||||||
if result.get('status') == 'no_album':
|
if result.get('status') == 'no_album':
|
||||||
return jsonify({"success": False, "error": "Album not found"}), 404
|
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 /
|
source (optional): per-album source pick (Spotify / iTunes /
|
||||||
Deezer / Discogs / Hydrabase). When omitted, the
|
Deezer / Discogs / Hydrabase). When omitted, the
|
||||||
orchestrator uses the configured primary with fallback.
|
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:
|
try:
|
||||||
from core.reorganize_queue import get_queue
|
from core.reorganize_queue import get_queue
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
chosen_source = data.get('source') or None
|
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
|
# Capture display fields at enqueue time so the status panel
|
||||||
# can render them without a DB lookup later.
|
# can render them without a DB lookup later.
|
||||||
|
|
@ -11150,6 +11169,7 @@ def reorganize_album_files(album_id):
|
||||||
artist_id=meta['artist_id'],
|
artist_id=meta['artist_id'],
|
||||||
artist_name=meta['artist_name'],
|
artist_name=meta['artist_name'],
|
||||||
source=chosen_source,
|
source=chosen_source,
|
||||||
|
metadata_source=metadata_source,
|
||||||
)
|
)
|
||||||
return jsonify({"success": True, **result})
|
return jsonify({"success": True, **result})
|
||||||
except Exception as e:
|
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
|
source (optional): same pick applied to every album. Per-album
|
||||||
overrides aren't supported here — use the per-album modal
|
overrides aren't supported here — use the per-album modal
|
||||||
for that.
|
for that.
|
||||||
|
mode (optional): 'api' or 'tags' applied to every album, same
|
||||||
|
shape as the per-album endpoint.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from core.reorganize_queue import get_queue
|
from core.reorganize_queue import get_queue
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
chosen_source = data.get('source') or None
|
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)
|
albums = get_database().get_artist_albums_for_reorganize(artist_id)
|
||||||
if not albums:
|
if not albums:
|
||||||
return jsonify({"success": False, "error": "No albums found for this artist"}), 404
|
return jsonify({"success": False, "error": "No albums found for this artist"}), 404
|
||||||
|
|
||||||
# Apply the user's chosen source to every album, then hand off
|
# Apply the user's chosen source + mode to every album, then
|
||||||
# to the queue's bulk-enqueue helper which owns the loop+tally.
|
# hand off to the queue's bulk-enqueue helper which owns the
|
||||||
|
# loop+tally.
|
||||||
for album in albums:
|
for album in albums:
|
||||||
album['source'] = chosen_source
|
album['source'] = chosen_source
|
||||||
|
album['metadata_source'] = metadata_source
|
||||||
result = get_queue().enqueue_many(albums)
|
result = get_queue().enqueue_many(albums)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
|
|
|
||||||
|
|
@ -3416,6 +3416,7 @@ const WHATS_NEW = {
|
||||||
'2.5.2': [
|
'2.5.2': [
|
||||||
// --- May 13, 2026 — 2.5.2 release ---
|
// --- May 13, 2026 — 2.5.2 release ---
|
||||||
{ date: '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 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: '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' },
|
{ 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],
|
// Section shape: { title, description, features: [bullet strings],
|
||||||
// usage_note?: 'optional hint shown at the bottom' }
|
// usage_note?: 'optional hint shown at the bottom' }
|
||||||
const VERSION_MODAL_SECTIONS = [
|
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",
|
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.",
|
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">';
|
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.
|
// Metadata source picker — populated from /reorganize/sources.
|
||||||
// Empty value = use configured primary (with fallback chain).
|
// Empty value = use configured primary (with fallback chain).
|
||||||
// Specific source = strict mode, that source only.
|
// 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 += '<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 += '<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">';
|
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
|
// Populate source picker after the modal mounts
|
||||||
setTimeout(() => _populateReorganizeSources(_reorganizeAlbumId), 50);
|
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) {
|
async function _populateReorganizeSources(albumId) {
|
||||||
|
|
@ -6496,10 +6525,11 @@ async function loadReorganizePreview() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const chosenSource = document.getElementById('reorganize-source-select')?.value || '';
|
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`, {
|
const response = await fetch(`/api/library/album/${_reorganizeAlbumId}/reorganize/preview`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ source: chosenSource })
|
body: JSON.stringify({ source: chosenSource, mode: chosenMode })
|
||||||
});
|
});
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
|
@ -6596,10 +6626,11 @@ async function executeReorganize() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const chosenSource = document.getElementById('reorganize-source-select')?.value || '';
|
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`, {
|
const response = await fetch(`/api/library/album/${_reorganizeAlbumId}/reorganize`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ source: chosenSource })
|
body: JSON.stringify({ source: chosenSource, mode: chosenMode })
|
||||||
});
|
});
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
if (!result.success) throw new Error(result.error);
|
if (!result.success) throw new Error(result.error);
|
||||||
|
|
@ -6690,10 +6721,21 @@ async function _showReorganizeAllModal() {
|
||||||
|
|
||||||
let html = '<div class="reorganize-content">';
|
let html = '<div class="reorganize-content">';
|
||||||
|
|
||||||
// Source picker — applies to ALL albums in this run. Albums without
|
// Mode picker — applies to ALL albums.
|
||||||
// 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">';
|
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 += '<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 += '<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">';
|
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);
|
console.error('Failed to load reorganize sources:', err);
|
||||||
}
|
}
|
||||||
}, 50);
|
}, 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() {
|
async function _executeReorganizeAll() {
|
||||||
|
|
@ -6766,14 +6816,15 @@ async function _executeReorganizeAll() {
|
||||||
const overlay = document.getElementById('reorganize-overlay');
|
const overlay = document.getElementById('reorganize-overlay');
|
||||||
if (overlay) overlay.classList.add('hidden');
|
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 chosenSource = document.getElementById('reorganize-source-select')?.value || '';
|
||||||
|
const chosenMode = document.getElementById('reorganize-mode-select')?.value || 'api';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`/api/library/artist/${artistId}/reorganize-all`, {
|
const resp = await fetch(`/api/library/artist/${artistId}/reorganize-all`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ source: chosenSource }),
|
body: JSON.stringify({ source: chosenSource, mode: chosenMode }),
|
||||||
});
|
});
|
||||||
const result = await resp.json();
|
const result = await resp.json();
|
||||||
if (!result.success) throw new Error(result.error || 'Queue request failed');
|
if (!result.success) throw new Error(result.error || 'Queue request failed');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue