diff --git a/core/library/case_folding.py b/core/library/case_folding.py index e0260d6f5..9c1d7e450 100644 --- a/core/library/case_folding.py +++ b/core/library/case_folding.py @@ -18,10 +18,10 @@ So this is not a cosmetic issue on either kind of filesystem, and a fix that only helps one of them is not a fix. -DISTINCT FROM `_keep_user_casing` (core/library_reorganize.py), which stops -reorganize CHURNING a folder's name when the source's casing differs. That is -about not renaming; this is about not creating a duplicate. Both can be true -at once, and the churn fix does not prevent the split. +Reorganize once had a separate `_keep_user_casing` helper to stop churn while +comparing provider metadata with a user's local value. That path is gone: +reorganize reads the catalogue value directly, so there is no second casing to +reconcile. The case-folding rule here still prevents duplicate directories. DELIBERATELY NOT A MERGE. This only steers NEW writes to the folder that already exists. Two folders that are already split stay split until something diff --git a/core/library/reorganize_tag_source.py b/core/library/reorganize_tag_source.py deleted file mode 100644 index de83e9f25..000000000 --- a/core/library/reorganize_tag_source.py +++ /dev/null @@ -1,327 +0,0 @@ -"""Build reorganize-planning metadata from a file's embedded tags -instead of from a live metadata-source API call. - -Issue #592 (tacobell444): when a library has been carefully enriched -+ tagged, doing a fresh API lookup at reorganize time can introduce -inconsistencies (provider naming drift, version-mismatches, missing -album-level metadata for niche releases). The user's own embedded -tags are usually the most stable source of truth for an enriched -library — and using them costs zero API calls. - -This module is the pure tag-to-context adapter. It turns the dict -that ``core.library.file_tags.read_embedded_tags`` returns into the -``api_album`` / ``api_track`` shapes that -``library_reorganize._build_post_process_context`` already consumes. -That keeps the downstream pipeline path-builder, post-process -helpers, AcoustID, etc.) completely unchanged: tag-mode just produces -the same input shape via a different upstream route. - -Pure helpers — no IO inside the extractors so every shape is -test-pinnable. The wrapper :func:`read_album_track_from_file` does -the file IO via ``read_embedded_tags`` and then routes through the -extractors. - -Returns ``None`` (extractors) / ``(None, None, reason)`` (wrapper) -when the embedded tags are missing fields essential for reorganize -(track title, album name, or track artist). The plan layer surfaces -that as an unmatched item with a clear reason — same UX as when the -metadata-API call returns no candidate. No silent degradation.""" - -from __future__ import annotations - -import os -import re -from typing import Any, Dict, List, Optional, Tuple - - -# Tokens we accept as valid `releasetype` / `albumtype` values. -# Mirrors the canonical set the rest of the metadata pipeline uses -# (`core/metadata/album_tracks.py:_normalize_album_type`). -_VALID_ALBUM_TYPES = frozenset({'album', 'single', 'ep', 'compilation'}) - - -# Match a 4-digit year anywhere in a date-like string ("2020", -# "2020-01-15", "2020/01/15", "Jan 5, 2020", etc.). -_YEAR_RE = re.compile(r'(\d{4})') - - -# Separators we split a single artist field on to recover a list. -# Mirrors the same separator set ``core/metadata/artist_resolution.py`` -# uses when normalizing soulseek matched-download artist strings. -_ARTIST_SPLIT_RE = re.compile( - r'\s*(?:,|;|/|&| feat\. | feat | ft\. | ft | featuring | x | with )\s*', - re.IGNORECASE, -) - - -def _stringify(value: Any) -> str: - """Coerce an embedded-tag value into a clean string.""" - if value is None: - return '' - return str(value).strip() - - -def _parse_int_first(value: Any) -> Optional[int]: - """Parse a track/disc number that may arrive as ``"5"``, ``"5/12"``, - ``5``, ``5.0`` or even ``"05"``. Returns the leading integer, or - ``None`` when no integer is recoverable. - - Defensive against the trailing-``/N`` shape ID3 stores: ``TRCK = - "5/12"`` means "track 5 of 12", and we want ``5``.""" - if value is None: - return None - if isinstance(value, (int,)): - return value - if isinstance(value, float): - return int(value) - s = _stringify(value) - if not s: - return None - head = s.split('/', 1)[0].strip() - try: - return int(head) - except (TypeError, ValueError): - try: - return int(float(head)) - except (TypeError, ValueError): - return None - - -def _parse_int_total(value: Any) -> Optional[int]: - """Parse the trailing ``N`` of an ID3-style ``"5/12"`` value, or - return the parsed value when it's a plain integer string.""" - if value is None: - return None - if isinstance(value, int): - return value - s = _stringify(value) - if not s: - return None - if '/' in s: - tail = s.split('/', 1)[1].strip() - try: - return int(tail) - except (TypeError, ValueError): - return None - try: - return int(s) - except (TypeError, ValueError): - return None - - -def _normalize_year(value: Any) -> str: - """Extract a 4-digit year from a date-like field. Returns '' when - no year is extractable. Reorganize templates only use the year - portion of release dates, so we don't need to preserve the full - date string.""" - s = _stringify(value) - if not s: - return '' - m = _YEAR_RE.search(s) - return m.group(1) if m else '' - - -def _normalize_album_type(value: Any) -> str: - """Lowercase + validate the ``releasetype`` tag against the canonical - token set. Returns '' for unknown values so the downstream path - builder falls back to its default.""" - s = _stringify(value).lower() - if s in _VALID_ALBUM_TYPES: - return s - return '' - - -def _split_artists(value: Any) -> List[str]: - """Split an artist-string field into a list. Handles common - separators (``,``, ``;``, ``/``, ``&``, ``feat``, ``ft``, ``x``, - ``with``). Strips whitespace, drops empties, dedupes (case- - insensitive) while preserving order.""" - s = _stringify(value) - if not s: - return [] - parts = _ARTIST_SPLIT_RE.split(s) - seen: set = set() - out: List[str] = [] - for p in parts: - cleaned = p.strip() - if not cleaned: - continue - key = cleaned.lower() - if key in seen: - continue - seen.add(key) - out.append(cleaned) - return out - - -def _resolve_track_artists(tags: Dict[str, Any]) -> List[str]: - """Resolve the per-track artist list from embedded tags. Prefers a - multi-value ``artists`` tag (TXXX:Artists / Vorbis ``artists``) - over splitting the single-string ``artist`` tag, which is exactly - the precedence the post-download enrichment uses.""" - artists_value = tags.get('artists') - if artists_value: - # Multi-value tag readers may already have joined with ', '. - # Re-split to recover the list. - parts = _split_artists(artists_value) - if parts: - return parts - return _split_artists(tags.get('artist') or '') - - -def extract_track_meta_from_tags(tags: Dict[str, Any]) -> Optional[Dict[str, Any]]: - """Build an ``api_track``-shaped dict from embedded tags. - - Returns ``None`` if essential fields are missing (title or - artist). Caller surfaces that as an unmatched plan item. - - Output shape matches what ``library_reorganize._build_post_process_context`` - consumes (``name`` / ``track_number`` / ``disc_number`` / - ``artists`` / ``duration_ms`` / ``id``).""" - if not isinstance(tags, dict) or not tags: - return None - - title = _stringify(tags.get('title')) - if not title: - return None - - artists = _resolve_track_artists(tags) - if not artists: - return None - - track_number = _parse_int_first(tags.get('tracknumber')) or 1 - disc_number = _parse_int_first(tags.get('discnumber')) or 1 - - return { - 'name': title, - 'title': title, # belt-and-braces — both keys are read downstream - 'track_number': track_number, - 'disc_number': disc_number, - 'artists': [{'name': a} for a in artists], - 'duration_ms': 0, # not derivable from tags alone; set later from `duration` - 'id': '', # tag-mode has no source ID; reorganize doesn't need one - 'uri': '', - } - - -def extract_album_meta_from_tags(tags: Dict[str, Any]) -> Dict[str, Any]: - """Build an ``api_album``-shaped dict from embedded tags. - - Falls back to empty / zero values when fields are missing — the - path builder accepts those and uses its own defaults. The album - name is the only field we can't fall back on; if missing the - caller should treat the track as unmatched (handled by - :func:`read_album_track_from_file`).""" - if not isinstance(tags, dict): - tags = {} - - album_name = _stringify(tags.get('album')) - album_artist = _stringify(tags.get('albumartist') or tags.get('album_artist')) - release_date = _normalize_year(tags.get('date') or tags.get('year') or tags.get('originaldate')) - total_tracks = ( - _parse_int_total(tags.get('totaltracks')) - or _parse_int_total(tags.get('tracktotal')) - or _parse_int_total(tags.get('tracknumber')) # may be "5/12" - or 0 - ) - album_type = _normalize_album_type(tags.get('releasetype')) - - # `total_discs` only comes from explicit total signals: a - # `totaldiscs` tag, or the trailing `/N` of an ID3-style - # `discnumber = "1/2"`. A bare `discnumber = "1"` carries no total - # and must NOT be treated as one (else single-disc albums would - # claim total=1 and the path builder would still skip the - # subfolder, but partial-album cases would underreport). - total_discs = _parse_int_total(tags.get('totaldiscs')) or 0 - discnumber_raw = _stringify(tags.get('discnumber')) - if '/' in discnumber_raw: - explicit_total = _parse_int_total(discnumber_raw) - if explicit_total: - total_discs = max(total_discs, explicit_total) - - return { - 'id': '', - 'album_id': '', - 'name': album_name, - 'title': album_name, - 'release_date': release_date, - 'total_tracks': total_tracks, - 'total_discs': total_discs, - 'image_url': '', - 'images': [], - 'album_artist': album_artist, - 'album_type': album_type, - } - - -def read_album_track_from_file( - file_path: str, - *, - read_embedded_tags_fn=None, -) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]], Optional[str]]: - """Read embedded tags from ``file_path`` and produce - ``(album_meta, track_meta, error_reason)``. - - Returns ``(None, None, reason)`` when the file can't be opened, - has no recognisable tags, or is missing essential fields (title - or artist). The reason string is human-readable and suitable for - surfacing directly in the reorganize preview/error UI. - - Args: - file_path: Resolved on-disk path to the audio file. - read_embedded_tags_fn: Optional override for the tag reader, - used by tests to avoid real mutagen IO. Defaults to - ``core.library.file_tags.read_embedded_tags``.""" - if not file_path or not isinstance(file_path, str): - return None, None, 'No file path on track row.' - - if read_embedded_tags_fn is None: - from core.library.file_tags import read_embedded_tags as _real_reader - read_embedded_tags_fn = _real_reader - - result = read_embedded_tags_fn(file_path) - if not isinstance(result, dict) or not result.get('available'): - reason = (result or {}).get('reason') if isinstance(result, dict) else None - return None, None, reason or 'Could not read embedded tags from file.' - - tags = result.get('tags') or {} - track_meta = extract_track_meta_from_tags(tags) - if track_meta is None: - return None, None, 'Embedded tags missing required title or artist.' - - album_meta = extract_album_meta_from_tags(tags) - if not album_meta.get('name'): - return None, None, 'Embedded tags missing album name.' - - # Promote duration from the file-info block onto the track meta - # so the path builder has a non-zero value if a downstream - # consumer wants it. - duration_seconds = result.get('duration') or 0 - try: - track_meta['duration_ms'] = int(float(duration_seconds) * 1000) - except (TypeError, ValueError): - track_meta['duration_ms'] = 0 - - return album_meta, track_meta, None - - -def normalize_resolved_path(file_path: Optional[str]) -> Optional[str]: - """Defensive wrapper: returns the input only when it points at a - real file. Saves the caller from another ``os.path.exists`` check - in already-noisy code paths.""" - if not file_path: - return None - try: - if not os.path.exists(file_path): - return None - except OSError: - return None - return file_path - - -__all__ = [ - 'extract_track_meta_from_tags', - 'extract_album_meta_from_tags', - 'read_album_track_from_file', - 'normalize_resolved_path', -] diff --git a/core/library/retag_planner.py b/core/library/retag_planner.py index e8457aa32..d7ac8fad7 100644 --- a/core/library/retag_planner.py +++ b/core/library/retag_planner.py @@ -1,13 +1,25 @@ -"""Pure planning logic for the library re-tag job. +"""Matching + planning for the library re-tag job. Given a source album's metadata + tracklist and the library's tracks (with their *current* file tags), this works out — per track — exactly which tags would change (the dry-run diff the finding shows) and the ``db_data`` payload to feed ``core.tag_writer.write_tags_to_file`` at apply time. +The diff itself is NOT decided here. ``core.tag_writer.build_tag_diff`` makes +it, because that is the function ``write_tags_to_file`` agrees with by +construction: the #800 placeholder guard, the #824 date normalisation and the +genre-subset guard all live in that pair. This module used to carry its own +comparison and knew none of them, so it reported changes the writer then +refused — and since a pending finding is refreshed in place rather than +re-inserted, those never went away. + +What is left here is the part the shared engine has no opinion about: pairing a +library track to a source track, shaping the source's values into a write +payload, and honouring ``mode``. + No file IO, no network, no DB: the job feeds in current tags + fetched source -data, so all the matching/diff logic stays unit-testable. Tags are only ever -ADDED/overwritten per-field — never a full tag-block wipe. +data, so all of it stays unit-testable. Tags are only ever ADDED/overwritten +per-field — never a full tag-block wipe. """ from __future__ import annotations @@ -16,9 +28,7 @@ from difflib import SequenceMatcher from typing import Any, Dict, List, Optional, Tuple -# Fields this job manages. Keys are the internal/display names; the diff and the -# write payload are both built from these. -MANAGED_FIELDS = ('title', 'artist', 'album', 'year', 'genre', 'track_number', 'disc_number') +from core.tag_writer import build_tag_diff # Modes: overwrite everything the source provides, or only fill blanks. MODE_OVERWRITE = 'overwrite' @@ -140,79 +150,102 @@ def _target_for_track(source_track: Any, album_meta: Dict[str, Any]) -> Dict[str } -def _current_value(current_tags: Dict[str, Any], field: str): - if field == 'artist': - # _read_tags stores album_artist + artist; prefer album_artist for the album-level compare. - return current_tags.get('album_artist') or current_tags.get('artist') or '' - if field == 'genre': - return current_tags.get('genre') or '' - return current_tags.get(field) - - -def _display(value) -> str: - if isinstance(value, list): - return ', '.join(str(v) for v in value) - return '' if value is None else str(value) +def _write_payload(target: Dict[str, Any]) -> Dict[str, Any]: + """The complete ``db_data`` the source implies — every field it supplied. - -def _is_empty(value) -> bool: - if value is None: - return True - if isinstance(value, str): - return value.strip() == '' - if isinstance(value, list): - return len(value) == 0 - return False + ``build_tag_diff`` compares a whole payload at once; :func:`plan_track` + then keeps only the keys whose field really changed, so an apply still + touches nothing the finding didn't show. + """ + data: Dict[str, Any] = {} + if target.get('title'): + data['title'] = target['title'] + if target.get('artist'): + data['artist_name'] = target['artist'] # album-level artist + if target.get('track_artist'): + data['track_artist'] = target['track_artist'] # per-track (compilations) + if target.get('album'): + data['album_title'] = target['album'] + if target.get('year'): + # Deliberately the year and NOT ``release_date``. The source supplies an + # album-level date while a file may carry a more specific one; with a + # year-only value build_tag_diff preserves the file's (#824) instead of + # flattening every dated file in the library on the first scan. + data['year'] = target['year'] + if target.get('genre'): + data['genres'] = target['genre'] # list + if target.get('track_number') is not None: + data['track_number'] = target['track_number'] + if target.get('track_count'): + data['track_count'] = target['track_count'] # writers want both + if target.get('disc_number') is not None: + data['disc_number'] = target['disc_number'] + return data + + +#: ``build_tag_diff``'s ``file_key`` -> the ``db_data`` keys that field writes. +#: Doubles as the list of fields this job manages: a diff row outside it (BPM) +#: is one nothing here supplies a value for. +_WRITE_KEYS = { + 'title': ('title',), + 'artist': ('track_artist',), + 'album': ('album_title',), + # BOTH keys, not just `artist_name`. `write_tags_to_file` writes the ARTIST + # tag from `track_artist or artist_name`, so a payload carrying only the + # album artist puts it in the track's ARTIST tag as well — replacing the + # per-track artists on a compilation or a DJ mix, from a finding that said + # nothing about them. + 'album_artist': ('artist_name', 'track_artist'), + 'year': ('year',), + 'genre': ('genres',), + 'track_number': ('track_number', 'track_count'), + 'disc_number': ('disc_number',), +} def plan_track(current_tags: Dict[str, Any], source_track: Any, album_meta: Dict[str, Any], mode: str = MODE_OVERWRITE) -> Dict[str, Any]: """Diff one library track's current tags against the source target. - Returns ``{changes, db_data}`` where ``changes`` is ``{field: {old, new}}`` - for display, and ``db_data`` is the (minimal) payload for - ``write_tags_to_file`` — it contains ONLY the fields that should be written - under ``mode``, so applying never touches unrelated/unchanged tags. + Returns ``{changes, db_data, protected}``: + + * ``changes`` — ``{field: {old, new}}`` for display + * ``db_data`` — the MINIMAL payload for ``write_tags_to_file``: only the + fields that should be written under ``mode`` + * ``protected`` — ``{field: {file, source}}`` for fields the writer's own + guards hold back, so the finding can say "kept yours" instead of + promising a change that will not happen + + The decision itself belongs to ``core.tag_writer.build_tag_diff``, which + is the function ``write_tags_to_file`` agrees with — the placeholder guard + (#800), the date normalisation (#824) and the genre-subset guard all live + there. A second opinion here is a finding the fix cannot resolve. """ target = _target_for_track(source_track, album_meta) + payload = _write_payload(target) + changes: Dict[str, Dict[str, str]] = {} - db_data: Dict[str, Any] = {} - - for field in MANAGED_FIELDS: - new_val = target.get(field) - if _is_empty(new_val): - continue # source gave us nothing for this field — leave the file alone - old_val = _current_value(current_tags, field) - - if mode == MODE_FILL_MISSING and not _is_empty(old_val): - continue # fill-missing only writes blanks - - old_disp, new_disp = _display(old_val), _display(new_val) - if old_disp == new_disp: - continue # already correct — nothing to write - - changes[field] = {'old': old_disp, 'new': new_disp} - # Map managed field → write_tags_to_file db_data key. - if field == 'title': - db_data['title'] = new_val - elif field == 'artist': - db_data['artist_name'] = new_val # = album artist for the writer - ta = target.get('track_artist') - if ta and ta != new_val: - db_data['track_artist'] = ta - elif field == 'album': - db_data['album_title'] = new_val - elif field == 'year': - db_data['year'] = new_val - elif field == 'genre': - db_data['genres'] = new_val # list - elif field == 'track_number': - db_data['track_number'] = new_val - elif field == 'disc_number': - db_data['disc_number'] = new_val - - # Always carry track_count alongside a track_number write (writers want both). - if 'track_number' in db_data and target.get('track_count'): - db_data['track_count'] = target['track_count'] - - return {'changes': changes, 'db_data': db_data} + protected: Dict[str, Dict[str, str]] = {} + keep: set = set() + + for row in build_tag_diff(current_tags or {}, payload): + field = row.get('file_key') + if field not in _WRITE_KEYS: + continue + if row.get('protected'): + protected[field] = {'file': row.get('file_value') or '', + 'source': row.get('db_value') or ''} + continue + if not row.get('changed'): + continue + if mode == MODE_FILL_MISSING and str(row.get('file_value') or '').strip(): + continue # fill-missing only writes blanks + changes[field] = {'old': row.get('file_value') or '', + 'new': row.get('db_value') or ''} + keep.update(_WRITE_KEYS[field]) + + return { + 'changes': changes, + 'db_data': {k: v for k, v in payload.items() if k in keep}, + 'protected': protected, + } diff --git a/core/library_reorganize.py b/core/library_reorganize.py index 819d9b91d..28a8ae17e 100644 --- a/core/library_reorganize.py +++ b/core/library_reorganize.py @@ -1,782 +1,52 @@ -"""Re-route a library album's existing files through the same -post-processing pipeline that handles fresh downloads. - -The old reorganize endpoint reinvented several wheels — its own template -engine, its own disc-number resolution from file tags, its own sidecar -sweep, its own collision detection. Each of those drifted from the -canonical post-processing path over time, producing reorganize-only -bugs (multi-disc deluxe collapsing to single-disc when even one file's -tag was missing; tracks silently skipped when their file paths didn't -resolve on disk; etc.). - -The new design follows the import page's pattern: copy each file to a -staging folder, build the same context dict the download workers -build, then call ``_post_process_matched_download`` for each one. -Post-processing already knows how to pick the right destination, write -the right tags, handle multi-disc subfolders, recreate sidecars (cover -art, lyrics), and run AcoustID verification — there's nothing for -reorganize to add on top. - -Hard requirement: the album must have at least one stored -metadata-source ID (spotify_album_id / itunes_album_id / deezer_id / -discogs_id / soul_id). With no source ID we have nothing authoritative -to ask for the canonical tracklist, and silently degrading to file -tags is exactly the failure mode the old code path produced. Albums -without a source ID are reported back to the caller and skipped -entirely. +"""Where a library album's files belong, and moving them there. + +A reorganize applies the current file-organization template to files the user +ALREADY OWNS. That is the whole job: compute a destination, move the file, +update the catalogue row. + +It used to be something else. Each file was copied into a staging folder and +pushed through ``_post_process_matched_download`` — the DOWNLOAD pipeline, an +ACCEPTANCE check for files of unknown origin. Post-processing does know how to +pick a destination and write tags, so the reuse looked free. It was not: + +* the acceptance check kept rejecting the library. Four opt-outs accumulated in + the context builder, one per report — ``is_local_import`` (#804) for the + integrity leg's duration disagreement, ``_skip_quarantine_check: 'acoustid'`` + (#1182) for a file quarantined over its OWN fingerprint (Sawano Hiroyuki + fingerprints as 澤野弘之), ``_no_album_folder_reuse`` (#829) because reuse + resolved to the folder the album was being moved OUT of. +* it re-tagged. That is the Library Re-tag job's work, from a source it can + show you first. +* it copied. ~800MB of I/O for a 20-track FLAC album, and a failure left ~40MB + a track in quarantine. + +And the tracklist came from a live provider call, which is why an album with no +stored source id could not be reorganized at all, why a preview took seconds, +and why the library's own values had to be pulled back in one exception at a +time (``_keep_user_casing`` twice, ``_keep_user_year``). + +So: the plan comes from the catalogue (:func:`_plan_from_catalogue`) and the +executor moves (:func:`reorganize_album_rename_only`). Identity is the AcoustID +Scanner's question and tags are the re-tag job's; neither of them moves anyone's +audio to answer. + +The destination is still built by ``core.imports.paths.build_final_path_for_track`` +through the same context shape post-processing uses, so a reorganize destination +and a fresh download's destination cannot drift apart. """ import errno import os import re import shutil -import threading -import time -import uuid -from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait -from dataclasses import dataclass, field from typing import Any, Callable, Dict, List, Optional, Set, Tuple -# Per-album track concurrency. Matches the download workers' per-batch -# concurrency (3) so reorganize feels comparable to a fresh download. -# -# Operational note: post-processing can spawn an ffmpeg subprocess per -# track if `lossy_copy.downsample_hires` is enabled. With 3 workers -# that's up to 3 concurrent ffmpeg processes. Acceptable for typical -# album sizes (10-20 tracks); on a giant single-album reorganize -# (50+ tracks) ffmpeg's transient memory could be noticeable but each -# subprocess is short-lived so total RAM doesn't pile up. If we ever -# see resource issues from this, drop to 2 here rather than disabling -# concurrency entirely. -_REORGANIZE_MAX_WORKERS = 3 - -# Watchdog interval — how often the orchestrator checks the worker -# pool while waiting for tasks to finish. Setting this to 30s means -# we log a warning naming any track that's been in flight longer than -# `_HUNG_WORKER_THRESHOLD` (so an operator can investigate) without -# burning CPU on a tight poll. Doesn't kill stuck threads (Python -# can't), just surfaces them. -_WATCHDOG_INTERVAL_SECONDS = 30 -_HUNG_WORKER_THRESHOLD_SECONDS = 300 # 5 min — generous; real worst-case - # is ffmpeg downsampling a long - # hi-res FLAC, ~30-60s typically. - -from core.metadata_service import ( - get_album_for_source, - get_album_tracks_for_source, - get_client_for_source, - get_primary_source, - get_source_priority, -) + from utils.logging_config import get_logger logger = get_logger("library_reorganize") -def _safe_filename(name: str) -> str: - """Strip path-illegal characters so we can use the value as a - filename component on the staging path.""" - return ''.join(c for c in (name or 'unknown') if c not in '<>:"/\\|?*').strip() or 'unknown' - - -def _normalize_album_tracks(api_tracks): - """Normalize the various provider tracklist shapes (dict-with-`items`, - bare list, ``None``) to a single list of item dicts.""" - if not api_tracks: - return [] - if isinstance(api_tracks, dict): - items = api_tracks.get('items') or [] - return items if items else [] - if isinstance(api_tracks, list): - return api_tracks - return [] - - -SUPPORTED_SOURCES = ('spotify', 'itunes', 'deezer', 'discogs', 'hydrabase') - -# Per-source album-ID column mapping on the `albums` table row. -_ALBUM_ID_COLUMNS = { - 'spotify': 'spotify_album_id', - 'itunes': 'itunes_album_id', - 'deezer': 'deezer_id', - 'discogs': 'discogs_id', - 'hydrabase': 'soul_id', - 'musicbrainz': 'musicbrainz_release_id', -} - -# Human-facing label for each source. -SOURCE_LABELS = { - 'spotify': 'Spotify', - 'itunes': 'Apple Music (iTunes)', - 'deezer': 'Deezer', - 'discogs': 'Discogs', - 'hydrabase': 'Hydrabase', -} - - -def _extract_source_ids(album_data: dict) -> Dict[str, str]: - """Pull the per-source album-ID strings off an album row.""" - return { - source: (album_data.get(column) or '') - for source, column in _ALBUM_ID_COLUMNS.items() - } - - -def available_sources_for_album(album_data: dict) -> List[dict]: - """Return the list of metadata sources the user can pick for this - album's reorganize. Every entry has both (a) a stored album ID on - the local row AND (b) an authenticated / configured client on this - SoulSync instance. - - Returns entries in source-priority order (preferred source first). - Each entry is ``{'source': str, 'label': str}``. No API calls — - purely local inspection. - """ - source_ids = _extract_source_ids(album_data) - try: - primary = get_primary_source() - except Exception: - primary = 'deezer' - - out = [] - for source in get_source_priority(primary): - if source not in SUPPORTED_SOURCES: - continue - if not source_ids.get(source): - continue - if get_client_for_source(source) is None: - continue - out.append({ - 'source': source, - 'label': SOURCE_LABELS.get(source, source), - }) - return out - - -def authed_sources() -> List[dict]: - """Return all metadata sources the user has authed/configured on - this SoulSync instance. Doesn't require any album-specific stored - ID — used by the bulk "Reorganize All" picker where each album - has its own ID coverage and we just want to know which sources - are reachable. Returned in priority order.""" - try: - primary = get_primary_source() - except Exception: - primary = 'deezer' - - out = [] - for source in get_source_priority(primary): - if source not in SUPPORTED_SOURCES: - continue - if get_client_for_source(source) is None: - continue - out.append({ - 'source': source, - 'label': SOURCE_LABELS.get(source, source), - }) - return out - - -_UNKNOWN_ARTIST_NAMES = {'unknown artist', 'unknown', ''} - - -def _is_unknown_artist(artist_name: Optional[str]) -> bool: - if not artist_name: - return True - return str(artist_name).strip().lower() in _UNKNOWN_ARTIST_NAMES - - -def _looks_like_album_id_title(album_title: Optional[str]) -> bool: - """Pre-#524 manual-import bug left some albums with a numeric - album_id stored as `albums.title`. Detect that shape so reorganize - can point the user at Unknown Artist Fixer instead of the generic - 'run enrichment' hint.""" - if not album_title: - return False - stripped = str(album_title).strip() - return len(stripped) >= 6 and stripped.isdigit() - - -def _unresolvable_reason(album_data: dict, primary_source: str, strict_source: bool) -> str: - """Reason text for albums reorganize can't place. Surfaces the - Unknown Artist Fixer hint when the row matches the bad-metadata - shape (Unknown Artist OR album-id-as-title) — that fixer reads - file tags + re-resolves metadata, which reorganize itself doesn't - do.""" - artist = album_data.get('artist_name') - title = album_data.get('title') - if _is_unknown_artist(artist) or _looks_like_album_id_title(title): - return ( - "Album has placeholder metadata (Unknown Artist or numeric " - "title) — run the 'Fix Unknown Artists' repair job to " - "recover real artist/album from file tags before reorganize" - ) - if strict_source: - return f"Source '{primary_source}' has no usable tracklist for this album" - return "No metadata source ID for this album" - - -# #767-2: a walked edition scoring below this against the on-disk files is treated -# as the WRONG edition (e.g. a 1-track single vs the 10-track deluxe scores 0.1), -# triggering the alternate-edition search. Matches the resolver's min_score. -_CANONICAL_FIT_FLOOR = 0.5 - - -def _score_edition_items(file_tracks: List[dict], items: List[dict]) -> float: - """Score a fetched provider tracklist (raw ``items``) against the on-disk - ``file_tracks`` using the canonical scorer. Normalises the provider's varied - shapes (``name``/``title``, ``duration_ms``/``duration`` seconds) first.""" - from core.metadata.canonical_version import score_release_against_files - rel = [] - for it in items or []: - dur = it.get('duration_ms') - if dur is None: - secs = it.get('duration') - dur = int(secs * 1000) if isinstance(secs, (int, float)) and secs else None - rel.append({'title': it.get('name') or it.get('title') or '', 'duration_ms': dur}) - return score_release_against_files(file_tracks, rel) if rel else 0.0 - - -def _resolve_better_edition(album_data, source_ids, file_tracks, primary_source): - """Misfit path: run the canonical resolver WITH alternate-edition expansion and, - if it lands on a genuinely different edition than the linked ones, fetch it for - organizing. Returns ``(source, album_id, api_album, items, score)`` or ``None``.""" - from core.metadata.canonical_resolver import ( - default_fetch_alternates, - default_fetch_tracklist, - resolve_canonical_for_album, - ) - art_id = str(album_data.get('artist_id') or '') - art_name = album_data.get('artist_name') or '' - title = album_data.get('title') or '' - - def _alts(source, aid): - return default_fetch_alternates( - source, aid, artist_id=art_id, artist_name=art_name, album_title=title, - ) - - try: - result = resolve_canonical_for_album( - album_source_ids=source_ids, - file_tracks=file_tracks, - fetch_tracklist=default_fetch_tracklist, - fetch_alternates=_alts, - source_priority=get_source_priority(primary_source), - primary_source=primary_source, - ) - except Exception as e: - logger.warning(f"[Reorganize] canonical resolve raised: {e}") - return None - if not result: - return None - linked = source_ids.get(result['source']) - if str(result['album_id']) == str(linked or ''): - return None # resolver chose a linked edition the walk already considered - try: - b_album = get_album_for_source(result['source'], result['album_id']) - b_items = _normalize_album_tracks( - get_album_tracks_for_source(result['source'], result['album_id']) - ) - except Exception as e: - logger.warning(f"[Reorganize] alternate edition fetch raised: {e}") - return None - if not b_album or not b_items: - return None - return result['source'], result['album_id'], b_album, b_items, result.get('score') or 0.0 - - -def _resolve_source( - album_data: dict, primary_source: str, strict_source: bool = False, - *, file_tracks: Optional[List[dict]] = None, on_better_edition=None, -): - """Walk the configured source priority looking for the first source - we have an ID for AND that returns a usable tracklist. - - When ``strict_source`` is True, only the caller-provided - ``primary_source`` is tried — no fallback. Used when the user has - explicitly picked a source in the reorganize modal: picking Spotify - means "use Spotify or fail", not "use Spotify and silently fall - back to Deezer". - - When ``file_tracks`` is supplied (and not ``strict_source``), the walked - edition is fit-scored against the on-disk files; a clear misfit triggers an - alternate-edition search (#767-2). ``on_better_edition(source, album_id, - score)`` is invoked to persist the pin when a better edition is chosen. - - Returns ``(source_name, album_meta, tracks_list)`` or ``(None, None, None)``. - """ - source_ids = _extract_source_ids(album_data) - - # #765: if a canonical release was pinned for this album (best-fit to the - # user's actual files), prefer it — so reorganize agrees with Track Number - # Repair and stops mislabelling standard albums as deluxe (#767-Bug2). Gated - # on the album row carrying a canonical, and skipped when the user explicitly - # picked a source in the modal (strict_source) — their choice wins. Falls - # through to the priority walk if the canonical fetch fails. - if not strict_source: - c_source = album_data.get('canonical_source') - c_id = album_data.get('canonical_album_id') - if c_source and c_id: - try: - api_album = get_album_for_source(c_source, c_id) - api_tracks = get_album_tracks_for_source(c_source, c_id) - items = _normalize_album_tracks(api_tracks) - if items and api_album: - return c_source, api_album, items - except Exception as e: - logger.warning(f"[Reorganize] canonical {c_source} lookup raised: {e}") - - if strict_source: - sources_to_try = [primary_source] if primary_source else [] - else: - sources_to_try = get_source_priority(primary_source) - - walk_source = walk_album = walk_items = None - for source in sources_to_try: - sid = source_ids.get(source) or '' - if not sid: - continue - try: - api_album = get_album_for_source(source, sid) - api_tracks = get_album_tracks_for_source(source, sid) - except Exception as e: - logger.warning(f"[Reorganize] {source} lookup raised: {e}") - continue - items = _normalize_album_tracks(api_tracks) - if not items or not api_album: - continue - walk_source, walk_album, walk_items = source, api_album, items - break - - # #767-2: the walk takes the first source we have an ID for, but that ID can - # point at the WRONG edition (a single enriched against the deluxe → it'd file - # the track as #2 of a 10-track album). With the on-disk tracklist in hand, - # fit-score the walked edition; only a clear misfit looks for a better-fitting - # edition. Well-fitting albums keep today's exact behavior + make no extra calls. - if not strict_source and file_tracks: - walk_fit = _score_edition_items(file_tracks, walk_items) if walk_items else 0.0 - if walk_fit < _CANONICAL_FIT_FLOOR: - better = _resolve_better_edition( - album_data, source_ids, file_tracks, primary_source, - ) - if better is not None: - b_source, b_id, b_album, b_items, b_score = better - if on_better_edition: - try: - on_better_edition(b_source, b_id, b_score) - except Exception as e: - logger.warning(f"[Reorganize] canonical pin persist failed: {e}") - logger.info( - "[Reorganize] %s: walked edition fit %.2f below floor — using " - "better-fit %s edition %s (fit %.2f)", - album_data.get('title', '?'), walk_fit, b_source, b_id, b_score, - ) - return b_source, b_album, b_items - - if walk_source: - return walk_source, walk_album, walk_items - return None, None, None - - -# Tokens that indicate a *different recording* of a track — when one -# side of a comparison has these and the other doesn't, the two are NOT -# the same track (e.g. "Bitch Don't Kill My Vibe" vs "Bitch Don't Kill -# My Vibe (Remix)" are different recordings; the tier 4 substring match -# would silently merge them otherwise). "Bonus track" is intentionally -# NOT here — it's a marketing annotation, not a recording difference. -_VERSION_DIFFERENTIATORS = frozenset({ - 'remix', 'remixed', - 'live', 'unplugged', 'concert', - 'acoustic', - 'demo', - 'extended', 'edit', - 'instrumental', 'karaoke', - 'remaster', 'remastered', 'remastering', - 'mono', 'stereo', - 'acapella', 'cappella', - 'cover', - 'reprise', - 'alternate', 'alt', - 'rehearsal', -}) - - -def _differentiators_in(norm_title: str) -> frozenset: - """Return the set of version-differentiator tokens present in a - normalized title. Used by the tier-4 matcher to reject substring - matches across different recordings of the same song.""" - if not norm_title: - return frozenset() - return frozenset(t for t in norm_title.split() if t in _VERSION_DIFFERENTIATORS) - - -# Featured-artist credit: "(feat. X)" / "[ft X]" / a trailing "feat. X". The -# parenthesised form is stripped wherever it appears; the bare form only when -# something follows it (so a song literally named "The Feat" is left alone, and -# "Defeat"/"Lift" never trip the word-boundary). Case-insensitive. -_FEAT_RE = re.compile( - r"""\s*[\(\[]\s*(?:feat|ft|featuring)\b\.?[^)\]]*[\)\]] # (feat. X) / [ft. X] - | \s+(?:feat|ft|featuring)\b\.?\s+\S.*$ # trailing feat. X ... - """, - re.IGNORECASE | re.VERBOSE, -) - -# Detection only (does a title carry ANY feat credit?) — word-boundary so -# "Defeat"/"Lift" never trip it. Used to avoid double-crediting. -_FEAT_DETECT_RE = re.compile(r"\b(?:feat|ft|featuring)\b", re.IGNORECASE) - - -def _feat_in_title_enabled() -> bool: - """Whether the user asked featured artists to live in the track title - (Settings → Metadata). Read live so the reorganize honors the same - switch the download path does. Isolated in a helper so tests can - monkeypatch it without a full config manager.""" - try: - from core.settings import config_manager - return bool(config_manager.get("metadata_enhancement.tags.feat_in_title", False)) - except Exception: - return False - - -# A folder the organizer itself writes for one disc of a multi-disc release: -# "Disc 1" / "CD 1" (the `file_organization.disc_label` setting) and "CD01" -# (the `$cdnum` template variable). Anchored and numeric so a real album called -# "Discovery" or an artist called "CD" is never mistaken for one. -_DISC_FOLDER_RE = re.compile(r'^(disc|disk|cd|volume|vol)\s*\.?\s*\d+$', re.IGNORECASE) - - -def _already_organized_by_disc(tracks) -> bool: - """Do the user's files already sit in per-disc folders? - - The single-disc cap below reads the user's layout from their track NUMBERS, - which cannot distinguish "a single-disc edition mis-matched against a deluxe" - (#1080) from "a multi-disc album that is still downloading". A part-downloaded - box set has only disc 1 on disk, uniquely numbered and inside disc 1 — exactly - what the cap keys on — so Reorganize proposed moving the album straight back - out of the disc folders the download pipeline had just created, and flipped it - back again once disc 2 arrived. - - The files settle it. SoulSync only writes a disc folder when the release IS - multi-disc, so an album already living in one is organized, not mis-matched, - and the setting gating this cap is "preserve my organization". - """ - for track in tracks or []: - path = str(track.get('file_path') or '').replace('\\', '/') - if not path: - continue # a missing file carries no evidence - parent = os.path.basename(os.path.dirname(path)) - if parent and _DISC_FOLDER_RE.match(parent): - return True - return False - - -def _preserve_casing_enabled() -> bool: - """Whether the reorganize leaves a title/album alone when the metadata - source differs from the user's file only by letter-case (#1078 QT3496: - already-organized files were flagged for cosmetic re-casing). Default on. - Isolated so tests can monkeypatch without a config manager.""" - try: - from core.settings import config_manager - return bool(config_manager.get("library.reorganize_preserve_casing", True)) - except Exception: - return True - - -def _keep_user_year(api_release_date, user_year): - """Prefer the user's own album year over the source's original-release - year when preserving is on (#1080 QT3496: a file imported as [2023] — a - reissue/edition year the user chose — was being 'corrected' to the - source's 2020 original). Returns a release_date string the path builder - reads for $year; falls back to the source value.""" - if not _preserve_casing_enabled(): - return api_release_date - uy = str(user_year or "").strip() - if len(uy) == 4 and uy.isdigit(): - src_year = str(api_release_date or "")[:4] - if uy != src_year: - return uy - return api_release_date - - -def _keep_user_casing(source_value, user_value): - """Return the USER's string when it matches the source only by case, else - the source string. Case-only means identical after casefold — so genuine - edits (punctuation, words, feat additions) still adopt the source; only - cosmetic capitalization churn is suppressed.""" - if not _preserve_casing_enabled(): - return source_value - s = str(source_value or "") - u = str(user_value or "") - if u and s and s != u and s.strip().casefold() == u.strip().casefold(): - return u - return source_value - - -def _extract_feat_credit(title: str) -> str: - """The '(feat. X)' credit substring from a title (leading space trimmed), - or '' when there's none. Lets us carry a user's own credit forward when - the API only knows the primary artist.""" - if not title: - return '' - m = _FEAT_RE.search(str(title)) - return m.group(0).strip() if m else '' - - -def _apply_feat_credit(track_name: str, normalized_artists: list, local_title: str) -> str: - """#1078: when feat_in_title is on, make sure the clean title the - reorganize builds carries the featured-artist credit — so the FILENAME - keeps it too (the tag writer re-adds it for the tag, but the filename is - built straight from this clean title and was dropping "(feat. X)"). - - Precedence when the API's own track name has no credit: - 1. featured artists from the API track's artist list (canonical names), - 2. else the credit already present in the user's file title (the API - only knows the primary — don't strip what the user curated). - A track name that already carries a credit is left untouched.""" - name = str(track_name or '') - if _FEAT_DETECT_RE.search(name): - return name - featured = [ - (a.get('name') if isinstance(a, dict) else str(a)) - for a in (normalized_artists[1:] if normalized_artists else []) - ] - featured = [f for f in featured if f] - if featured: - return f"{name} (feat. {', '.join(featured)})".strip() - credit = _extract_feat_credit(local_title) - if credit: - return f"{name} {credit}".strip() - return name - - -def _normalize_title(value) -> str: - """Lowercase + strip cosmetic punctuation and treat brackets / dashes - / slashes as word separators so the same track named slightly - differently across providers and user libraries still matches. - - Examples that should normalize equal: - - - ``Bitch, Don't Kill My Vibe - Remix`` ↔ ``Bitch, Don't Kill My Vibe (Remix)`` - - ``Don't Stop Believin'`` ↔ ``Don’t Stop Believin’`` - - ``Swimming Pools (Drank) - Extended Version`` - ↔ ``Swimming Pools (Drank) (Extended Version)`` - - ``The Chase (feat. Big Artist)`` ↔ ``The Chase`` (#914) - """ - if value is None: - return '' - out = str(value).strip() - # #914: drop featured-artist credits FIRST (while the parens are still here to - # bound the group). iTunes appends "(feat. X)" to track titles while a user's - # file is often just "The Chase" — the credit is metadata, not the song's - # identity, and leaving it in dropped the match ratio below the threshold so - # correctly-identified tracks reported as "not in the tracklist". - out = _FEAT_RE.sub('', out).lower() - # Strip characters that don't carry meaning across providers. - for ch in ('"', "'", '‘', '’', '“', '”', '.', ',', '!', '?', - '(', ')', '[', ']', '{', '}'): - out = out.replace(ch, '') - # Treat separators as whitespace so "foo - bar" and "foo (bar)" align. - for ch in ('-', '–', '—', ':', '/', '\\'): - out = out.replace(ch, ' ') - return ' '.join(out.split()) - - -# Title-match scoring grid. Each component's weight was picked to -# satisfy these design rules: -# -# 1. EXACT title alone is enough to win. -# 2. SUBSTRING at the high-confidence floor (≥0.6) is enough to win. -# 3. SUBSTRING at the lower with-tn-match floor (≥0.3) needs the -# track_number bonus to win — track_number provides the missing -# confidence. -# 4. TRACK-NUMBER alone is NOT enough — never falls through to a -# blind track-number lookup on multi-disc albums (that's the -# bug that mis-routed winecountrygames's bonus tracks). -# 5. Different version-differentiator tokens (Remix vs no-remix) -# hard-reject before scoring (see `_score_candidate`). -# -# Worked examples (with threshold = 50): -# -# exact title + tn match 100 + 20 = 120 → match -# exact title alone 100 = 100 → match -# substring ratio 1.0 (no tn match) 50 + 40 = 90 → match -# substring ratio 0.6 (no tn match) 50 + 0 = 50 → match -# substring ratio 0.5 (no tn match) 0 = 0 → no match -# substring ratio 0.45 + tn match 40 + 20 = 60 → match -# substring ratio 0.28 + tn match 0 + 20 = 20 → no match -# (Real vs "Real Real Real") -# track_number alone (no title signal) 0 + 20 = 20 → no match -# different version diffs (any inputs) hard-reject → 0 -# -# Weights are deliberately spaced so each gate is well-clear of the -# threshold; small ratio adjustments don't flip a borderline case -# unexpectedly. - -_MATCH_SCORE_THRESHOLD = 50 - -_W_EXACT_TITLE = 100 -_W_TRACK_NUMBER = 20 - -# Standalone substring (no tn match required): floor + scaled bonus. -# At ratio = floor: contribute base only. At ratio = 1.0: contribute -# base + range. Linear in between. -_W_SUBSTRING_BASE_STANDALONE = 50 -_W_SUBSTRING_RATIO_RANGE = 40 -_SUBSTRING_RATIO_FLOOR_STANDALONE = 0.6 - -# With-tn-match substring: lower floor (0.3) but slightly reduced -# base (40) so this path never beats a standalone high-ratio match -# on equal-tn ties. -_W_SUBSTRING_BASE_WITH_TN = 40 -_SUBSTRING_RATIO_FLOOR_WITH_TN = 0.3 - - -def _score_candidate( - norm_local: str, - local_tn: Optional[int], - local_diffs: frozenset, - api_norm: str, - api_tn: Optional[int], -) -> int: - """Score a single API candidate against the local track. Higher - means more confident match; 0 means no usable signal. The orchestrator - picks the highest-scoring candidate above - :data:`_MATCH_SCORE_THRESHOLD` and treats sub-threshold tracks as - unmatched (the "trust the source — if it doesn't have the track, - skip it" design policy). - - Components: - - - **Exact normalized-title match** is the strongest signal — usually - enough on its own, especially because local titles SoulSync wrote - should already match the source's text after normalization. - - **Substring containment** with a length-ratio guard handles - annotation drift like ``"The Recipe - Bonus Track"`` (local) - matching ``"The Recipe"`` (API). The ratio bonus rewards more - specific matches, so longer common prefixes win over shorter ones. - - **Track-number agreement** is a tiebreaker, never enough alone - (track_number-only would mis-route on multi-disc). - - **Version-differentiator mismatch** is a hard reject — if local - has ``Remix`` and API doesn't (or vice versa), they're different - recordings, not annotation drift. Returns 0 unconditionally. - """ - if not norm_local or not api_norm: - return 0 - - # Hard reject: version differentiators must agree exactly. ``Remix`` - # vs no-remix means different recordings, regardless of how - # otherwise-similar the titles are. - if _differentiators_in(api_norm) != local_diffs: - return 0 - - score = 0 - tn_match = local_tn is not None and api_tn == local_tn - - if api_norm == norm_local: - score += _W_EXACT_TITLE - else: - if api_norm in norm_local: - ratio = len(api_norm) / max(len(norm_local), 1) - elif norm_local in api_norm: - ratio = len(norm_local) / max(len(api_norm), 1) - else: - ratio = 0.0 - if ratio >= _SUBSTRING_RATIO_FLOOR_STANDALONE: - # Strong substring — credit regardless of tn agreement. - normalized = ( - (ratio - _SUBSTRING_RATIO_FLOOR_STANDALONE) - / (1.0 - _SUBSTRING_RATIO_FLOOR_STANDALONE) - ) - score += _W_SUBSTRING_BASE_STANDALONE + int(normalized * _W_SUBSTRING_RATIO_RANGE) - elif tn_match and ratio >= _SUBSTRING_RATIO_FLOOR_WITH_TN: - # Weaker substring (e.g., "the recipe" in "the recipe bonus - # track" at ratio 0.45) — accept ONLY because track_number - # also matches, and at slightly reduced base score. - score += _W_SUBSTRING_BASE_WITH_TN - - if tn_match: - score += _W_TRACK_NUMBER - - return score - - -def _prenormalize_api_tracks(api_tracks: List[dict]) -> List[tuple]: - """Compute ``(item, normalized_title, parsed_track_number)`` once - per API track so the matcher doesn't redo this work on every local - track. Callers that match many local tracks against the same API - list (the orchestrator's per-album loop) should hold this list and - pass it to :func:`_find_api_track`. - - For a 17-track local library matched against a 22-track API list, - avoiding re-normalization saves 17×22 = 374 normalize calls per - album reorganize.""" - out = [] - for item in api_tracks: - api_norm = _normalize_title(item.get('name') or item.get('title')) - try: - api_tn = int(item.get('track_number')) if item.get('track_number') is not None else None - except (TypeError, ValueError): - api_tn = None - out.append((item, api_norm, api_tn)) - return out - - -def _find_api_track(api_tracks, db_title: str, db_track_number) -> Optional[dict]: - """Find the API track that corresponds to a given local track row. - - ``api_tracks`` may be either a raw list of API dicts (will be - normalized internally) OR a list of pre-normalized 3-tuples from - :func:`_prenormalize_api_tracks`. The orchestrator uses the - pre-normalized form to avoid O(n*m) normalization calls; tests - use the raw list for convenience. - - Local rows carry (title, track_number) but NOT disc_number. - Multi-disc albums repeat track_numbers across discs, so a - track_number-only join would collapse the mapping. Title is the - natural disambiguator (each disc's track 1 has a different title), - but local titles drift from API titles in predictable ways: - trailing ``- Bonus Track`` annotations, ``- Remix`` vs ``(Remix)``, - etc. - - Implementation: each candidate is scored by :func:`_score_candidate`; - the highest-scoring one above :data:`_MATCH_SCORE_THRESHOLD` wins. - If nothing clears the threshold the source genuinely doesn't have a - plausible match and we return ``None`` — the orchestrator surfaces - that as ``"not in tracklist, left in place"`` rather than silently - mis-routing. - """ - norm_local = _normalize_title(db_title) - if not norm_local: - return None - try: - tn = int(db_track_number) if db_track_number is not None else None - except (TypeError, ValueError): - tn = None - local_diffs = _differentiators_in(norm_local) - - # Accept either pre-normalized candidates or raw API dicts. - if api_tracks and isinstance(api_tracks[0], tuple): - candidates = api_tracks # type: ignore[assignment] - else: - candidates = _prenormalize_api_tracks(api_tracks) # type: ignore[arg-type] - - best_item: Optional[dict] = None - best_score = 0 - best_tn_match = False - - for item, api_norm, api_tn in candidates: - score = _score_candidate(norm_local, tn, local_diffs, api_norm, api_tn) - if score < _MATCH_SCORE_THRESHOLD: - continue - tn_match = tn is not None and api_tn == tn - if score > best_score or (score == best_score and tn_match and not best_tn_match): - best_item = item - best_score = score - best_tn_match = tn_match - - return best_item - - def load_album_and_tracks(db, album_id): """Load the album row + all its track rows from the local DB. @@ -823,108 +93,93 @@ 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], - } +def _plan_from_catalogue(album_data: dict, tracks: List[dict]) -> dict: + """Catalogue planner: the album's own library rows ARE the tracklist. + + Reorganize moves files the user already owns. Where they belong is a + question about the album in the library, so the names come from the library + — the same values the Library page shows, hand-corrected titles included. + + The old planner asked a provider and then pulled the library's values back + in one exception at a time: ``_keep_user_casing`` for the album name + (#1078), again for the track title (#1078), ``_keep_user_year`` for the year + (#1080). Three patches, each added after a report, each saying the same + thing. Reading the catalogue makes all three true by construction. + + Consequences, all intended: - from core.library.reorganize_tag_source import read_album_track_from_file + * An album with no stored source id is reorganizable. Refusing it + (``status: 'no_source_id'``) was a provider requirement imposed on an + operation that needs no provider. + * The plan is offline — no per-preview API call, and no ``Invalid base62 + id`` 400s from candidate ids that were never Spotify's to begin with. + * ``total_discs`` is the layout the catalogue knows, not one a live + tracklist decides differently on each call. + + A track the library cannot name comes back ``matched=False`` with a reason + rather than being dropped, so the preview can say which one and why. + """ + artist_name = album_data.get('artist_name') or '' + # Whether any of these files ALREADY sits in a `Disc N` folder. The + # catalogue only knows the discs whose tracks have been imported, so a + # half-downloaded 2-disc album reads as single-disc and the plan would file + # it flat — pulling it out of the `Disc 1/` the download put it in, and + # pushing it back once disc 2 lands. That flip-flop is the thing this whole + # change exists to stop, so the layout on disk gets a say. + disc_layout_on_disk = False + api_album = { + 'id': '', + 'name': album_data.get('title') or '', + 'release_date': album_data.get('release_date') or album_data.get('year') or '', + 'total_tracks': album_data.get('track_count') or len(tracks), + 'image_url': album_data.get('image_url') or '', + } 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: + title = (track.get('title') or '').strip() + if not title: 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.', + 'track': track, 'api_track': None, 'matched': False, + 'reason': 'The library has no title for this track — there is ' + 'nothing to name the file after.', }) continue - - if first_album_meta is None: - first_album_meta = album_meta try: - disc = int(track_meta.get('disc_number') or 1) + disc = int(track.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. + if not disc_layout_on_disk: + parent = os.path.basename(os.path.dirname(track.get('file_path') or '')) + disc_layout_on_disk = bool(parent and _DISC_DIR_RE.match(parent)) try: - tagged_total = int(album_meta.get('total_discs') or 0) + number = int(track.get('track_number') or 0) except (TypeError, ValueError): - tagged_total = 0 - if tagged_total > max_disc: - max_disc = tagged_total - + number = 0 items.append({ 'track': track, - 'api_track': track_meta, - 'api_album': album_meta, + 'api_track': { + 'id': '', + 'name': title, + 'track_number': number or 1, + 'disc_number': disc, + 'duration_ms': track.get('duration') or 0, + 'artists': [{'name': track.get('artist_name') or artist_name}], + }, '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, - } + if disc_layout_on_disk and max_disc < 2: + max_disc = 2 return { 'status': 'planned', - 'source': 'tags', - 'api_album': first_album_meta or {}, + 'source': 'catalogue', + 'api_album': api_album, 'total_discs': max_disc, 'items': items, } @@ -933,44 +188,12 @@ def _plan_from_tags( 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, - on_better_edition: Optional[Callable[[str, str, float], None]] = 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. + """Compute the offline, catalogue-driven per-track plan. - Returns: - ``{'status': 'planned' | 'no_source_id' | 'no_tracks', - 'source': str | None, - 'api_album': dict | None, - 'total_discs': int, - 'items': [{'track': dict, 'api_track': dict | None, - 'matched': bool, 'reason': str | None}, ...]}`` - - Per-track behavior matches the orchestrator exactly: - - Match by `(normalized_title, track_number)`, then title alone, then - track_number alone. - - Tracks with no match are reported with `matched=False` and a reason. - - `disc_number` for each track comes from its matched API entry; if - unmatched, `api_track is None` and the caller decides what to do. + This intentionally has no source/mode argument. Reorganize has one truth: + the values held by the library. Provider refresh belongs to Retag and file + tags are an output of Retag, not a second path authority. """ if not tracks: return { @@ -978,92 +201,7 @@ 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() - except Exception: - primary_source = 'deezer' - - # On-disk track shape for the #767-2 fit check (duration stored in ms). - file_tracks = [ - {'duration_ms': t.get('duration') or 0, 'title': t.get('title') or ''} - for t in tracks - ] - source, api_album, api_tracks = _resolve_source( - album_data, primary_source, strict_source=strict_source, - file_tracks=file_tracks, on_better_edition=on_better_edition, - ) - if not source: - reason = _unresolvable_reason(album_data, primary_source, strict_source) - 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], - } - - total_discs = max( - (int(item.get('disc_number') or 1) for item in api_tracks), - default=1, - ) - - # Pre-normalize once so the matcher doesn't redo the work per track. - prenormalized = _prenormalize_api_tracks(api_tracks) - items = [] - for track in tracks: - api_track = _find_api_track(prenormalized, track.get('title', ''), track.get('track_number')) - if api_track is None: - items.append({ - 'track': track, 'api_track': None, 'matched': False, - 'reason': f"No matching track in {source} tracklist (likely a bonus / non-canonical track)", - }) - else: - items.append({ - 'track': track, 'api_track': api_track, 'matched': True, - 'reason': None, - }) - - # #1080 (QT3496): a SINGLE-disc user album re-matched against a MULTI-disc - # source edition (deluxe / 2-disc) picks up disc-2 track numbers and stamps - # a bogus disc prefix ("11" -> "0211"). Read the user's REAL layout from - # their own track numbers and, when it's unambiguously single-disc, organize - # by that instead of the source's disc structure. Conservative on purpose — - # only caps when BOTH hold, so genuine multi-disc is never flattened: - # * the user's track numbers don't repeat → single disc (a box set - # numbers per-disc, so 1..13 / 1..14 REPEAT → left multi-disc, #1009); - # * every user track fits within the source's disc 1 → a continuously- - # numbered 2-disc set (1..25) spills past disc 1 → left multi-disc. - # Gated on the same preserve-my-organization setting as casing/year. - if (total_discs > 1 and _preserve_casing_enabled() - and not _already_organized_by_disc(tracks)): - try: - user_nums = [int(t.get('track_number')) for t in tracks - if str(t.get('track_number') or '').strip().isdigit()] - api_disc1 = sum(1 for t in api_tracks if int(t.get('disc_number') or 1) == 1) - if (user_nums and len(user_nums) == len(set(user_nums)) - and api_disc1 and max(user_nums) <= api_disc1): - total_discs = 1 - for item in items: - if item.get('matched') and item.get('api_track'): - # shallow copy — override only the disc, keep name/track/artists - item['api_track'] = {**item['api_track'], 'disc_number': 1} - except Exception: - # never let the single-disc heuristic break the reorganize — on any - # odd data just fall back to the source's disc structure - logger.debug("single-disc cap skipped (unexpected track data)", exc_info=True) - - return { - 'status': 'planned', - 'source': source, - 'api_album': api_album, - 'total_discs': total_discs, - 'items': items, - } + return _plan_from_catalogue(album_data, tracks) def _build_post_process_context( @@ -1072,16 +210,8 @@ def _build_post_process_context( artist_name: str, album_title: str, total_discs: int, - local_title: Optional[str] = None, - local_year: Optional[str] = None, ) -> dict: - """Build the same shape `import_album_process` builds so post-process - treats this exactly like a fresh download with full Spotify-style - metadata in hand. - - ``local_title`` is the user's own current track title — used only to - carry a featured-artist credit forward when feat_in_title is on and the - API doesn't supply one (#1078).""" + """Build the download-shaped context consumed by the shared path builder.""" track_number = int(api_track.get('track_number') or 1) disc_number = int(api_track.get('disc_number') or 1) track_artists = api_track.get('artists') or [artist_name] @@ -1091,17 +221,11 @@ def _build_post_process_context( api_album_id = api_album.get('id') or api_album.get('album_id') or '' api_album_name = api_album.get('name') or api_album.get('title') or album_title - # #1078: keep the user's album-folder casing when the source differs only - # by case (album_title is the user's own library album name). - api_album_name = _keep_user_casing(api_album_name, album_title) api_album_release = ( api_album.get('release_date') or api_album.get('releaseDate') or '' ) - # #1080: keep the user's own album year ($year) — a reissue/edition year - # they imported with, not the source's original-release year. - api_album_release = _keep_user_year(api_album_release, local_year) api_album_total_tracks = ( api_album.get('total_tracks') or api_album.get('totalTracks') @@ -1118,18 +242,6 @@ def _build_post_process_context( api_album_image = first.get('url') or '' track_name = api_track.get('name') or api_track.get('title') or '' - # #1078: keep the featured-artist credit on the CLEAN title when the user - # asked for feat-in-title. The tag writer re-adds it to the tag, but the - # filename is built straight from this clean title and was silently - # dropping "(feat. X)" — flagging already-correct files for "correction". - if _feat_in_title_enabled(): - track_name = _apply_feat_credit(track_name, normalized_artists, local_title or '') - # #1078: keep the user's own title casing when the source title differs - # ONLY by case — no cosmetic rename/re-tag on already-organized files. - # Runs AFTER feat so "Song (feat. X)" vs a bare source title stays a real - # change; this only collapses pure capitalization differences. Both the - # filename and the title tag are built from this string, so they agree. - track_name = _keep_user_casing(track_name, local_title or '') return { 'spotify_artist': { @@ -1172,27 +284,19 @@ def _build_post_process_context( 'is_album_download': True, 'has_clean_spotify_data': True, 'has_full_spotify_metadata': True, - # A reorganize processes the user's OWN library files, not slskd - # transfers — same as the Import page (#804). Skips the integrity - # check's duration-agreement leg: the re-resolved API tracklist can - # legitimately disagree with the user's copy (a different master / - # long version), and that mismatch was QUARANTINING a copy of a file - # that stayed happily in the library (TheHomeGuy: 'Through Glass' - # 283s vs Discogs' 241s). Size + parse corruption legs still run. - 'is_local_import': True, - # ...and for the same reason, the AcoustID identity leg does not get to - # quarantine this file. A reorganize stages a COPY of a track the user - # ALREADY OWNS and runs it through the download post-process; when the - # fingerprint disagreed, that leg moved the copy into ss_quarantine and - # the whole run reported `failed`, so a rename that should have been a - # no-op only worked on a second attempt with "Rename only" ticked. The - # disagreement is routinely legitimate — a different master, a regional - # release, or an artist credited in another script (Sawano Hiroyuki - # fingerprints as 澤野弘之). Identity of files already in the library is - # the AcoustID Scanner's job, and that one raises a finding instead of - # moving anyone's audio. The size and parse-corruption legs still run: - # skipping identity is not skipping safety. - '_skip_quarantine_check': 'acoustid', + # `is_local_import` (#804) and `_skip_quarantine_check: 'acoustid'` + # (#1182) used to sit here. Both were opt-outs FROM the download + # post-process: a reorganize staged a copy of a file the user already + # owns and pushed it through an acceptance check for files of unknown + # origin, where the integrity leg quarantined it over a duration the + # provider disagreed with and the AcoustID leg quarantined it over its + # own fingerprint (Sawano Hiroyuki fingerprints as 澤野弘之). + # + # A reorganize does not post-process any more, so there is nothing left + # to opt out of. This context now exists for ONE purpose: handing the + # shared path builder the same shape a download hands it, so the two + # cannot drift apart. + # # Reorganize destinations must come from the CURRENT template alone. # The #829 existing-folder reuse would resolve to the folder the album # already lives in — the very folder reorganize is trying to move it @@ -1210,9 +314,6 @@ def preview_album_reorganize( transfer_dir: str, resolve_file_path_fn: Callable[[Optional[str]], Optional[str]], 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 @@ -1234,13 +335,10 @@ def preview_album_reorganize( web_server. Signature is ``(context, spotify_artist, album_info_or_none, file_ext) -> (path, ok)``. Injected so this module stays Flask-free. - primary_source: Optional override for the configured primary - source. - Returns: ``{ 'success': bool, - 'status': str, # 'planned' | 'no_album' | 'no_tracks' | 'no_source_id' + 'status': str, # 'planned' | 'no_album' | 'no_tracks' 'source': str | None, 'album': str, 'artist': str, @@ -1265,12 +363,7 @@ def preview_album_reorganize( 'tracks': [], } - 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, - ) + plan = plan_album_reorganize(album_data, tracks) artist_name = album_data.get('artist_name') or 'Unknown Artist' album_title = album_data.get('title') or 'Unknown Album' @@ -1281,23 +374,6 @@ def preview_album_reorganize( 'source': plan['source'], } - if plan['status'] == 'no_source_id': - return { - 'success': False, 'status': 'no_source_id', - **common, - 'tracks': [{ - 'track_id': t.get('id'), - 'title': t.get('title', ''), - 'track_number': t.get('track_number', 0), - 'current_path': t.get('file_path', ''), - 'new_path': '', - 'file_exists': False, 'unchanged': False, 'collision': False, - 'matched': False, - 'reason': 'No metadata source ID — run enrichment first', - 'disc_number': None, - } for t in tracks], - } - total_discs = plan['total_discs'] api_album = plan['api_album'] or {} preview_tracks = [] @@ -1346,16 +422,10 @@ def preview_album_reorganize( api_track = plan_item['api_track'] 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 + # Build the download-shaped context consumed by the shared path + # builder. The values themselves are all from the catalogue. context = _build_post_process_context( - per_item_album, api_track, artist_name, album_title, total_discs, - local_title=title, - local_year=(str(album_data.get('year')) if album_data.get('year') else None), + api_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, @@ -1492,578 +562,6 @@ def _build_album_info(context: dict) -> dict: } -@dataclass -class _RunContext: - """Bundles all state + injected dependencies a single - ``_process_one_track`` call needs. - - Hoisted out of orchestrator-local closures so the per-track - helpers can be unit-tested directly with a fake ctx, and so a - stack trace into a failing helper is intelligible (closures - captured 16+ values, none of which were visible in tracebacks). - - Thread-safety contract — read this before adding new fields: - - - ``state_lock`` MUST be held when mutating any of the - lock-protected fields below. The provided ``record_error`` - method already takes the lock; direct mutation outside that - method is the only place where future contributors might - forget. Add new mutable shared state with the same discipline. - - Lock-protected fields (mutate only inside ``state_lock``): - - summary dict — counts and errors list - src_dirs_touched set — populated by `_finalize_track` - dst_dirs_touched set — populated by `_finalize_track` - - Read-only after construction (safe to read without locking): - - album_id, api_album, artist_name, album_title, total_discs, - staging_album_dir, resolve_file_path_fn, post_process_fn, - update_track_path_fn, on_progress, stop_check, state_lock - - Side-effecting methods that take the lock internally: - - record_error() — records a per-track failure - emit() — fires on_progress callback (no lock; - assumes caller holds it when also - passing summary fields, which the - record_error and orchestrator-success - paths both do) - """ - album_id: str - api_album: dict - artist_name: str - album_title: str - total_discs: int - local_year: Optional[str] # the user's stored album year (#1080) - staging_album_dir: str - state_lock: threading.Lock # required to mutate lock-protected fields - summary: dict # LOCK-PROTECTED - src_dirs_touched: Set[str] # LOCK-PROTECTED - dst_dirs_touched: Set[str] # LOCK-PROTECTED - resolve_file_path_fn: Callable[[Optional[str]], Optional[str]] - post_process_fn: Callable[[str, dict, str], None] - update_track_path_fn: Optional[Callable[[Any, str], None]] = None - on_progress: Optional[Callable[[dict], None]] = None - stop_check: Optional[Callable[[], bool]] = None - transfer_dir: Optional[str] = None # anchors the #746 /deleted-quarantine skip - - def emit(self, **updates) -> None: - """Fire the progress callback. Caller is responsible for - holding ``state_lock`` when the updates payload includes - snapshots of lock-protected fields (so the snapshot is - coherent). Currently always called from inside the lock by - ``record_error`` and the orchestrator's success path.""" - if self.on_progress is None: - return - try: - self.on_progress(updates) - except Exception as e: - logger.debug("progress emit failed: %s", e) - - def record_error(self, track_id, title, message, kind: str = 'skipped') -> None: - with self.state_lock: - self.summary['errors'].append({ - 'track_id': track_id, - 'title': title, - 'error': message, - }) - self.summary[kind] += 1 - self.emit(**{ - kind: self.summary[kind], - 'errors': list(self.summary['errors']), - 'processed': ( - self.summary['moved'] - + self.summary['skipped'] - + self.summary['failed'] - ), - }) - - -def _stage_track(ctx: _RunContext, track_id, title, resolved_src) -> Optional[str]: - """Stage a copy of ``resolved_src`` into a per-track UUID - subdirectory under ``ctx.staging_album_dir``. - - Per-track subdirs are required for concurrent safety: post-process - calls ``_cleanup_empty_directories`` after each move, which walks - UP from the source file removing empty dirs. With a shared - ``staging_album_dir`` that walk would race with other workers' - in-flight ``makedirs``/``copy2`` calls — worker A finishing could - nuke the dir between worker B's ``makedirs`` and ``copy2``, - causing intermittent ``[WinError 3]`` / ``ENOENT`` failures. - - With per-track subdirs: - - - Worker A's cleanup walks: per-track subdir (empty after move → - removed) → ``staging_album_dir`` (still has other workers' - subdirs → not empty → walk stops). ✓ - - Worker B's stage-in: makedirs its OWN subdir, copies into - it. No interference from worker A. ✓ - """ - worker_dir = os.path.join(ctx.staging_album_dir, uuid.uuid4().hex[:8]) - try: - os.makedirs(worker_dir, exist_ok=True) - except OSError as mk_err: - ctx.record_error(track_id, title, - f"Couldn't create staging subdirectory: {mk_err}", - kind='failed') - return None - staging_file = os.path.join(worker_dir, os.path.basename(resolved_src)) - try: - shutil.copy2(resolved_src, staging_file) - except OSError as copy_err: - ctx.record_error(track_id, title, - f"Couldn't copy to staging: {copy_err}", - kind='failed') - return None - return staging_file - - -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. - - ``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( - api_album, api_track, ctx.artist_name, ctx.album_title, ctx.total_discs, - local_title=title, local_year=ctx.local_year, - ) - context_key = f"reorganize_{ctx.album_id}_{track_id}_{uuid.uuid4().hex[:8]}" - try: - ctx.post_process_fn(context_key, context, staging_file) - except Exception as pp_err: - ctx.record_error(track_id, title, - f"Post-processing failed: {pp_err}", - kind='failed') - return None - new_path = context.get('_final_processed_path') - if not new_path or not os.path.exists(new_path): - ctx.record_error(track_id, title, - 'Post-processing did not produce a final file ' - '(AcoustID rejection, quarantine, or skip).', - kind='failed') - return None - return new_path - - -def _finalize_track(ctx: _RunContext, track_id, resolved_src, new_path) -> bool: - """Update the DB row, then remove the original (in that order — DB - failure leaves the file at both locations, recoverable by library - scan; the reverse would orphan the row). Records src/dst dirs for - end-of-run cleanup, deletes per-track sidecars. - - Returns ``True`` if the track is fully landed (DB row points to - ``new_path`` AND the original is dealt with), ``False`` if DB - update failed. Caller MUST treat False as a failure for counting - purposes — the file is at both locations, the DB still points to - the old path, and counting it as "moved" overstates how many - tracks the user can actually find via the UI.""" - if ctx.update_track_path_fn: - try: - ctx.update_track_path_fn(track_id, new_path) - except Exception as db_err: - logger.warning( - f"[Reorganize] DB path update failed for {track_id}: {db_err} " - f"— leaving original at {resolved_src} so the library scan can recover." - ) - return False - if os.path.normpath(resolved_src) == os.path.normpath(new_path): - return True # in-place edit; DB already correct, nothing to remove - with ctx.state_lock: - ctx.src_dirs_touched.add(os.path.dirname(resolved_src)) - ctx.dst_dirs_touched.add(os.path.dirname(new_path)) - - # Discord report (Foxxify): users with lossy-copy enabled have - # `track.flac` AND `track.opus` side-by-side. The DB tracks ONE - # (the lossy copy). Reorganize used to move only the canonical - # and leave the orphan behind, blocking empty-folder cleanup. - # Move sibling-format audio to the same destination dir BEFORE - # removing the canonical source, preserving both formats with - # the canonical's renamed stem. - siblings = _find_sibling_audio_files(resolved_src) - for sibling_src in siblings: - moved_to = _move_sibling_to_destination(sibling_src, new_path) - if moved_to: - logger.debug( - "[Reorganize] Moved sibling-format file alongside canonical: %s", - moved_to, - ) - - try: - os.remove(resolved_src) - except OSError as rm_err: - logger.warning(f"[Reorganize] Couldn't remove original {resolved_src}: {rm_err}") - _delete_track_sidecars(resolved_src) - return True - - -def _process_one_track(ctx: _RunContext, plan_item: dict) -> None: - """Process a single plan item end-to-end. Safe to call concurrently - from multiple workers — all shared-state mutations go through - ``ctx.state_lock`` (via ``record_error`` and ``_finalize_track``).""" - if ctx.stop_check and ctx.stop_check(): - return - track = plan_item['track'] - title = track.get('title', 'Unknown') - track_id = track.get('id') - ctx.emit(current_track=title) - - if not plan_item['matched']: - ctx.record_error(track_id, title, - plan_item.get('reason') or 'No matching API track') - return - - db_path = track.get('file_path') - resolved_src = ctx.resolve_file_path_fn(db_path) if db_path else None - if not resolved_src: - ctx.record_error(track_id, title, - f"File not found on disk — DB path: {db_path or '(empty)'}") - return - - # #746: leave duplicate-cleaner quarantine files (/deleted) - # where they are. Matches the preview's skip so apply never yanks a file - # back out of /deleted. (Mirrors the preview guard in - # preview_album_reorganize.) - if _is_in_deleted_quarantine(resolved_src, ctx.transfer_dir): - ctx.record_error(track_id, title, - 'In deleted/quarantine folder — skipped') - return - - staging_file = _stage_track(ctx, track_id, title, resolved_src) - if staging_file is None: - return - - 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 - - finalized = _finalize_track(ctx, track_id, resolved_src, new_path) - if not finalized: - # File landed at new_path but DB row + original-removal didn't. - # User can still find the track (library scan will re-index from - # new_path), but we can't honestly count it as "moved" — that - # would overstate how many tracks the UI knows are at their new - # locations. Surfacing as failed lets the user see something - # needs attention (per kettui's PR #377 review). - ctx.record_error( - track_id, title, - 'Track landed at new location but DB update failed — ' - 'file is at both old and new paths until library scan re-indexes.', - kind='failed', - ) - return - - with ctx.state_lock: - ctx.summary['moved'] += 1 - ctx.emit( - moved=ctx.summary['moved'], - processed=ctx.summary['moved'] + ctx.summary['skipped'] + ctx.summary['failed'], - ) - - -def reorganize_album( - *, - album_id: str, - db, - staging_root: str, - resolve_file_path_fn: Callable[[Optional[str]], Optional[str]], - post_process_fn: Callable[[str, dict, str], None], - update_track_path_fn: Optional[Callable[[object, str], None]] = None, - cleanup_empty_dir_fn: Optional[Callable[[str], None]] = None, - transfer_dir: Optional[str] = None, - on_progress: Optional[Callable[[dict], None]] = None, - 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. - - See module docstring for the rationale. Dependencies (file - resolution, post-processing, DB-path update, empty-dir cleanup) - are injected so the orchestrator stays in ``core/`` and is unit - testable without spinning up the Flask app. - - Args: - album_id: Library album ID. - db: Database object exposing ``_get_connection()``. - staging_root: Root staging directory under the user's download - path. A per-album subfolder is created beneath it; the - whole subfolder is removed at the end of the run. - resolve_file_path_fn: Resolves a DB-stored file path to the - actual on-disk path (or ``None`` if missing). Injected - because the resolution logic lives in ``web_server``. - post_process_fn: ``_post_process_matched_download``. Must set - ``context['_final_processed_path']`` on success. - update_track_path_fn: Called as - ``update_track_path_fn(track_id, new_path)`` after each - successful post-process to update the DB row. ``None`` to - skip (e.g. in tests). - cleanup_empty_dir_fn: Called with each source directory we - emptied so the caller can prune empty parents. ``None`` to - skip. - on_progress: Optional callback for live status updates. - Receives a dict with any subset of the standard reorganize - state keys (``current_track``, ``processed``, ``moved``, - ``skipped``, ``failed``, ``errors``). - primary_source: Override for the configured primary source. - Defaults to ``get_primary_source()``. - stop_check: Returns True when the caller wants the reorganize - to abort early (e.g. server shutdown). - - Returns: - Status summary dict with ``status`` ∈ ``{'completed', - 'no_album', 'no_tracks', 'no_source_id'}`` plus per-track - counters. - """ - summary = { - 'status': 'completed', - 'source': None, - 'total': 0, - 'moved': 0, - 'skipped': 0, - 'failed': 0, - 'errors': [], - } - - state_lock = threading.Lock() - - def _emit(**updates): - if on_progress is None: - return - try: - on_progress(updates) - except Exception as e: - logger.debug("reorganize progress callback failed: %s", e) - - # Load album + tracks - album_data, tracks = load_album_and_tracks(db, album_id) - if album_data is None: - summary['status'] = 'no_album' - return summary - - if not tracks: - summary['status'] = 'no_tracks' - return summary - - summary['total'] = len(tracks) - _emit(total=len(tracks)) - - # #767-2: persist the canonical pin when the resolver lands on a better-fit - # edition than the linked one, so Track Number Repair + future runs agree and - # we don't re-resolve every time. Never overrides a manually-locked pin (the - # set_album_canonical SQL guard from #758 enforces that). - def _persist_canonical(source, alt_album_id, score): - try: - db.set_album_canonical(album_id, source, alt_album_id, score) - except Exception as e: - logger.warning("[Reorganize] set_album_canonical failed: %s", e) - - # Build the per-track plan (same logic the preview uses). - 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, - on_better_edition=_persist_canonical, - ) - if plan['status'] == 'no_source_id': - summary['status'] = 'no_source_id' - 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' " - "repair job to recover real artist/album from file tags first." - ) - else: - err_text = ( - f"No reachable metadata source ID for '{album_data.get('title', '?')}' — " - "run enrichment first to populate at least one of " - "spotify_album_id / itunes_album_id / deezer_id / discogs_id / soul_id." - ) - summary['errors'].append({'error': err_text}) - return summary - - source = plan['source'] - api_album = plan['api_album'] - total_discs = plan['total_discs'] - summary['source'] = source - logger.info( - f"[Reorganize] Album '{album_data.get('title')}' resolved via {source}: " - f"{len(plan['items'])} item(s) planned" - ) - - # Per-album staging dir under the configured download path. Cleaned - # up (best-effort) at the end of the run regardless of outcome. - artist_name = album_data.get('artist_name') or 'Unknown Artist' - album_title = album_data.get('title') or 'Unknown Album' - staging_album_dir = os.path.join( - staging_root, - f"{_safe_filename(artist_name)} - {_safe_filename(album_title)}_{uuid.uuid4().hex[:8]}", - ) - try: - os.makedirs(staging_album_dir, exist_ok=True) - except OSError as e: - summary['status'] = 'setup_failed' - summary['errors'].append({ - 'error': f"Couldn't create staging directory '{staging_album_dir}': {e}", - }) - return summary - - src_dirs_touched: Set[str] = set() - dst_dirs_touched: Set[str] = set() - - ctx = _RunContext( - album_id=str(album_id), - api_album=api_album or {}, - artist_name=artist_name, - album_title=album_title, - total_discs=total_discs, - local_year=(str(album_data.get('year')) if album_data.get('year') else None), - staging_album_dir=staging_album_dir, - state_lock=state_lock, - summary=summary, - src_dirs_touched=src_dirs_touched, - dst_dirs_touched=dst_dirs_touched, - resolve_file_path_fn=resolve_file_path_fn, - post_process_fn=post_process_fn, - update_track_path_fn=update_track_path_fn, - on_progress=on_progress, - stop_check=stop_check, - transfer_dir=transfer_dir, - ) - - try: - # 3 concurrent workers per album — matches the download-side - # batch worker count. Post-process has its own per-context-key - # lock so concurrent calls don't race on the same file, and - # all shared-state mutations here are inside `state_lock`. - # - # Wait loop with a periodic watchdog: instead of blocking - # indefinitely on `as_completed`, we wake every - # `_WATCHDOG_INTERVAL_SECONDS` so we can react to stop_check - # promptly AND log a warning if any track has been processing - # for longer than `_HUNG_WORKER_THRESHOLD_SECONDS`. We can't - # kill the thread (Python doesn't allow that cleanly), but - # surfacing it lets operators investigate. - with ThreadPoolExecutor( - max_workers=_REORGANIZE_MAX_WORKERS, - thread_name_prefix='Reorganize', - ) as executor: - future_to_item = { - executor.submit(_process_one_track, ctx, item): item - for item in plan['items'] - } - future_started_at = {f: time.monotonic() for f in future_to_item} - pending = set(future_to_item.keys()) - warned_about: Set[Any] = set() - - while pending: - if stop_check and stop_check(): - for f in pending: - f.cancel() - break - - done, pending = wait( - pending, - timeout=_WATCHDOG_INTERVAL_SECONDS, - return_when=FIRST_COMPLETED, - ) - for finished in done: - try: - finished.result() - except Exception as worker_err: - logger.error( - f"[Reorganize] Worker raised: {worker_err}", - exc_info=True, - ) - - # Watchdog pass — log once per stuck future. - now = time.monotonic() - for f in pending: - if f in warned_about: - continue - elapsed = now - future_started_at[f] - if elapsed >= _HUNG_WORKER_THRESHOLD_SECONDS: - item = future_to_item.get(f, {}) - track_title = (item.get('track') or {}).get('title', 'Unknown') - logger.warning( - f"[Reorganize] Worker stuck for {elapsed:.0f}s on track " - f"'{track_title}' — leaving it running, other workers continuing." - ) - warned_about.add(f) - - finally: - # Best-effort cleanup of the staging dir. - try: - if os.path.isdir(staging_album_dir): - shutil.rmtree(staging_album_dir, ignore_errors=True) - except Exception: # noqa: S110 — finally-block cleanup, logger may be torn down - pass - - # Best-effort cleanup of source directories. For each touched dir - # that has no audio files left (i.e. every track in this dir was - # successfully moved), delete album-level sidecars (cover.jpg, - # folder.jpg, etc.) so the dir is empty enough for the empty-dir - # pruner to take it. If audio remains (a track failed to move), - # leave everything alone so the user can see what's still there. - for src_dir in src_dirs_touched: - try: - if _has_remaining_audio(src_dir): - continue - _delete_album_sidecars(src_dir) - except Exception: # noqa: S110 — finally-block cleanup, logger may be torn down - pass - - for src_dir in src_dirs_touched: - # Injected pruner (transfer-dir-bounded — works when the library lives - # under the transfer folder) PLUS the library-safe upward prune (#985) - # for libraries that don't. - if cleanup_empty_dir_fn: - try: - cleanup_empty_dir_fn(src_dir) - except Exception: # noqa: S110 — finally-block cleanup, logger may be torn down - pass - try: - _prune_empty_source_dirs(src_dir) - except Exception: # noqa: S110 — finally-block cleanup, logger may be torn down - pass - - # Prune empty *destination* siblings — e.g. when a previous - # failed reorganize attempt left ``Artist/Album-Sibling/`` dirs - # behind that we never end up using, OR when a current-run - # post-process created a destination dir then failed AcoustID - # before landing the file. Walk up from any successful - # destination to the artist folder, then prune one level of - # empty children. Bounded depth = safer than recursive sweep. - if transfer_dir and dst_dirs_touched: - artist_dirs = set() - for dst in dst_dirs_touched: - artist = _find_artist_dir(dst, transfer_dir) - if artist: - artist_dirs.add(artist) - for artist_dir in artist_dirs: - _prune_empty_album_dirs(artist_dir) - - return summary - - def _rename_track_in_place(current_abs: str, new_abs: str) -> Tuple[bool, Optional[str]]: """Move ONE file from ``current_abs`` to ``new_abs`` in place — no copy, no re-tag, no post-processing. Creates the destination folder, carries sibling-format files @@ -2080,10 +578,7 @@ def _rename_track_in_place(current_abs: str, new_abs: str) -> Tuple[bool, Option if os.path.exists(new_abs) and not same: return False, 'destination already exists' os.makedirs(os.path.dirname(new_abs), exist_ok=True) - # Carry sibling-format audio to the same destination with the renamed stem — - # mirrors _finalize_track so lossy-copy pairs don't get orphaned. - for sibling_src in _find_sibling_audio_files(current_abs): - _move_sibling_to_destination(sibling_src, new_abs) + siblings = _find_sibling_audio_files(current_abs) try: os.rename(current_abs, new_abs) except OSError as e: @@ -2091,11 +586,116 @@ def _rename_track_in_place(current_abs: str, new_abs: str) -> Tuple[bool, Option shutil.move(current_abs, new_abs) # crosses a filesystem boundary else: raise + # Only once the audio has landed: a failed move must leave the whole + # track, sidecars and lossy copies included, where it was. Carrying the + # siblings first meant a failed rename left the .opus at the new path + # while the .flac and the catalogue row still named the old one. + for sibling_src in siblings: + _move_sibling_to_destination(sibling_src, new_abs) + _move_track_sidecars(current_abs, new_abs) return True, None except Exception as e: return False, str(e) +def _move_track_sidecars(current_abs: str, new_abs: str) -> None: + """Carry a track's own sidecars (.lrc/.nfo/.txt/.cue/.json) to the new stem. + + The full-mode reorganize could DELETE these at the source, because + post-processing re-created them at the destination from the provider. A move + has no such second half — leaving them behind loses hand-written lyrics. + + Best-effort and never destructive: a sidecar already at the destination is + left exactly as it is. + """ + src_dir = os.path.dirname(current_abs) + dst_dir = os.path.dirname(new_abs) + src_stem = os.path.splitext(os.path.basename(current_abs))[0] + dst_stem = os.path.splitext(os.path.basename(new_abs))[0] + for ext in _TRACK_SIDECAR_EXTS: + src_side = os.path.join(src_dir, src_stem + ext) + if not os.path.isfile(src_side): + continue + dst_side = os.path.join(dst_dir, dst_stem + ext) + if os.path.exists(dst_side): + continue + try: + os.rename(src_side, dst_side) + except OSError as e: + if getattr(e, 'errno', None) == errno.EXDEV: + try: + shutil.move(src_side, dst_side) + except Exception as move_err: + logger.debug("[Reorganize] sidecar %s not moved: %s", src_side, move_err) + else: + logger.debug("[Reorganize] sidecar %s not moved: %s", src_side, e) + + +def _album_dir_for_track_dir(directory: str) -> str: + """Return the album root for a track directory. + + In a ``Disc N`` layout the artwork normally lives one level above the + audio. Otherwise the track directory itself is the album root. + """ + directory = os.path.normpath(directory) + if _DISC_DIR_RE.match(os.path.basename(directory)): + return os.path.dirname(directory) + return directory + + +def _tree_has_audio(directory: str) -> bool: + """Whether ``directory`` or any descendant still contains audio.""" + try: + for _root, _dirs, files in os.walk(directory): + if any(os.path.splitext(name)[1].lower() in _AUDIO_EXTS for name in files): + return True + except OSError: + return True # uncertainty must preserve data + return False + + +def _move_album_sidecars(source_dir: str, destination_dir: str) -> int: + """Move cover art and album sidecars after ALL source audio has left. + + Existing destination files are never overwritten. Unrecognised real + content (for example a PDF booklet) stays at the source. OS junk stays too; + moving ``.DS_Store`` would add no value and can only create collisions. + """ + if not source_dir or not destination_dir: + return 0 + source_dir = os.path.normpath(source_dir) + destination_dir = os.path.normpath(destination_dir) + if source_dir == destination_dir or _tree_has_audio(source_dir): + return 0 + + from core.library.residual_files import is_image, is_sidecar + + moved = 0 + try: + names = os.listdir(source_dir) + except OSError: + return 0 + for name in names: + if not (is_image(name) or is_sidecar(name)): + continue + source = os.path.join(source_dir, name) + destination = os.path.join(destination_dir, name) + if not os.path.isfile(source) or os.path.exists(destination): + continue + try: + os.makedirs(destination_dir, exist_ok=True) + try: + os.rename(source, destination) + except OSError as exc: + if getattr(exc, 'errno', None) != errno.EXDEV: + raise + shutil.move(source, destination) + moved += 1 + except Exception as exc: # best effort; never risk the audio move + logger.debug("[Reorganize] album sidecar %s not moved: %s", source, exc) + return moved + + def reorganize_album_rename_only( *, album_id: str, @@ -2106,9 +706,6 @@ def reorganize_album_rename_only( update_track_path_fn: Optional[Callable[[object, str], None]] = None, cleanup_empty_dir_fn: Optional[Callable[[str], None]] = None, on_progress: Optional[Callable[[dict], None]] = None, - primary_source: Optional[str] = None, - strict_source: bool = False, - metadata_source: str = 'api', stop_check: Optional[Callable[[], bool]] = None, preview_fn: Optional[Callable] = None, ) -> dict: @@ -2142,8 +739,6 @@ def _emit(**updates): album_id=album_id, db=db, transfer_dir=transfer_dir, resolve_file_path_fn=resolve_file_path_fn, build_final_path_fn=build_final_path_fn, - primary_source=primary_source, strict_source=strict_source, - metadata_source=metadata_source, ) summary['source'] = preview.get('source') if not preview.get('success'): @@ -2153,6 +748,7 @@ def _emit(**updates): tracks = preview.get('tracks', []) summary['total'] = len(tracks) src_dirs_touched: Set[str] = set() + album_dir_moves: Dict[str, str] = {} for t in tracks: if stop_check and stop_check(): @@ -2223,11 +819,23 @@ def _emit(**updates): _emit(failed=summary['failed'], errors=list(summary['errors'])) continue if current_abs: - src_dirs_touched.add(os.path.dirname(current_abs)) + source_track_dir = os.path.dirname(current_abs) + destination_track_dir = os.path.dirname(new_abs) + src_dirs_touched.add(source_track_dir) + album_dir_moves[_album_dir_for_track_dir(source_track_dir)] = ( + _album_dir_for_track_dir(destination_track_dir) + ) summary['moved'] += 1 _emit(moved=summary['moved'], processed=summary['moved'] + summary['skipped'] + summary['failed']) + # Only after all successful track moves: carry the album's cover/scan art, + # NFO, cue and playlist files. The helper refuses while ANY source audio + # remains, so a partial or failed run never splits an album's sidecars away + # from tracks that stayed behind. + for source_album_dir, destination_album_dir in album_dir_moves.items(): + _move_album_sidecars(source_album_dir, destination_album_dir) + for src_dir in src_dirs_touched: if cleanup_empty_dir_fn: try: @@ -2242,63 +850,6 @@ def _emit(**updates): return summary -def _find_artist_dir(dest_path: str, transfer_dir: str) -> Optional[str]: - """Walk up from ``dest_path`` until the parent equals ``transfer_dir``; - the directory at that point is the artist folder. Returns None if - ``dest_path`` isn't inside ``transfer_dir`` at all.""" - if not transfer_dir: - return None - transfer_norm = os.path.normpath(transfer_dir) - cur = os.path.normpath(dest_path) - while True: - parent = os.path.dirname(cur) - if parent == cur: - return None # filesystem root - if os.path.normpath(parent) == transfer_norm: - return cur - cur = parent - - -def _prune_empty_album_dirs(artist_dir: str) -> None: - """Remove direct subdirectories of ``artist_dir`` that are empty. - Single-level prune: deliberately doesn't recurse — we want to - catch leftover album-sibling folders without aggressively touching - the user's nested directory tree. - - Also walks one level deeper into each album dir to remove empty - Disc-N subfolders that previous runs may have created.""" - if not os.path.isdir(artist_dir): - return - try: - children = list(os.listdir(artist_dir)) - except OSError: - return - for entry in children: - album_path = os.path.join(artist_dir, entry) - if not os.path.isdir(album_path): - continue - # First pass: prune empty Disc-N subfolders inside this album. - try: - for sub in list(os.listdir(album_path)): - disc_path = os.path.join(album_path, sub) - if os.path.isdir(disc_path): - try: - if not os.listdir(disc_path): - os.rmdir(disc_path) - except OSError: - pass - except OSError: - pass - # Then: if the whole album dir is now empty, prune it. - try: - if not os.listdir(album_path): - os.rmdir(album_path) - logger.info(f"[Reorganize] Pruned empty album dir: {album_path}") - except OSError: - pass - - -# A disc subfolder inside an album: "Disc 1", "CD 2", "Disk 01", "Vol. 3", etc. _DISC_DIR_RE = re.compile(r'^(disc|disk|cd|vol|volume)\s*\.?\s*\d+$', re.IGNORECASE) @@ -2432,6 +983,15 @@ def _move_sibling_to_destination(sibling_src: str, canonical_dst: str) -> Option sibling_dst = os.path.join(dst_dir, canonical_stem + sibling_ext) if os.path.normpath(sibling_src) == os.path.normpath(sibling_dst): return sibling_dst # already at the right place + if os.path.exists(sibling_dst): + # The canonical move refuses this outright rather than destroy a file + # nobody asked about; `shutil.move` is `os.rename` on one filesystem and + # would have overwritten it without a word. + logger.warning( + "[Reorganize] Left sibling-format file %s alone: %s already exists", + sibling_src, sibling_dst, + ) + return None try: os.makedirs(dst_dir, exist_ok=True) shutil.move(sibling_src, sibling_dst) @@ -2442,63 +1002,3 @@ def _move_sibling_to_destination(sibling_src: str, canonical_dst: str) -> Option sibling_src, sibling_dst, e, ) return None - - -def _delete_track_sidecars(audio_path: str) -> None: - """Delete per-track sidecars (.lrc / .nfo / .txt / .cue / .json) that - sit alongside `audio_path` and share its filename stem. Best-effort — - individual failures are logged at debug and never raised.""" - src_dir = os.path.dirname(audio_path) - stem = os.path.splitext(os.path.basename(audio_path))[0] - for ext in _TRACK_SIDECAR_EXTS: - sidecar = os.path.join(src_dir, stem + ext) - if os.path.isfile(sidecar): - try: - os.remove(sidecar) - except OSError as e: - logger.debug(f"[Reorganize] Couldn't remove sidecar {sidecar}: {e}") - - -def _delete_album_sidecars(src_dir: str) -> None: - """Delete album-level *residual* files from ``src_dir`` — any cover/scan image, - lyric/metadata sidecar (.lrc/.nfo/.cue/.m3u), or OS junk. Called during - end-of-run cleanup ONLY when no audio remains in the directory, so everything - here is leftover from the album that just moved (#891 — previously this only - removed a fixed list of cover names, so ``back.jpg`` / ``disc.jpg`` / ``.webp`` - survived and kept the folder un-prunable). - - Uses the shared ``is_disposable`` predicate so it agrees with the Empty Folder - Cleaner on what's a dead leftover; anything unrecognized (a booklet ``.pdf``, a - video) is deliberately LEFT. Best-effort — individual failures are debug-logged.""" - from core.library.residual_files import is_disposable - try: - entries = os.listdir(src_dir) - except OSError: - return - for name in entries: - if not is_disposable(name): - continue - full = os.path.join(src_dir, name) - if os.path.isfile(full): - try: - os.remove(full) - except OSError as e: - logger.debug(f"[Reorganize] Couldn't remove residual file {full}: {e}") - - -def _has_remaining_audio(directory: str) -> bool: - """Return True if `directory` contains any audio files. Used as the - safety check before stripping album-level sidecars: if a track - failed to move, leave its cover art and friends in place.""" - if not os.path.isdir(directory): - return False - try: - for name in os.listdir(directory): - full = os.path.join(directory, name) - if not os.path.isfile(full): - continue - if os.path.splitext(name)[1].lower() in _AUDIO_EXTS: - return True - except OSError: - return True # Safer to assume "yes, leave it" if we can't check - return False diff --git a/core/metadata/canonical_resolver.py b/core/metadata/canonical_resolver.py index 784265919..b05cc468a 100644 --- a/core/metadata/canonical_resolver.py +++ b/core/metadata/canonical_resolver.py @@ -347,17 +347,18 @@ def resolve_and_store_canonical_for_album( Returns the resolved ``{source, album_id, score}`` or None when unresolved. ``store=False`` resolves without writing — used by the backfill job's dry run. - Uses the SAME album/source-id loader the Reorganizer uses - (``load_album_and_tracks`` + ``_extract_source_ids``) so the canonical is - chosen over exactly the source IDs the reorganizer sees. Scores off the DB + Uses the SAME album loader the Reorganizer uses (``load_album_and_tracks``) + and the shared source-id map, so the canonical is chosen over exactly the + source IDs the rest of the app sees. Scores off the DB track rows' ``duration`` (stored in ms) + ``title`` — the library's view of the files — so no per-file disk reads are needed.""" - from core.library_reorganize import _extract_source_ids, load_album_and_tracks + from core.library_reorganize import load_album_and_tracks + from core.metadata.registry import extract_album_source_ids album_data, tracks = load_album_and_tracks(db, album_id) if not album_data or not tracks: return None - source_ids = {s: v for s, v in _extract_source_ids(album_data).items() if v} + source_ids = {s: v for s, v in extract_album_source_ids(album_data).items() if v} if not source_ids: return None diff --git a/core/metadata/canonical_version.py b/core/metadata/canonical_version.py index 1f54a1e81..892c3e63b 100644 --- a/core/metadata/canonical_version.py +++ b/core/metadata/canonical_version.py @@ -208,7 +208,7 @@ def pick_canonical_release( # Album sources the canonical system reads (mirror of -# core.library_reorganize._ALBUM_ID_COLUMNS — a test pins them in sync). A manual +# core.metadata.registry.ALBUM_SOURCE_ID_COLUMNS — a test pins them in sync). A manual # match on any of these should pin/lock the canonical version (#758); a match on # a source the canonical tools don't read (e.g. lastfm) has no version to pin. CANONICAL_ALBUM_SOURCES = frozenset({'spotify', 'itunes', 'deezer', 'discogs', 'hydrabase', 'musicbrainz'}) diff --git a/core/metadata/registry.py b/core/metadata/registry.py index 9b040e13d..d862c7528 100644 --- a/core/metadata/registry.py +++ b/core/metadata/registry.py @@ -19,6 +19,34 @@ MetadataClientFactory = Callable[[], Any] METADATA_SOURCE_PRIORITY = ("deezer", "itunes", "spotify", "discogs", "hydrabase", "musicbrainz", "jiosaavn", "bandcamp") +#: The ``albums`` column carrying each source's album id. One definition, +#: because three of them drifted: reorganize accepted all six, the re-tag job +#: four, and a Discogs- or Hydrabase-matched album was therefore reorganizable +#: but not re-taggable — silently, by falling out of a ``continue``. +ALBUM_SOURCE_ID_COLUMNS = { + "spotify": "spotify_album_id", + "itunes": "itunes_album_id", + "deezer": "deezer_id", + "discogs": "discogs_id", + "hydrabase": "soul_id", + "musicbrainz": "musicbrainz_release_id", +} + +def extract_album_source_ids(album_row) -> Dict[str, str]: + """Pull the per-source album-ID strings off an album row. + + Lived in ``core.library_reorganize`` while the reorganizer was the only + caller that needed to know which provider an album is matched to. It plans + from the catalogue now and asks no provider anything, so the helper belongs + with the column map — where the canonical resolver, which DOES need it, can + reach it without importing the reorganizer. + """ + return { + source: (album_row.get(column) or '') + for source, column in ALBUM_SOURCE_ID_COLUMNS.items() + } + + METADATA_SOURCE_LABELS = { "spotify": "Spotify", "itunes": "iTunes", diff --git a/core/metadata_service.py b/core/metadata_service.py index 2b07647ae..b7074b313 100644 --- a/core/metadata_service.py +++ b/core/metadata_service.py @@ -38,6 +38,8 @@ ) from core.metadata.lookup import MetadataLookupOptions from core.metadata.registry import ( + ALBUM_SOURCE_ID_COLUMNS, + extract_album_source_ids, METADATA_SOURCE_PRIORITY, clear_cached_metadata_client, clear_cached_metadata_clients, diff --git a/core/reorganize_queue.py b/core/reorganize_queue.py index ea4d57301..78779af31 100644 --- a/core/reorganize_queue.py +++ b/core/reorganize_queue.py @@ -8,20 +8,13 @@ Design rules: -- **Single global queue**, single worker thread. Reorganize is - I/O-heavy (file copy, mutagen tagging, AcoustID, possibly ffmpeg) - and post-process is not designed for cross-album concurrency. - In-album track parallelism still happens inside `reorganize_album` - (3 worker threads — see `_REORGANIZE_MAX_WORKERS`). +- **Single global queue**, single worker thread. Reorganize changes paths and + catalogue rows; serial execution keeps collision decisions deterministic. - **Dedupe on enqueue**: an album that's already queued or currently running is rejected silently. Stops the user from spamming the same album N times by clicking the button repeatedly. -- **Per-item source**: each queued item carries its own `source` - string (the user's per-album modal pick). Worker passes it - through to `reorganize_album(primary_source=..., strict_source=...)`. - - **Continue on failure**: a failed item doesn't stop the queue. Worker logs the failure, marks the item `failed`, moves on. @@ -34,7 +27,7 @@ - **In-memory only**: queue state lives in a module-level singleton. A server restart loses the queue (in-flight item likely also lost - half-way through post-process). DB persistence is a follow-up if + half-way through a move). DB persistence is a follow-up if this turns out to matter operationally. """ @@ -64,22 +57,13 @@ class QueueItem: album_title: str # captured at enqueue time for UI display artist_id: Optional[str] 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' - # Rename-only mode (#875): move files to the current naming scheme WITHOUT the - # copy + post-processing (re-tag / quality / AcoustID) the full flow runs. - rename_only: bool = False status: str = 'queued' # queued | running | done | failed | cancelled started_at: Optional[float] = None finished_at: Optional[float] = None # Populated by the worker after each item finishes — surfaced to the # status panel so users see counts + per-item error messages. result_status: Optional[str] = None # mirrors `reorganize_album` summary['status'] - result_source: Optional[str] = None # which source the orchestrator actually used moved: int = 0 skipped: int = 0 failed: int = 0 @@ -97,15 +81,11 @@ def to_snapshot(self) -> dict: 'album_title': self.album_title, 'artist_id': self.artist_id, 'artist_name': self.artist_name, - 'source': self.source, - 'metadata_source': self.metadata_source, - 'rename_only': self.rename_only, 'enqueued_at': self.enqueued_at, 'started_at': self.started_at, 'finished_at': self.finished_at, 'status': self.status, 'result_status': self.result_status, - 'result_source': self.result_source, 'moved': self.moved, 'skipped': self.skipped, 'failed': self.failed, @@ -129,7 +109,7 @@ def __init__(self, *, runner: Optional[Callable[[QueueItem], dict]] = None): Args: runner: Callable that takes a `QueueItem` and runs the actual reorganize, returning a summary dict with - ``status``, ``source``, ``moved``, ``skipped``, + ``status``, ``moved``, ``skipped``, ``failed``, ``errors`` keys (the shape ``reorganize_album`` already returns). Tests inject a fake runner; production wires the real one in @@ -163,9 +143,6 @@ def enqueue( album_title: str, artist_id: Optional[str], artist_name: str, - source: Optional[str] = None, - metadata_source: str = 'api', - rename_only: bool = False, ) -> dict: """Add an album to the queue. Returns a result dict: @@ -192,10 +169,7 @@ def enqueue( album_title=album_title, artist_id=artist_id, artist_name=artist_name, - source=source, enqueued_at=time.time(), - metadata_source=metadata_source or 'api', - rename_only=bool(rename_only), ) self._items.append(item) position = sum(1 for i in self._items if i.status == 'queued') @@ -203,8 +177,7 @@ def enqueue( self._cond.notify_all() logger.info( f"[Queue] Enqueued '{album_title}' (album_id={album_id}, " - f"queue_id={item.queue_id}, position={position}, " - f"source={source or 'auto'}, metadata={item.metadata_source})" + f"queue_id={item.queue_id}, position={position})" ) return { 'queued': True, @@ -215,7 +188,7 @@ def enqueue( def enqueue_many(self, items: List[Dict[str, Any]]) -> Dict[str, int]: """Bulk-enqueue a list of items. Each ``item`` is a dict with the same keys :meth:`enqueue` accepts (``album_id``, - ``album_title``, ``artist_id``, ``artist_name``, ``source``). + ``album_title``, ``artist_id`` and ``artist_name``). Dedupe still applies per-album-id. Holds the queue lock for the entire batch so two things hold: @@ -252,15 +225,13 @@ def enqueue_many(self, items: List[Dict[str, Any]]) -> Dict[str, int]: album_title=raw.get('album_title') or 'Unknown Album', artist_id=str(raw['artist_id']) if raw.get('artist_id') is not None else None, 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 logger.info( f"[Queue] Bulk-enqueued '{item.album_title}' (album_id={album_id}, " - f"queue_id={item.queue_id}, source={item.source or 'auto'})" + f"queue_id={item.queue_id})" ) if enqueued: self._ensure_worker() @@ -402,7 +373,6 @@ def _run(self) -> None: item.skipped = int(summary.get('skipped', 0)) item.failed = int(summary.get('failed', 0)) item.result_status = summary.get('status') - item.result_source = summary.get('source') errors = summary.get('errors') or [] if errors: first_err = errors[0] if isinstance(errors[0], dict) else {'error': str(errors[0])} diff --git a/core/reorganize_runner.py b/core/reorganize_runner.py index be5c3dce9..cd3702f70 100644 --- a/core/reorganize_runner.py +++ b/core/reorganize_runner.py @@ -4,9 +4,12 @@ The runner ties three subsystems together: -* :func:`core.library_reorganize.reorganize_album` — the orchestrator - that copies files to staging, matches them against the metadata - source, and routes each through the post-process pipeline. +* :func:`core.library_reorganize.reorganize_album_rename_only` — the + executor. It MOVES each file to the path the current template + dictates and does nothing else. There was a second, "full" executor + that staged a copy and pushed it through the download post-process; + it is gone, along with everything a reorganize had to opt out of to + survive it. * :func:`core.reorganize_queue.get_queue` — the queue this runner is registered with; we forward live progress updates back into the active queue item so the status panel can show per-track state. @@ -33,11 +36,11 @@ def build_runner( *, get_database: Callable[[], object], resolve_file_path_fn: Callable[[Optional[str]], Optional[str]], - post_process_fn: Callable[[str, dict, str], None], cleanup_empty_directories_fn: Callable[[str, str], None], is_shutting_down_fn: Callable[[], bool], - get_download_path: Callable[[], str], get_transfer_path: Callable[[], str], + post_process_fn: Optional[Callable[[str, dict, str], None]] = None, + get_download_path: Optional[Callable[[], str]] = None, build_final_path_fn: Optional[Callable] = None, ) -> Callable[[object], dict]: """Return the closure the queue worker invokes per item. @@ -46,22 +49,24 @@ def build_runner( get_database: Returns the live MusicDatabase singleton. resolve_file_path_fn: Resolves a DB-stored file path to the actual on-disk path (or ``None`` if missing). - post_process_fn: ``_post_process_matched_download``. Must set - ``context['_final_processed_path']`` on success. cleanup_empty_directories_fn: Called as ``cleanup_empty_directories_fn(transfer_dir, marker_path)`` to prune empty source dirs after a track is moved. is_shutting_down_fn: Returns True when the server is shutting down so the orchestrator can abort early. - get_download_path: Resolves the user's configured download - path *at call time* (so config changes apply live). - get_transfer_path: Same, for the transfer path. + get_transfer_path: Resolves the user's configured transfer path + *at call time* (so config changes apply live). + post_process_fn, get_download_path: accepted and ignored. A + reorganize no longer stages a copy and no longer runs the + download post-process, so it needs neither the pipeline nor + the staging root. Kept in the signature so existing wiring + keeps working. Returns: A callable ``runner(item)`` suitable for :meth:`core.reorganize_queue.ReorganizeQueue.set_runner`. """ - from core.library_reorganize import reorganize_album, reorganize_album_rename_only + from core.library_reorganize import reorganize_album_rename_only from core.reorganize_queue import get_queue def _repoint_findings(conn, old_path, new_path): @@ -159,7 +164,6 @@ def runner(item): # Read config per-run so the user changing their download path # in Settings takes effect on the next reorganize without a # server restart. - download_dir = get_download_path() transfer_dir = get_transfer_path() def _cleanup_empty(src_dir): @@ -175,56 +179,28 @@ def _on_progress(updates): # Progress fan-out failures must never break a run. logger.debug("reorganize progress fan-out: %s", e) - # Rename-only mode (#875): just move files to the current scheme — no staging, - # no copy, no post-processing. Falls through to the full pipeline otherwise. - if getattr(item, 'rename_only', False): - if build_final_path_fn is None: - return { - 'status': 'setup_failed', 'source': None, - 'total': 0, 'moved': 0, 'skipped': 0, 'failed': 0, - 'errors': [{'error': 'Rename-only mode unavailable (no path builder)'}], - } - return reorganize_album_rename_only( - album_id=item.album_id, - db=get_database(), - transfer_dir=transfer_dir, - resolve_file_path_fn=resolve_file_path_fn, - build_final_path_fn=build_final_path_fn, - update_track_path_fn=_update_track_path, - cleanup_empty_dir_fn=_cleanup_empty, - on_progress=_on_progress, - primary_source=item.source, - strict_source=bool(item.source), - metadata_source=getattr(item, 'metadata_source', 'api') or 'api', - stop_check=is_shutting_down_fn, - ) - - staging_root = os.path.join(download_dir, 'ssync_staging') - try: - os.makedirs(staging_root, exist_ok=True) - except OSError as mk_err: - logger.error(f"[Reorganize] Cannot create staging dir {staging_root}: {mk_err}") + # A reorganize moves files to the current scheme, and that is all it + # does. There used to be a second, "full" executor that staged a COPY of + # a file the user already owns and pushed it through the DOWNLOAD + # post-process — an acceptance check for files of unknown origin. It is + # gone: tagging is the Library Re-tag job's, identity is the AcoustID + # Scanner's, and neither of them moves anyone's audio to answer. + if build_final_path_fn is None: return { - 'status': 'setup_failed', - 'source': None, + 'status': 'setup_failed', 'source': None, 'total': 0, 'moved': 0, 'skipped': 0, 'failed': 0, - 'errors': [{'error': f'Could not create staging dir: {mk_err}'}], + 'errors': [{'error': 'Reorganize unavailable (no path builder)'}], } - - return reorganize_album( + return reorganize_album_rename_only( album_id=item.album_id, db=get_database(), - staging_root=staging_root, + transfer_dir=transfer_dir, resolve_file_path_fn=resolve_file_path_fn, - post_process_fn=post_process_fn, + build_final_path_fn=build_final_path_fn, update_track_path_fn=_update_track_path, cleanup_empty_dir_fn=_cleanup_empty, - transfer_dir=transfer_dir, on_progress=_on_progress, - primary_source=item.source, - strict_source=bool(item.source), stop_check=is_shutting_down_fn, - metadata_source=getattr(item, 'metadata_source', 'api') or 'api', ) return runner diff --git a/core/repair_jobs/library_reorganize.py b/core/repair_jobs/library_reorganize.py index 7624b7a04..bf3214ce1 100644 --- a/core/repair_jobs/library_reorganize.py +++ b/core/repair_jobs/library_reorganize.py @@ -17,14 +17,14 @@ instead of the album destination. GitHub issue #500 (@bafoed). Fix: delegate to the per-album planner -(``core.library_reorganize.preview_album_reorganize`` / -``reorganize_album``) the per-album reorganize modal already uses. The +(``core.library_reorganize.preview_album_reorganize``) the per-album +reorganize modal already uses. The planner is DB-driven — it knows the album has multiple tracks regardless of how many currently sit in the transfer folder, so the album-vs-single classification is structurally correct. Apply mode delegates to ``core.reorganize_queue`` so the actual file -move + post-processing + DB update + sidecar handling all flow +move + DB update + sidecar handling all flow through the same code path the per-album modal uses. No second move implementation to keep in sync. @@ -33,8 +33,8 @@ - Job is disabled by default — never auto-runs unless user enables. - Only DB-known tracks are considered. Files in transfer with no DB entry are handled by the separate ``orphan_file_detector`` job. -- Albums with no matching metadata source ID are skipped with a - clear "needs enrichment first" finding rather than guessed at. +- The catalogue is the sole path authority; no provider or file tags are + consulted while deciding a destination. """ import os @@ -58,16 +58,14 @@ class LibraryReorganizeJob(RepairJob): 'Any track whose current path doesn\'t match the expected path gets flagged in dry-run ' 'mode or queued for a move in live mode.\n\n' 'In live mode, moves are dispatched to the same reorganize queue the per-album modal ' - 'uses — file move + post-processing + DB update + sidecar handling all flow through ' + 'uses — file move + DB update + sidecar handling all flow through ' 'one code path.\n\n' - 'Albums with no matching metadata source ID are skipped — run enrichment first to ' - 'populate at least one of spotify_album_id / itunes_album_id / deezer_id.\n\n' + 'Destinations come from the library catalogue itself. No metadata provider is called, ' + 'and albums without provider IDs are handled normally.\n\n' 'Files in the transfer folder that aren\'t tracked in the database are handled by ' 'the separate Orphan File Detector job.\n\n' - 'Sidecars (.lrc, .jpg, .nfo, cover.jpg, etc) are handled by the underlying ' - 'reorganize queue: per-track sidecars are deleted at the source and album-level ' - 'cover art is re-downloaded fresh at the destination via the same post-processing ' - 'pipeline downloads use.\n\n' + 'Sidecars (.lrc, .jpg, .nfo, cover.jpg, etc.) move with their tracks and album. ' + 'Existing destination sidecars are never overwritten.\n\n' 'Settings:\n' '- Dry Run: When enabled, only reports what would change without moving files' ) @@ -162,16 +160,12 @@ def _resolve(file_path): album_id = album_row['id'] album_title = album_row['title'] or 'Unknown Album' - # Default to the API planner (authoritative metadata via source IDs). - # But media-server libraries usually have NO source IDs, so that path - # dead-ends at 'no_source_id' and the job only ever reports "needs - # enrichment" without moving anything (#862). Reorganizing to match - # the template only needs the metadata already on the files — so when - # the API planner can't resolve a source, fall back to TAG mode, which - # reads each file's embedded title/artist/album/year (the year is what - # the user's "($year) $album" template needs). Only genuinely tag-poor - # albums then fall through to a finding. - reorg_metadata_source = 'api' + # #862: the API planner dead-ended at 'no_source_id' for media-server + # libraries, which usually carry no source IDs, so this job only ever + # reported "needs enrichment" without moving anything — and the answer + # then was a fallback to reading each file's embedded tags. The plan + # comes from the library's own rows now, so there is no source to + # resolve and no fallback to arrange. try: preview = preview_album_reorganize( album_id=str(album_id), @@ -180,18 +174,6 @@ def _resolve(file_path): resolve_file_path_fn=_resolve, build_final_path_fn=build_final_path_for_track, ) - if preview.get('status') == 'no_source_id': - tags_preview = preview_album_reorganize( - album_id=str(album_id), - db=context.db, - transfer_dir=transfer_dir, - resolve_file_path_fn=_resolve, - build_final_path_fn=build_final_path_for_track, - metadata_source='tags', - ) - if tags_preview.get('status') == 'planned': - preview = tags_preview - reorg_metadata_source = 'tags' except Exception as exc: logger.warning( "Reorganize preview failed for album %s ('%s'): %s", @@ -211,36 +193,6 @@ def _resolve(file_path): result.skipped += 1 continue - if status == 'no_source_id': - # Reached only when BOTH the API planner (no source ID) AND the - # tag-mode fallback (files missing essential title/artist/album - # tags, or not on disk) failed — so no destination can be computed. - # One album-level finding rather than N per-track ones (UI clutter). - result.skipped += len(tracks) or 1 - if dry_run and context.create_finding and tracks: - inserted = context.create_finding( - job_id=self.job_id, - finding_type='album_needs_enrichment', - severity='info', - entity_type='album', - entity_id=str(album_id), - file_path=None, - title=f'Cannot place: {album_title}', - description=( - f"Album '{album_title}' by {preview.get('artist', '?')} " - "couldn't be reorganized: it has no metadata source ID " - "AND its files are missing essential tags (title / artist " - "/ album) or aren't on disk. Re-tag the files or run " - "'Fix Unknown Artists', then run this job again." - ), - details={'album_id': str(album_id), 'reason': 'no_source_id'}, - ) - if inserted: - result.findings_created += 1 - else: - result.findings_skipped_dedup += 1 - continue - # Successful plan — count mismatched tracks mismatched = [ t for t in tracks @@ -285,7 +237,6 @@ def _resolve(file_path): 'to_abs': t.get('new_path_abs') or '', 'album_id': str(album_id), 'album_title': album_title, - 'source': preview.get('source'), 'track_id': t.get('track_id'), }, ) @@ -301,19 +252,14 @@ def _resolve(file_path): result.errors += 1 else: # Apply mode: enqueue the album for the live reorganize - # queue worker. The queue handles file move + post-process - # + DB update + sidecar via the same code path the per- + # queue worker. The queue handles file move + DB update + + # sidecars via the same code path the per- # album modal uses — no second move implementation. items_to_enqueue.append({ 'album_id': str(album_id), 'album_title': album_title, 'artist_id': str(album_row.get('artist_id') or ''), 'artist_name': preview.get('artist') or album_row.get('artist_name') or 'Unknown Artist', - 'source': preview.get('source'), - # Carry the mode the preview actually used so the live move - # matches it — otherwise the queue runner defaults to 'api' - # and a tag-mode-only album would fail at apply time (#862). - 'metadata_source': reorg_metadata_source, }) if context.update_progress and (i + 1) % 25 == 0: diff --git a/core/repair_jobs/library_retag.py b/core/repair_jobs/library_retag.py index 49711532d..4f86e0fe6 100644 --- a/core/repair_jobs/library_retag.py +++ b/core/repair_jobs/library_retag.py @@ -20,30 +20,42 @@ plan_track, ) from core.metadata.album_tracks import get_album_for_source, get_album_tracks_for_source -from core.metadata_service import get_primary_source, get_source_priority +from core.metadata_service import ( + ALBUM_SOURCE_ID_COLUMNS, + get_primary_source, + get_source_priority, +) from core.repair_jobs import register_job from core.repair_jobs.base import JobContext, JobResult, RepairJob from utils.logging_config import get_logger logger = get_logger("repair_job.library_retag") -# (source, albums-table column) in resolution-preference order is decided at -# runtime from the configured source priority; this maps source -> column. -_ALBUM_SOURCE_COLUMNS = { - 'spotify': 'spotify_album_id', - 'itunes': 'itunes_album_id', - 'deezer': 'deezer_id', - 'musicbrainz': 'musicbrainz_release_id', -} +# Which albums this job can pull fresh data for. The order it resolves them in +# is decided at runtime from the configured source priority; the map itself is +# shared with reorganize so the two agree on what "matched" means. +_ALBUM_SOURCE_COLUMNS = ALBUM_SOURCE_ID_COLUMNS def _read_current_tags(file_path): + """The file's current tags, read with the SAME reader the writer's guards + use (``core.tag_writer.read_file_tags``). + + This was ``core.soulsync_client._read_tags`` — mutagen's easy view, which + keeps only the FIRST value of each frame. A multi-genre file came back as + one genre, so the plan and ``write_tags_to_file`` disagreed about the very + same file. + + A failure returns ``{'error': ...}`` rather than ``{}``: an empty dict + reads as "this file has no tags at all", which is how an unreadable file + turned into a finding claiming every field was wrong. + """ try: - from core.soulsync_client import _read_tags - return _read_tags(file_path) or {} + from core.tag_writer import read_file_tags + return read_file_tags(file_path) or {} except Exception as exc: logger.debug("read tags failed for %s: %s", file_path, exc) - return {} + return {'error': str(exc)} def _run_full_enrich(file_path, full_meta) -> bool: @@ -104,7 +116,9 @@ def apply_track_plans(track_plans, cover_action=None, cover_url=None, full=False _lyrics_client = None from core.tag_writer import write_tags_to_file - last_dir = None + # Every folder a track was actually written into. A multi-disc album lives + # in more than one, and the sidecar used to follow only the last track. + written_dirs: list = [] for tp in track_plans or []: fp = tp.get('file_path') db_data = tp.get('db_data') or {} @@ -115,7 +129,9 @@ def apply_track_plans(track_plans, cover_action=None, cover_url=None, full=False res = write_tags_to_file(fp, db_data, embed_cover=embed_cover, cover_data=cover_data) if res.get('success'): result['written'] += 1 - last_dir = _os.path.dirname(fp) + fp_dir = _os.path.dirname(fp) + if fp_dir not in written_dirs: + written_dirs.append(fp_dir) if full and tp.get('full_meta'): _run_full_enrich(fp, tp['full_meta']) else: @@ -146,15 +162,16 @@ def apply_track_plans(track_plans, cover_action=None, cover_url=None, full=False except Exception as e: logger.debug("retag lyrics fetch failed for %s: %s", fp, e) - if cover_action and cover_data and last_dir: - try: - cover_path = _os.path.join(last_dir, 'cover.jpg') - if cover_action == 'replace' or not _os.path.exists(cover_path): - with open(cover_path, 'wb') as fh: - fh.write(cover_data[0]) - result['cover_written'] = True - except Exception as e: - logger.debug("retag cover.jpg write failed: %s", e) + if cover_action and cover_data: + for cover_dir in written_dirs: + try: + cover_path = _os.path.join(cover_dir, 'cover.jpg') + if cover_action == 'replace' or not _os.path.exists(cover_path): + with open(cover_path, 'wb') as fh: + fh.write(cover_data[0]) + result['cover_written'] = True + except Exception as e: + logger.debug("retag cover.jpg write failed for %s: %s", cover_dir, e) return result @@ -244,9 +261,11 @@ class LibraryRetagJob(RepairJob): 'Turn it off to auto-apply on scan.\n' '- Mode: "overwrite" rewrites every field the source provides; "fill_missing" ' 'only fills blank tags (keeps your existing values).\n' - '- Cover art: replace / fill-missing / skip. "replace" force-refreshes ' - 'art on every matched album (use this after changing your cover-art ' - 'sources to re-pull fresh covers). When you have configured cover-art ' + '- Cover art: fill-missing (default) / replace / skip. "fill-missing" ' + 'only touches albums with no art. "replace" force-refreshes art on ' + 'EVERY matched album — that means a finding for every one of them, so ' + 'use it deliberately after changing your cover-art sources. When you ' + 'have configured cover-art ' 'sources (Settings > metadata enhancement art order), the art is pulled ' 'from those; otherwise it falls back to the matched source\'s album image.\n' '- Source: which matched source to pull from (default: your source priority).' @@ -258,7 +277,12 @@ class LibraryRetagJob(RepairJob): 'dry_run': True, 'depth': 'light', 'mode': MODE_OVERWRITE, - 'cover_art': 'replace', + # fill_missing, not replace. A cover action alone is enough to create a + # finding, and 'replace' produces one for EVERY matched album whose tags + # are already perfect — on every scan, forever, because a pending + # finding is refreshed in place rather than re-inserted. 'replace' stays + # available as the deliberate "re-pull all my art" run. + 'cover_art': 'fill_missing', 'lyrics': 'skip', 'source': 'auto', } @@ -267,7 +291,7 @@ class LibraryRetagJob(RepairJob): 'mode': [MODE_OVERWRITE, MODE_FILL_MISSING], 'cover_art': ['replace', 'fill_missing', 'skip'], 'lyrics': ['fetch', 'skip'], - 'source': ['auto', 'spotify', 'itunes', 'deezer', 'musicbrainz'], + 'source': ['auto', *ALBUM_SOURCE_ID_COLUMNS], } auto_fix = True writes_library_files = True @@ -402,29 +426,39 @@ def _scan_album(self, context, result, album_id, album_title, artist_name, except Exception as e: logger.debug("preferred cover-art lookup failed for album %s: %s", album_id, e) + # Resolve container/host path mismatches the same way the apply handler + # does, ONCE, up front. The old bare os.path.isfile() on the raw DB path + # failed for EVERY track on path-mapped setups (Docker mounts), so + # cover-mode scans produced "(0 track(s))" findings that the apply then + # rejected with "No tracks to re-tag in finding". + download_folder = (context.config_manager.get('soulseek.download_path', '') + if context.config_manager else None) + resolved = { + t['id']: resolve_library_file_path( + t['file_path'], + transfer_folder=getattr(context, 'transfer_folder', None), + download_folder=download_folder, + config_manager=context.config_manager, + ) + for t in library_tracks + } + # Cover action (album-level), independent of tag changes. Decided first # so cover-only albums (tags fine, art missing) still include their - # tracks for the apply to embed art into. - cover_action = self._cover_action(cover_mode, cover_url, library_tracks) + # tracks for the apply to embed art into. It asks about a RESOLVED path: + # "does this album have art?" answered against a container path the + # scan process cannot see is always "no", which made fill-missing + # behave exactly like replace. + rep_path = next((p for p in resolved.values() if p), None) + cover_action = self._cover_action(cover_mode, cover_url, rep_path) pairs = match_source_tracks(source_tracks, library_tracks) - download_folder = (context.config_manager.get('soulseek.download_path', '') - if context.config_manager else None) track_plans = [] unmatched = [] unreachable = 0 + unreadable = 0 for lib, src in pairs: - # Resolve container/host path mismatches the same way the apply - # handler does. The old bare os.path.isfile() on the raw DB path - # failed for EVERY track on path-mapped setups (Docker mounts), so - # cover-mode scans produced "(0 track(s))" findings that the apply - # then rejected with "No tracks to re-tag in finding". - rp = resolve_library_file_path( - lib['file_path'], - transfer_folder=getattr(context, 'transfer_folder', None), - download_folder=download_folder, - config_manager=context.config_manager, - ) + rp = resolved.get(lib['id']) if not rp: unreachable += 1 continue # genuinely unreachable from this process @@ -450,6 +484,12 @@ def _scan_album(self, context, result, album_id, album_title, artist_name, track_plans.append(plan_row) continue current = _read_current_tags(rp) + if current.get('error'): + # No known current value means no diff worth showing. Planning + # against "everything is empty" would promise to rewrite tags + # nobody has read. + unreadable += 1 + continue plan = plan_track(current, src, album_meta, mode=mode) # Include a track when its tags change, OR there's a cover action, # OR lyrics are being fetched (db_data may be empty — apply still @@ -464,6 +504,11 @@ def _scan_album(self, context, result, album_id, album_title, artist_name, 'changes': plan['changes'], 'db_data': db_data, } + if plan.get('protected'): + # Fields where the writer keeps the file's own value (#800 + # placeholders). Carried so the finding can say so instead + # of listing a change that will not happen. + tp['protected'] = plan['protected'] if lyrics_action: # READ-only lyrics query metadata (never written as tags). tp['lyrics_meta'] = { @@ -512,6 +557,12 @@ def _scan_album(self, context, result, album_id, album_title, artist_name, f'tags left untouched{" (cover art still applied)" if cover_action else ""}.') if unreachable: desc += f' {unreachable} track(s) not reachable on disk and skipped.' + if unreadable: + desc += f' {unreadable} track(s) had unreadable tags and were skipped.' + held_back = sorted({f for tp in track_plans for f in (tp.get('protected') or {})}) + if held_back: + desc += (f' {", ".join(held_back)}: the source offers a placeholder, so ' + f'your own value is held back.') # Cover-only findings say so instead of the puzzling "(0 track(s))". title_what = (f'{tag_change_tracks} track(s)' if tag_change_tracks @@ -549,8 +600,12 @@ def _scan_album(self, context, result, album_id, album_title, artist_name, result.findings_skipped_dedup += 1 @staticmethod - def _cover_action(cover_mode, cover_url, library_tracks): - """Return 'replace' / 'fill' / None for the album's cover under the mode.""" + def _cover_action(cover_mode, cover_url, rep_path): + """Return 'replace' / 'fill' / None for the album's cover under the mode. + + ``rep_path`` is a RESOLVED path to one of the album's files — the folder + it lives in is where the sidecar is looked for. + """ if cover_mode == 'skip' or not cover_url: return None if cover_mode == 'replace': @@ -558,8 +613,7 @@ def _cover_action(cover_mode, cover_url, library_tracks): # fill_missing — only if the album has no art on disk try: from core.metadata.art_apply import album_has_art_on_disk - rep = library_tracks[0]['file_path'] if library_tracks else '' - return None if album_has_art_on_disk(rep) else 'fill' + return None if album_has_art_on_disk(rep_path or '') else 'fill' except Exception: return None diff --git a/tests/library/test_residual_files.py b/tests/library/test_residual_files.py index 3c030d4e0..9180fffd3 100644 --- a/tests/library/test_residual_files.py +++ b/tests/library/test_residual_files.py @@ -35,17 +35,9 @@ def test_real_content_not_disposable(): assert not is_disposable(n), n -# ── the reorganize sweep that uses the predicate ────────────────────────────── -def test_delete_album_sidecars_sweeps_all_residual_keeps_real(tmp_path: Path): - from core.library_reorganize import _delete_album_sidecars - - d = tmp_path / 'Old Album' - d.mkdir() - for n in ('cover.jpg', 'back.jpg', 'disc.png', 'lyrics.lrc', 'album.nfo', '.DS_Store'): - (d / n).write_text('x') - (d / 'booklet.pdf').write_text('keep') # unrecognized → must survive - - _delete_album_sidecars(str(d)) - - survivors = {p.name for p in d.iterdir()} - assert survivors == {'booklet.pdf'} # every residual swept, booklet kept +# The reorganize sweep that used this predicate (`_delete_album_sidecars`) is +# gone. It DELETED an emptied source folder's cover art and sidecars, which was +# safe only because the full-mode reorganize re-created them at the destination +# from the provider. A reorganize moves files now and has no such second half, +# so deleting them would lose them. The predicate stays — it is what any future +# "carry these along" sweep will ask. diff --git a/tests/test_canonical_manual_lock.py b/tests/test_canonical_manual_lock.py index 54f9bcea6..566f05d2a 100644 --- a/tests/test_canonical_manual_lock.py +++ b/tests/test_canonical_manual_lock.py @@ -43,8 +43,8 @@ def test_does_not_pin_source_canonical_cant_read(source): def test_sources_stay_in_sync_with_album_id_columns(): # The set must mirror the canonical reader's column map; if a source is # added there, this fails until CANONICAL_ALBUM_SOURCES is updated. - from core.library_reorganize import _ALBUM_ID_COLUMNS - assert CANONICAL_ALBUM_SOURCES == set(_ALBUM_ID_COLUMNS) + from core.metadata.registry import ALBUM_SOURCE_ID_COLUMNS + assert CANONICAL_ALBUM_SOURCES == set(ALBUM_SOURCE_ID_COLUMNS) # --------------------------------------------------------------------------- diff --git a/tests/test_case_folding_integration.py b/tests/test_case_folding_integration.py index 26b468bbb..a5e5b9e00 100644 --- a/tests/test_case_folding_integration.py +++ b/tests/test_case_folding_integration.py @@ -34,8 +34,6 @@ def get_active_media_server(self): return None monkeypatch.setattr('core.imports.paths._get_config_manager', lambda: _Cfg()) - monkeypatch.setattr('core.library_reorganize._preserve_casing_enabled', lambda: True) - monkeypatch.setattr('core.library_reorganize._feat_in_title_enabled', lambda: False) return root @@ -45,7 +43,7 @@ def _context(artist='Pink Floyd', album='The Wall', title='Another Brick'): "total_tracks": 10, "images": [{"url": ""}]}, {"name": title, "track_number": 1, "disc_number": 1, "artists": [{"name": artist}]}, - artist, album, 1, local_title=title) + artist, album, 1) def _build(create_dirs=False): diff --git a/tests/test_download_reorganize_agree.py b/tests/test_download_reorganize_agree.py deleted file mode 100644 index b9c05200e..000000000 --- a/tests/test_download_reorganize_agree.py +++ /dev/null @@ -1,249 +0,0 @@ -"""A freshly downloaded album must already sit where Reorganize would put it. - -Both pipelines call the SAME builder (``core.imports.paths.build_final_path_for_track``), -so the destination can only diverge through the context they feed it. It did: -Reorganize applied a single-disc cap that the download pipeline knows nothing -about, so an album whose tracks the downloader had just filed under "Disc 1/" -was immediately proposed for a move back out — and moved back in again once the -second disc arrived. - -The acceptance criterion is the user's: download an album, press Reorganize, -nothing moves. -""" - -from __future__ import annotations - -import os - -import pytest - -import core.imports.paths as import_paths -import core.library_reorganize as lr -from core.imports.paths import build_final_path_for_track - - -class _Config: - def __init__(self, values): - self._values = values - - def get(self, key, default=None): - return self._values.get(key, default) - - def get_active_media_server(self): - return "primary" - - -ARTIST = "Sawano Hiroyuki" -ALBUM = "TV Anime Attack on Titan Season 2 (Original Soundtrack)" -# A genuine 2-disc release, numbered per disc. -API_TRACKS = ([{"name": "D1-%02d" % n, "track_number": n, "disc_number": 1, - "artists": [{"name": ARTIST}]} for n in range(1, 26)] - + [{"name": "D2-%02d" % n, "track_number": n, "disc_number": 2, - "artists": [{"name": ARTIST}]} for n in range(1, 21)]) -API_ALBUM = {"id": "sp1", "name": ALBUM, "release_date": "2017-06-28", - "total_tracks": len(API_TRACKS), "images": [{"url": ""}]} - - -@pytest.fixture() -def cfg(monkeypatch, tmp_path): - config = _Config({ - # The shipped default spelling — relative, exactly what the settings - # page shows and what a container install carries. - "soulseek.transfer_path": "./Transfer", - "file_organization.enabled": True, - "file_organization.templates": { - "album_path": "$albumartist/$album/$track - $title", - "single_path": "$albumartist/$albumartist - $title/$title", - }, - "file_organization.collab_artist_mode": "first", - "file_organization.disc_label": "Disc", - }) - monkeypatch.chdir(tmp_path) - monkeypatch.setattr(import_paths, "_get_config_manager", lambda: config) - # The provider tracklist lookup the builder may make. Returning None is the - # realistic worst case (cache miss, provider down) AND the case that used to - # change the destination: see - # test_the_destination_does_not_depend_on_a_provider_lookup below. - monkeypatch.setattr(import_paths, "_get_album_tracks_for_source", lambda *a: None) - monkeypatch.setattr(lr, "_preserve_casing_enabled", lambda: True) - monkeypatch.setattr(lr, "_feat_in_title_enabled", lambda: False) - return tmp_path - - -def _download_destination(track_number, disc_number, title): - """What the download/import pipeline files a finished track as.""" - context = { - "artist": {"name": ARTIST}, - "album": {"name": ALBUM, "id": "sp1", "release_date": "2017-06-28", - "total_tracks": len(API_TRACKS), "total_discs": 2, - "album_type": "album", "artists": [{"name": ARTIST}]}, - "track_info": {"name": title, "id": "t", "track_number": track_number, - "disc_number": disc_number, "artists": [{"name": ARTIST}]}, - "original_search_result": {"title": title, "clean_title": title, - "clean_album": ALBUM, "clean_artist": ARTIST, - "artists": [{"name": ARTIST}]}, - "source": "spotify", "is_album_download": True, - } - path, _ = build_final_path_for_track( - context, {"name": ARTIST}, - {"is_album": True, "album_name": ALBUM, - "track_number": track_number, "disc_number": disc_number}, - ".flac", create_dirs=False) - return path - - -def _reorganize_destination(user_tracks, monkeypatch): - """What Reorganize proposes for the same album, via the real planner.""" - album_data = {"id": "AL1", "title": ALBUM, "artist_name": ARTIST, - "artist_id": "AR1", "spotify_album_id": "sp1"} - monkeypatch.setattr( - lr, "_resolve_source", - lambda ad, ps, strict_source=False, **kw: ("spotify", API_ALBUM, API_TRACKS)) - plan = lr.plan_album_reorganize(album_data, user_tracks, "spotify") - - out = [] - for item in plan["items"]: - assert item["matched"], item.get("reason") - ctx = lr._build_post_process_context( - API_ALBUM, item["api_track"], ARTIST, ALBUM, plan["total_discs"], - local_title=item["track"]["title"]) - path, _ = build_final_path_for_track( - ctx, ctx["spotify_artist"], lr._build_album_info(ctx), ".flac", - create_dirs=False) - out.append(path) - return out - - -def test_reorganize_proposes_nothing_for_a_part_downloaded_multi_disc_album(cfg, monkeypatch): - """Three tracks of disc 1 have landed. This is the reported case.""" - downloaded = [_download_destination(n, 1, "D1-%02d" % n) for n in (1, 2, 3)] - assert all(os.sep + "Disc 1" + os.sep in p for p in downloaded), downloaded - - user_tracks = [{"id": "T%d" % n, "title": "D1-%02d" % n, "track_number": n, - "file_path": downloaded[i]} - for i, n in enumerate((1, 2, 3))] - - assert _reorganize_destination(user_tracks, monkeypatch) == downloaded - - -def test_reorganize_proposes_nothing_once_both_discs_have_landed(cfg, monkeypatch): - """And it must not flip back the other way when the album completes.""" - picks = [(1, 1, "D1-01"), (25, 1, "D1-25"), (1, 2, "D2-01"), (20, 2, "D2-20")] - downloaded = [_download_destination(tn, dn, title) for tn, dn, title in picks] - - user_tracks = [{"id": "T%d" % i, "title": title, "track_number": tn, - "file_path": downloaded[i]} - for i, (tn, dn, title) in enumerate(picks)] - - assert _reorganize_destination(user_tracks, monkeypatch) == downloaded - - -def test_the_destination_is_absolute_so_the_catalogue_can_store_it(cfg): - path = _download_destination(1, 1, "D1-01") - assert os.path.isabs(path), path - assert path.startswith(str(cfg / "Transfer") + os.sep) - - -def test_the_destination_does_not_depend_on_a_provider_lookup(cfg, monkeypatch): - """A caller that KNOWS the disc count must be trusted. - - The builder re-derived the count from a live provider tracklist whenever the - supplied value was <= 1, so the same track landed in `Album/01 - x.flac` or - `Album/Disc 1/01 - x.flac` depending on whether that lookup happened to - succeed. A cache miss or an offline provider was enough to file two tracks - of one album in two different folders. - - "Knows" has to be explicit. Almost every context builder writes - `.get('total_discs', 1)`, where the 1 means "nobody told me" — reading that - as a declaration would silence the #981 lookup for playlist and wishlist - downloads and file disc-1 tracks of a real 2-disc release flat. - """ - def _ctx(total_discs): - return { - "artist": {"name": ARTIST}, - "album": {"name": ALBUM, "id": "sp1", "release_date": "2017-06-28", - "total_tracks": len(API_TRACKS), "total_discs": total_discs, - "total_discs_declared": True, - "album_type": "album", "artists": [{"name": ARTIST}]}, - "track_info": {"name": "D1-01", "id": "t", "track_number": 1, - "disc_number": 1, "artists": [{"name": ARTIST}]}, - "original_search_result": {"title": "D1-01", "clean_title": "D1-01", - "clean_album": ALBUM, "clean_artist": ARTIST, - "artists": [{"name": ARTIST}]}, - "source": "spotify", "is_album_download": True, - } - - album_info = {"is_album": True, "album_name": ALBUM, - "track_number": 1, "disc_number": 1} - - monkeypatch.setattr(import_paths, "_get_album_tracks_for_source", lambda *a: API_TRACKS) - with_lookup, _ = build_final_path_for_track( - _ctx(1), {"name": ARTIST}, album_info, ".flac", create_dirs=False) - - monkeypatch.setattr(import_paths, "_get_album_tracks_for_source", lambda *a: None) - without_lookup, _ = build_final_path_for_track( - _ctx(1), {"name": ARTIST}, album_info, ".flac", create_dirs=False) - - assert with_lookup == without_lookup, ( - "an explicit total_discs=1 was overridden by a provider lookup" - ) - assert os.sep + "Disc 1" + os.sep not in with_lookup - - -def test_an_absent_total_discs_still_asks_the_provider(cfg, monkeypatch): - """The lookup is the fallback for callers that genuinely do not know — a - single-track download has no album context of its own (#981).""" - ctx = { - "artist": {"name": ARTIST}, - "album": {"name": ALBUM, "id": "sp1", "release_date": "2017-06-28", - "total_tracks": len(API_TRACKS), - "album_type": "album", "artists": [{"name": ARTIST}]}, - "track_info": {"name": "D1-01", "id": "t", "track_number": 1, - "disc_number": 1, "artists": [{"name": ARTIST}]}, - "original_search_result": {"title": "D1-01", "clean_title": "D1-01", - "clean_album": ALBUM, "clean_artist": ARTIST, - "artists": [{"name": ARTIST}]}, - "source": "spotify", "is_album_download": True, - } - monkeypatch.setattr(import_paths, "_get_album_tracks_for_source", lambda *a: API_TRACKS) - path, _ = build_final_path_for_track( - ctx, {"name": ARTIST}, - {"is_album": True, "album_name": ALBUM, "track_number": 1, "disc_number": 1}, - ".flac", create_dirs=False) - assert os.sep + "Disc 1" + os.sep in path - - -def test_a_defaulted_total_discs_still_asks_the_provider(cfg, monkeypatch): - """The regression guard for the above: `total_discs: 1` written by a caller - that merely defaulted it (core/downloads/candidates.py, staging.py, - master.py all do `.get('total_discs', 1)`) is NOT a declaration. Spotify - album objects carry no disc count at all, so 1 there means unknown.""" - ctx = { - "artist": {"name": ARTIST}, - "album": {"name": ALBUM, "id": "sp1", "release_date": "2017-06-28", - "total_tracks": len(API_TRACKS), "total_discs": 1, - "album_type": "album", "artists": [{"name": ARTIST}]}, - "track_info": {"name": "D1-01", "id": "t", "track_number": 1, - "disc_number": 1, "artists": [{"name": ARTIST}]}, - "original_search_result": {"title": "D1-01", "clean_title": "D1-01", - "clean_album": ALBUM, "clean_artist": ARTIST, - "artists": [{"name": ARTIST}]}, - "source": "spotify", "is_album_download": True, - } - monkeypatch.setattr(import_paths, "_get_album_tracks_for_source", lambda *a: API_TRACKS) - path, _ = build_final_path_for_track( - ctx, {"name": ARTIST}, - {"is_album": True, "album_name": ALBUM, "track_number": 1, "disc_number": 1}, - ".flac", create_dirs=False) - assert os.sep + "Disc 1" + os.sep in path, ( - "a defaulted 1 was read as a declaration, so the #981 lookup never ran" - ) - - -def test_the_reorganize_context_declares_its_disc_count(): - """Reorganize is the caller that really knows: it counted the discs off the - source tracklist it just resolved.""" - ctx = lr._build_post_process_context( - API_ALBUM, API_TRACKS[0], ARTIST, ALBUM, 2, local_title="D1-01") - assert ctx["spotify_album"]["total_discs"] == 2 - assert ctx["spotify_album"]["total_discs_declared"] is True diff --git a/tests/test_library_reorganize.py b/tests/test_library_reorganize.py index 60e05d844..cf19d4cec 100644 --- a/tests/test_library_reorganize.py +++ b/tests/test_library_reorganize.py @@ -323,31 +323,11 @@ def test_scan_skips_tracks_with_missing_files(make_context, monkeypatch): assert result.findings_created == 0 -def test_scan_emits_album_needs_enrichment_when_planner_returns_no_source_id(make_context, monkeypatch): - """Pin: planner returns status='no_source_id' → emit ONE - album-level finding ('needs enrichment') instead of N per-track - 'no source' findings (which would clutter the UI).""" - db = _FakeDB([_make_album_row(id_='A1', title='Unenriched Album')]) - _stub_preview(monkeypatch, { - 'A1': { - 'success': False, 'status': 'no_source_id', - 'source': None, - 'album': 'Unenriched Album', 'artist': 'Some Artist', - 'tracks': [ - {'track_id': 't1', 'title': 'Track 1', 'matched': False, 'reason': '...'}, - {'track_id': 't2', 'title': 'Track 2', 'matched': False, 'reason': '...'}, - ], - }, - }) - ctx = make_context(db=db, dry_run=True) - - job = LibraryReorganizeJob() - result = job.scan(ctx) - - findings = ctx._captured_findings # type: ignore[attr-defined] - assert result.findings_created == 1 - assert findings[0]['finding_type'] == 'album_needs_enrichment' - assert 'Unenriched Album' in findings[0]['title'] +# The `album_needs_enrichment` dead-end finding this job used to emit for a +# `no_source_id` plan is gone with the branch that raised it: the planner reads +# the catalogue, which needs no enrichment to name a file. +# `test_an_album_with_no_source_id_still_produces_findings` below pins the +# replacement — such an album now gets real path_mismatch findings. def test_scan_skips_albums_planner_reports_as_no_album(make_context, monkeypatch): @@ -457,38 +437,36 @@ def enqueue_many(self, items): assert len(enqueue_calls) == 1 queued = enqueue_calls[0] assert {q['album_id'] for q in queued} == {'A1', 'A2'} - assert {q['source'] for q in queued} == {'spotify', 'deezer'} + # No per-item source: a reorganize consults no provider, so there is + # nothing about "which source" for the queue to carry. + assert all('source' not in q for q in queued) assert result.auto_fixed == 2 # Apply mode does NOT emit findings — it enqueues for actual move. assert result.findings_created == 0 -def _stub_preview_by_mode(monkeypatch, api_resp, tags_resp): - """Patch preview to return different responses for api vs tag mode, so the - #862 api→tags fallback can be exercised.""" +def _stub_one_preview(monkeypatch, resp): + """One response for every album — the planner has no modes to switch on.""" from core import library_reorganize as core_lr - - def _fake_preview(*, album_id, metadata_source='api', **kwargs): - return tags_resp if metadata_source == 'tags' else api_resp - monkeypatch.setattr(core_lr, 'preview_album_reorganize', _fake_preview) + monkeypatch.setattr(core_lr, 'preview_album_reorganize', + lambda **kwargs: resp) -def test_scan_falls_back_to_tag_mode_when_api_has_no_source_id(make_context, monkeypatch): - """#862: media-server albums have no source ID, so the API planner returns - no_source_id. The job must fall back to TAG mode and, when that plans, emit - real path_mismatch findings — NOT a dead-end 'needs enrichment' finding.""" +def test_an_album_with_no_source_id_still_produces_findings(make_context, monkeypatch): + """#862: media-server albums have no source ID, so the API planner returned + `no_source_id` and this job only ever reported a dead-end "needs enrichment" + finding without moving anything. The answer then was a fallback to reading + each file's embedded tags; the answer now is that the plan never needed a + source at all — it comes from the library's own rows.""" db = _FakeDB([_make_album_row(id_='A1', title='Tagged Album')]) - _stub_preview_by_mode( - monkeypatch, - api_resp={'success': False, 'status': 'no_source_id', 'source': None, - 'album': 'Tagged Album', 'artist': 'A', - 'tracks': [{'track_id': 't1', 'title': 'X', 'matched': False}]}, - tags_resp={'success': True, 'status': 'planned', 'source': 'tags', - 'album': 'Tagged Album', 'artist': 'A', - 'tracks': [{'track_id': 't1', 'title': 'X', - 'current_path': 'old/X.flac', 'new_path': 'A/(2008) Tagged Album/01 - X.flac', - 'matched': True, 'unchanged': False, 'file_exists': True}]}, - ) + _stub_one_preview(monkeypatch, { + 'success': True, 'status': 'planned', 'source': 'catalogue', + 'album': 'Tagged Album', 'artist': 'A', + 'tracks': [{'track_id': 't1', 'title': 'X', + 'current_path': 'old/X.flac', + 'new_path': 'A/(2008) Tagged Album/01 - X.flac', + 'matched': True, 'unchanged': False, 'file_exists': True}], + }) ctx = make_context(db=db, dry_run=True) result = LibraryReorganizeJob().scan(ctx) @@ -499,22 +477,19 @@ def test_scan_falls_back_to_tag_mode_when_api_has_no_source_id(make_context, mon assert all(f['finding_type'] != 'album_needs_enrichment' for f in findings) -def test_apply_mode_enqueues_tag_metadata_source_on_fallback(make_context, monkeypatch): - """#862: when the album reorganizes via the tag-mode fallback, the enqueued - item must carry metadata_source='tags' so the live move uses tags too (the - queue runner otherwise defaults to 'api' and would fail again).""" +def test_apply_mode_enqueues_without_a_mode_to_carry(make_context, monkeypatch): + """The enqueued item used to carry `metadata_source` so the live move would + use the same planner the preview did. There is one planner now, so there is + nothing to carry — and nothing that can disagree.""" db = _FakeDB([_make_album_row(id_='A1', title='Tagged Album', artist_id=10, artist_name='A')]) - _stub_preview_by_mode( - monkeypatch, - api_resp={'success': False, 'status': 'no_source_id', 'source': None, - 'album': 'Tagged Album', 'artist': 'A', - 'tracks': [{'track_id': 't1', 'title': 'X', 'matched': False}]}, - tags_resp={'success': True, 'status': 'planned', 'source': 'tags', - 'album': 'Tagged Album', 'artist': 'A', - 'tracks': [{'track_id': 't1', 'title': 'X', - 'current_path': 'old/X.flac', 'new_path': 'A/(2008) Tagged Album/01 - X.flac', - 'matched': True, 'unchanged': False, 'file_exists': True}]}, - ) + _stub_one_preview(monkeypatch, { + 'success': True, 'status': 'planned', 'source': 'catalogue', + 'album': 'Tagged Album', 'artist': 'A', + 'tracks': [{'track_id': 't1', 'title': 'X', + 'current_path': 'old/X.flac', + 'new_path': 'A/(2008) Tagged Album/01 - X.flac', + 'matched': True, 'unchanged': False, 'file_exists': True}], + }) enqueue_calls = [] @@ -530,8 +505,8 @@ def enqueue_many(self, items): LibraryReorganizeJob().scan(ctx) assert len(enqueue_calls) == 1 - assert enqueue_calls[0][0]['metadata_source'] == 'tags' - assert enqueue_calls[0][0]['source'] == 'tags' + assert 'metadata_source' not in enqueue_calls[0][0] + assert 'source' not in enqueue_calls[0][0] def test_scan_only_iterates_albums_for_active_server(make_context, monkeypatch): diff --git a/tests/test_library_reorganize_orchestrator.py b/tests/test_library_reorganize_orchestrator.py deleted file mode 100644 index ba1d765b8..000000000 --- a/tests/test_library_reorganize_orchestrator.py +++ /dev/null @@ -1,2149 +0,0 @@ -"""Tests for `core.library_reorganize.reorganize_album` — the new -post-processing-pipeline approach (the orchestrator that copies files -to staging and routes them through the same code that handles fresh -downloads, instead of doing per-album template work in web_server). - -Contract this test file pins: - -1. Albums without ANY metadata-source ID return ``status='no_source_id'`` - without staging anything, copying anything, or calling post-process. - Silent degradation to file tags is the failure mode the previous - implementation had; the new contract is "we have the source of - truth or we don't touch the album." -2. Source resolution honors the configured primary first, then walks - ``get_source_priority`` until something returns a tracklist. -3. Each library track is matched to the API tracklist by - ``track_number``. Tracks not in the API response (bonus tracks on a - deluxe edition, etc.) are reported as skipped and left in place — - they are NOT force-fed wrong context to post-process. -4. Files that don't resolve on disk are surfaced as skipped errors - with the offending DB path, not silently dropped. -5. After a successful post-process the original file is removed and - the DB row is updated to the new path. A failed post-process leaves - the original alone so the user doesn't lose data. -6. Staging directory is cleaned up regardless of how the run ends. -""" - -import os -import shutil -import sqlite3 -import sys -import types - -import pytest - - -# --- module stubs (same shape used elsewhere in the test suite) ----------- -if "spotipy" not in sys.modules: - spotipy = types.ModuleType("spotipy") - - class _DummySpotify: - def __init__(self, *args, **kwargs): - pass - - oauth2 = types.ModuleType("spotipy.oauth2") - - class _DummyOAuth: - def __init__(self, *args, **kwargs): - pass - - spotipy.Spotify = _DummySpotify - oauth2.SpotifyOAuth = _DummyOAuth - oauth2.SpotifyClientCredentials = _DummyOAuth - spotipy.oauth2 = oauth2 - sys.modules["spotipy"] = spotipy - sys.modules["spotipy.oauth2"] = oauth2 - -if "core.settings" not in sys.modules: - config_pkg = types.ModuleType("config") - settings_mod = types.ModuleType("core.settings") - - class _DummyConfigManager: - def get(self, key, default=None): - return default - - def get_active_media_server(self): - return "primary" - - settings_mod.config_manager = _DummyConfigManager() - config_pkg.settings = settings_mod - sys.modules["config"] = config_pkg - sys.modules["core.settings"] = settings_mod - - -from core import library_reorganize # noqa: E402 - - -# --- helpers -------------------------------------------------------------- - -class _FakeDB: - """Wraps a sqlite3 in-memory connection that survives `close()` calls - so the tests can reuse it for assertions after the orchestrator runs.""" - - def __init__(self): - self._conn = sqlite3.connect(":memory:") - self._conn.row_factory = sqlite3.Row - - def _get_connection(self): - return _NonClosingConnWrapper(self._conn) - - -class _NonClosingConnWrapper: - def __init__(self, real): - self._real = real - - def cursor(self): - return self._real.cursor() - - def execute(self, *args, **kwargs): - return self._real.execute(*args, **kwargs) - - def commit(self): - return self._real.commit() - - def close(self): - # Underlying connection survives — tests reuse it. - pass - - def __enter__(self): - return self - - def __exit__(self, *args): - pass - - -def _setup_album(db, *, album_id='alb-1', spotify_id='', deezer_id='', - itunes_id='', discogs_id='', soul_id='', tracks=()): - """Build a minimal artists/albums/tracks schema and seed one album. - - `tracks` is a list of `(track_id, track_number, title, file_path)`. - """ - cur = db._conn.cursor() - cur.execute("CREATE TABLE artists (id TEXT PRIMARY KEY, name TEXT)") - cur.execute(""" - CREATE TABLE albums ( - id TEXT PRIMARY KEY, - artist_id TEXT, - title TEXT, - spotify_album_id TEXT, - deezer_id TEXT, - itunes_album_id TEXT, - discogs_id TEXT, - soul_id TEXT - ) - """) - cur.execute(""" - CREATE TABLE tracks ( - id TEXT PRIMARY KEY, - album_id TEXT, - artist_id TEXT, - title TEXT, - track_number INTEGER, - file_path TEXT, - updated_at TEXT - ) - """) - cur.execute("INSERT INTO artists VALUES (?, ?)", ('artist-1', 'Aerosmith')) - cur.execute( - "INSERT INTO albums (id, artist_id, title, spotify_album_id, deezer_id, " - "itunes_album_id, discogs_id, soul_id) VALUES (?,?,?,?,?,?,?,?)", - (album_id, 'artist-1', 'Aerosmith (1973)', spotify_id, deezer_id, - itunes_id, discogs_id, soul_id), - ) - for tid, tn, title, fp in tracks: - cur.execute( - "INSERT INTO tracks (id, album_id, artist_id, title, track_number, file_path) " - "VALUES (?,?,?,?,?,?)", - (tid, album_id, 'artist-1', title, tn, fp), - ) - db._conn.commit() - - -@pytest.fixture -def tmpdirs(tmp_path): - """Three working directories: original library files, staging root, - transfer destination.""" - library = tmp_path / "library" - staging = tmp_path / "staging" - transfer = tmp_path / "transfer" - library.mkdir() - staging.mkdir() - transfer.mkdir() - return library, staging, transfer - - -def _make_audio_file(library_dir, name='song.flac', content=b'fakeflacdata'): - p = library_dir / name - p.write_bytes(content) - return str(p) - - -# --- tests: source resolution --------------------------------------------- - -def test_returns_no_source_id_when_album_has_none(monkeypatch, tmpdirs): - library, staging, _transfer = tmpdirs - db = _FakeDB() - _setup_album(db, tracks=[ - ('t1', 1, 'Same Old Song And Dance', _make_audio_file(library)), - ]) - - pp_calls = [] - - def pp(key, ctx, fp): - pp_calls.append(key) - - monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'spotify') - monkeypatch.setattr(library_reorganize, 'get_source_priority', - lambda p: [p, 'deezer', 'itunes', 'discogs', 'hydrabase']) - monkeypatch.setattr(library_reorganize, 'get_album_for_source', lambda *a: None) - monkeypatch.setattr(library_reorganize, 'get_album_tracks_for_source', lambda *a: None) - - summary = library_reorganize.reorganize_album( - album_id='alb-1', db=db, staging_root=str(staging), - resolve_file_path_fn=lambda p: p, post_process_fn=pp, - ) - - assert summary['status'] == 'no_source_id' - assert summary['moved'] == 0 - assert pp_calls == [] - - -def test_falls_through_to_next_source_when_primary_returns_nothing(monkeypatch, tmpdirs): - library, staging, _transfer = tmpdirs - db = _FakeDB() - _setup_album(db, spotify_id='sp-1', deezer_id='dz-1', tracks=[ - ('t1', 1, 'Same Old Song And Dance', _make_audio_file(library)), - ]) - - monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'spotify') - monkeypatch.setattr(library_reorganize, 'get_source_priority', - lambda p: [p, 'deezer']) - - def fake_album(src, sid): - return {'id': sid, 'name': 'Aerosmith', 'release_date': '1973-01-01'} \ - if src == 'deezer' else None - - def fake_tracks(src, sid): - return {'items': [{'id': 'dz-t1', 'name': 'Same Old Song And Dance', - 'track_number': 1, 'disc_number': 1}]} \ - if src == 'deezer' else None - - monkeypatch.setattr(library_reorganize, 'get_album_for_source', fake_album) - monkeypatch.setattr(library_reorganize, 'get_album_tracks_for_source', fake_tracks) - - def pp(key, ctx, fp): - ctx['_final_processed_path'] = str(library / 'final.flac') - with open(ctx['_final_processed_path'], 'wb') as f: - f.write(b'final') - - summary = library_reorganize.reorganize_album( - album_id='alb-1', db=db, staging_root=str(staging), - resolve_file_path_fn=lambda p: p, post_process_fn=pp, - ) - - assert summary['source'] == 'deezer' - assert summary['moved'] == 1 - - -# --- tests: per-track behavior -------------------------------------------- - -def test_multi_disc_album_disambiguates_by_title(monkeypatch, tmpdirs): - """The whole point of moving from track_number-only to title-based - matching: a 2-disc album has track_number=1 on BOTH discs, but the - titles differ. Each library track must end up routed to the API - entry with the matching title — and therefore to the correct - disc_number in the post-process context.""" - library, staging, _transfer = tmpdirs - db = _FakeDB() - _setup_album(db, deezer_id='dz-1', tracks=[ - # Disc 1 track 1: 'Same Old Song And Dance' - ('t1d1', 1, 'Same Old Song And Dance', _make_audio_file(library, 'd1t1.flac')), - # Disc 2 track 1: 'Dream On' - ('t1d2', 1, 'Dream On', _make_audio_file(library, 'd2t1.flac')), - ]) - - monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') - monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) - monkeypatch.setattr(library_reorganize, 'get_album_for_source', - lambda *a: {'id': 'dz-1', 'name': 'Aerosmith'}) - monkeypatch.setattr( - library_reorganize, 'get_album_tracks_for_source', - lambda *a: {'items': [ - {'id': 'd1t1', 'name': 'Same Old Song And Dance', 'track_number': 1, 'disc_number': 1}, - {'id': 'd2t1', 'name': 'Dream On', 'track_number': 1, 'disc_number': 2}, - ]}, - ) - - title_to_disc = {} - - def pp(key, ctx, fp): - # Capture which disc_number landed in the per-track context - title_to_disc[ctx['track_info']['name']] = ctx['track_info']['disc_number'] - # Also record total_discs so we can assert it's correct - title_to_disc.setdefault('_total_discs', ctx['spotify_album']['total_discs']) - ctx['_final_processed_path'] = str(library / f"out_{ctx['track_info']['disc_number']}_{ctx['track_info']['track_number']}.flac") - with open(ctx['_final_processed_path'], 'wb') as f: - f.write(b'final') - - summary = library_reorganize.reorganize_album( - album_id='alb-1', db=db, staging_root=str(staging), - resolve_file_path_fn=lambda p: p, post_process_fn=pp, - ) - - assert summary['moved'] == 2 - # The crucial assertion: each track must get the disc_number of - # its title-matched API entry, NOT a collapsed last-write-wins value. - assert title_to_disc['Same Old Song And Dance'] == 1 - assert title_to_disc['Dream On'] == 2 - # And the album-level total_discs must be 2 so post-process inserts the subfolder - assert title_to_disc['_total_discs'] == 2 - - -def test_title_match_tolerates_smart_quotes_and_punctuation(monkeypatch, tmpdirs): - library, staging, _transfer = tmpdirs - db = _FakeDB() - _setup_album(db, deezer_id='dz-1', tracks=[ - ('t1', 1, "Don't Stop Believin'", _make_audio_file(library, 't1.flac')), - ]) - - monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') - monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) - monkeypatch.setattr(library_reorganize, 'get_album_for_source', - lambda *a: {'id': 'dz-1', 'name': 'A'}) - monkeypatch.setattr( - library_reorganize, 'get_album_tracks_for_source', - # API uses smart quotes — historically a common mismatch source - lambda *a: {'items': [ - {'id': 'a1', 'name': 'Don’t Stop Believin’', 'track_number': 1, 'disc_number': 1}, - ]}, - ) - - pp_calls = [] - - def pp(key, ctx, fp): - pp_calls.append(ctx['track_info']['name']) - ctx['_final_processed_path'] = str(library / 'out.flac') - with open(ctx['_final_processed_path'], 'wb') as f: - f.write(b'final') - - summary = library_reorganize.reorganize_album( - album_id='alb-1', db=db, staging_root=str(staging), - resolve_file_path_fn=lambda p: p, post_process_fn=pp, - ) - - assert summary['moved'] == 1 - assert len(pp_calls) == 1 - - -def test_bonus_track_routes_to_correct_disc_via_substring_match(monkeypatch, tmpdirs): - """Real-world scenario from winecountrygames's Kendrick Lamar deluxe: - user has ``The Recipe - Bonus Track`` (track 1, disc 2 in his library) - AND ``Sherane`` (track 1, disc 1). The API returns the bonus track as - plain ``The Recipe`` (no suffix). Without substring matching, the - bonus track falls through to track-number-only and lands on disc 1. - With substring matching (gated on track_number), it correctly routes - to disc 2.""" - library, _staging, _transfer = tmpdirs - db = _FakeDB() - _setup_album(db, deezer_id='dz-1', tracks=[ - # Disc 1, track 1 - ('t1d1', 1, 'Sherane', _make_audio_file(library, 'd1t1.flac')), - # Disc 2, track 1 — local title has " - Bonus Track" suffix - ('t1d2', 1, 'The Recipe - Bonus Track', _make_audio_file(library, 'd2t1.flac')), - ]) - - monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') - monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) - monkeypatch.setattr(library_reorganize, 'get_album_for_source', - lambda *a: {'id': 'dz-1', 'name': 'good kid m.A.A.d city (Deluxe)'}) - monkeypatch.setattr( - library_reorganize, 'get_album_tracks_for_source', - lambda *a: {'items': [ - {'id': 'a1', 'name': 'Sherane', 'track_number': 1, 'disc_number': 1}, - {'id': 'a2', 'name': 'The Recipe', 'track_number': 1, 'disc_number': 2}, - ]}, - ) - - title_to_disc = {} - - def pp(key, ctx, fp): - title_to_disc[ctx['track_info']['name']] = ctx['track_info']['disc_number'] - ctx['_final_processed_path'] = str(library / f"out_{ctx['track_info']['disc_number']}.flac") - with open(ctx['_final_processed_path'], 'wb') as f: - f.write(b'final') - - library_reorganize.reorganize_album( - album_id='alb-1', db=db, staging_root=str(_staging), - resolve_file_path_fn=lambda p: p, post_process_fn=pp, - ) - - # The local "The Recipe - Bonus Track" must route to the API's - # disc-2 entry (which is named just "The Recipe"), via substring - # match + track_number tiebreaker. - assert title_to_disc['Sherane'] == 1 - assert title_to_disc['The Recipe'] == 2 - - -def test_dash_vs_parens_normalize_equally_for_remix_versions(monkeypatch, tmpdirs): - """Local file has ``Bitch, Don't Kill My Vibe - Remix`` (dash style), - API has the same track as ``Bitch, Don't Kill My Vibe (Remix)`` - (parens style). Both must normalize to the same string so tier 1 - matches without falling to substring or track_number fallbacks.""" - library, staging, _transfer = tmpdirs - db = _FakeDB() - _setup_album(db, deezer_id='dz-1', tracks=[ - ('t1', 5, "Bitch, Don't Kill My Vibe - Remix", _make_audio_file(library, 't1.flac')), - ]) - - monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') - monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) - monkeypatch.setattr(library_reorganize, 'get_album_for_source', - lambda *a: {'id': 'dz-1', 'name': 'A'}) - monkeypatch.setattr( - library_reorganize, 'get_album_tracks_for_source', - lambda *a: {'items': [ - {'id': 'a1', 'name': "Bitch, Don't Kill My Vibe (Remix)", - 'track_number': 5, 'disc_number': 2}, - ]}, - ) - - matched = [] - - def pp(key, ctx, fp): - matched.append((ctx['track_info']['name'], ctx['track_info']['disc_number'])) - ctx['_final_processed_path'] = str(library / 'out.flac') - with open(ctx['_final_processed_path'], 'wb') as f: - f.write(b'final') - - library_reorganize.reorganize_album( - album_id='alb-1', db=db, staging_root=str(staging), - resolve_file_path_fn=lambda p: p, post_process_fn=pp, - ) - - assert matched == [("Bitch, Don't Kill My Vibe (Remix)", 2)] - - -def test_substring_match_handles_track_number_disagreement(monkeypatch, tmpdirs): - """Real-world Kendrick Lamar deluxe case: the user's library has - ``The Recipe (Black Hippy Remix) - Bonus Track`` numbered as track - 4 of disc 2, but Deezer has the same track at disc 2 track 5 (and - has ``Bitch... (Remix)`` at disc 2 track 4). Track_number-gated - containment misses; length-ratio containment must pick the right - one without false-positive risk.""" - library, staging, _transfer = tmpdirs - db = _FakeDB() - _setup_album(db, deezer_id='dz-1', tracks=[ - ('t4', 4, 'The Recipe (Black Hippy Remix) - Bonus Track', - _make_audio_file(library, 't4.flac')), - ]) - - monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') - monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) - monkeypatch.setattr(library_reorganize, 'get_album_for_source', - lambda *a: {'id': 'dz-1', 'name': 'A'}) - monkeypatch.setattr( - library_reorganize, 'get_album_tracks_for_source', - lambda *a: {'items': [ - # API has the bonus tracks in a different order than the user - {'id': 'd1t4', 'name': 'The Art of Peer Pressure', - 'track_number': 4, 'disc_number': 1}, - {'id': 'd2t4', 'name': "Bitch, Don't Kill My Vibe (Remix)", - 'track_number': 4, 'disc_number': 2}, - {'id': 'd2t5', 'name': 'The Recipe (Black Hippy Remix)', - 'track_number': 5, 'disc_number': 2}, - ]}, - ) - - matched = [] - - def pp(key, ctx, fp): - matched.append((ctx['track_info']['name'], ctx['track_info']['disc_number'])) - ctx['_final_processed_path'] = str(library / 'out.flac') - with open(ctx['_final_processed_path'], 'wb') as f: - f.write(b'final') - - library_reorganize.reorganize_album( - album_id='alb-1', db=db, staging_root=str(staging), - resolve_file_path_fn=lambda p: p, post_process_fn=pp, - ) - - # The local Black Hippy Remix Bonus Track must end up in disc 2, - # NOT collide with disc 1's "Art of Peer Pressure" via track_number. - assert matched == [('The Recipe (Black Hippy Remix)', 2)] - - -def test_remix_does_not_substring_match_to_original_recording(monkeypatch, tmpdirs): - """winecountrygames's iTunes case: iTunes doesn't have the remix, - just the original ``Bitch Don't Kill My Vibe``. Substring + ratio - alone would merge the local remix bonus track into the original - via tier 4 (ratio 0.78). Reject because they have different version - differentiators ('remix' vs none) — they're different recordings.""" - library, staging, _transfer = tmpdirs - db = _FakeDB() - _setup_album(db, itunes_id='it-1', tracks=[ - # Original — should match cleanly via tier 1 to iTunes' entry - ('t2', 2, "Bitch, Don't Kill My Vibe", _make_audio_file(library, 't2.flac')), - # Remix — iTunes doesn't have it; must report unmatched, NOT - # collide with the original via substring - ('t5', 5, "Bitch, Don't Kill My Vibe - Remix", _make_audio_file(library, 't5.flac')), - ]) - - monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'itunes') - monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) - monkeypatch.setattr(library_reorganize, 'get_album_for_source', - lambda *a: {'id': 'it-1', 'name': 'A'}) - monkeypatch.setattr( - library_reorganize, 'get_album_tracks_for_source', - lambda *a: {'items': [ - {'id': 'it2', 'name': "Bitch, Don't Kill My Vibe", - 'track_number': 2, 'disc_number': 1}, - ]}, - ) - - matched_titles = [] - skipped_titles = [] - - def pp(key, ctx, fp): - matched_titles.append(ctx['track_info']['name']) - ctx['_final_processed_path'] = str(library / f'out_{ctx["track_info"]["name"]}.flac') - with open(ctx['_final_processed_path'], 'wb') as f: - f.write(b'final') - - summary = library_reorganize.reorganize_album( - album_id='alb-1', db=db, staging_root=str(staging), - resolve_file_path_fn=lambda p: p, post_process_fn=pp, - ) - - skipped_titles = [e['title'] for e in summary['errors']] - # Only the original should have been processed - assert matched_titles == ["Bitch, Don't Kill My Vibe"] - # The remix should be reported as unmatched, NOT merged with the original - assert "Bitch, Don't Kill My Vibe - Remix" in skipped_titles - assert summary['moved'] == 1 - assert summary['skipped'] == 1 - - -def test_substring_match_does_not_false_positive_across_discs(monkeypatch, tmpdirs): - """Safety: ``Real`` (substring) must not silently map to a longer - track like ``Real Real Real`` on a different disc. Substring match - is gated on matching track_number; if the only API entry whose - title contains the local one has a different track_number, the - matcher must fall through to last-resort track_number-only.""" - library, staging, _transfer = tmpdirs - db = _FakeDB() - _setup_album(db, deezer_id='dz-1', tracks=[ - ('t11', 11, 'Real', _make_audio_file(library, 't11.flac')), - ]) - - monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') - monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) - monkeypatch.setattr(library_reorganize, 'get_album_for_source', - lambda *a: {'id': 'dz-1', 'name': 'A'}) - monkeypatch.setattr( - library_reorganize, 'get_album_tracks_for_source', - lambda *a: {'items': [ - # Real-the-track on disc 1, position 11 — the right answer - {'id': 'a1', 'name': 'Real', 'track_number': 11, 'disc_number': 1}, - # A nearby longer title on disc 2 that contains "real" — must NOT win - {'id': 'a2', 'name': 'Real Real Real', 'track_number': 1, 'disc_number': 2}, - ]}, - ) - - matched = [] - - def pp(key, ctx, fp): - matched.append((ctx['track_info']['name'], ctx['track_info']['disc_number'])) - ctx['_final_processed_path'] = str(library / 'out.flac') - with open(ctx['_final_processed_path'], 'wb') as f: - f.write(b'final') - - library_reorganize.reorganize_album( - album_id='alb-1', db=db, staging_root=str(staging), - resolve_file_path_fn=lambda p: p, post_process_fn=pp, - ) - - # Tier 1 (exact + track_number) wins for the legitimate disc 1 entry - assert matched == [('Real', 1)] - - -def test_skips_track_when_source_tracklist_doesnt_contain_it(monkeypatch, tmpdirs): - """winecountrygames's actual scenario: Deezer's response for the - Kendrick deluxe was missing 'The Recipe (Black Hippy Remix)' — the - user has 17 local tracks, Deezer knows 16. The 17th local track - has no title-based match anywhere in the API tracklist. Per the - design policy 'trust the source', we must NOT fall back to - track_number-only matching (which would falsely route the missing - bonus track to whatever disc-1 entry shares its track_number, - causing a collision with a totally unrelated song).""" - library, staging, _transfer = tmpdirs - db = _FakeDB() - _setup_album(db, deezer_id='dz-1', tracks=[ - # Local tn=4 — but API doesn't have this track at all; the only - # API entry with track_number=4 is "The Art of Peer Pressure" - # (a completely different song). Old tier-5 fallback would have - # silently routed our bonus track to that entry → collision. - ('t4', 4, 'The Recipe (Black Hippy Remix) - Bonus Track', - _make_audio_file(library, 't4.flac')), - ]) - - monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') - monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) - monkeypatch.setattr(library_reorganize, 'get_album_for_source', - lambda *a: {'id': 'dz-1', 'name': 'A'}) - monkeypatch.setattr( - library_reorganize, 'get_album_tracks_for_source', - lambda *a: {'items': [ - # Same tn=4, completely different title — must NOT capture - # the local track via track_number fallback. - {'id': 'd1t4', 'name': 'The Art of Peer Pressure', - 'track_number': 4, 'disc_number': 1}, - ]}, - ) - - pp_calls = [] - - def pp(*a, **k): - pp_calls.append(a) - - summary = library_reorganize.reorganize_album( - album_id='alb-1', db=db, staging_root=str(staging), - resolve_file_path_fn=lambda p: p, post_process_fn=pp, - ) - - # Track must be skipped, NOT routed to The Art of Peer Pressure. - assert summary['moved'] == 0 - assert summary['skipped'] == 1 - assert pp_calls == [] - assert 'not in' in summary['errors'][0]['error'].lower() \ - or 'bonus' in summary['errors'][0]['error'].lower() \ - or 'non-canonical' in summary['errors'][0]['error'].lower() - - -def test_skips_track_not_in_api_tracklist(monkeypatch, tmpdirs): - """Bonus track scenario: user has 12 tracks, source's catalog version - only has 10. Tracks not in the API response must be skipped, NOT - force-fed wrong context to post-process.""" - library, staging, _transfer = tmpdirs - db = _FakeDB() - _setup_album(db, deezer_id='dz-1', tracks=[ - ('t1', 1, 'Track 1', _make_audio_file(library, 't1.flac')), - ('t2', 2, 'Track 2', _make_audio_file(library, 't2.flac')), - ('t11', 11, 'Bonus Track', _make_audio_file(library, 't11.flac')), - ]) - - monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') - monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) - monkeypatch.setattr(library_reorganize, 'get_album_for_source', - lambda *a: {'id': 'dz-1', 'name': 'A'}) - monkeypatch.setattr( - library_reorganize, 'get_album_tracks_for_source', - lambda *a: {'items': [ - {'id': 'a1', 'name': 'Track 1', 'track_number': 1, 'disc_number': 1}, - {'id': 'a2', 'name': 'Track 2', 'track_number': 2, 'disc_number': 1}, - ]}, - ) - - pp_for = [] - - def pp(key, ctx, fp): - pp_for.append(ctx['track_info']['track_number']) - ctx['_final_processed_path'] = str(library / f"out_{ctx['track_info']['track_number']}.flac") - with open(ctx['_final_processed_path'], 'wb') as f: - f.write(b'final') - - summary = library_reorganize.reorganize_album( - album_id='alb-1', db=db, staging_root=str(staging), - resolve_file_path_fn=lambda p: p, post_process_fn=pp, - ) - - assert sorted(pp_for) == [1, 2] - assert summary['moved'] == 2 - assert summary['skipped'] == 1 - assert any('Bonus Track' in e['title'] for e in summary['errors']) - - -def test_surfaces_unresolved_file_path(monkeypatch, tmpdirs): - library, staging, _transfer = tmpdirs - db = _FakeDB() - _setup_album(db, deezer_id='dz-1', tracks=[ - ('t1', 1, 'Track 1', '/nonexistent/file.flac'), - ]) - - monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') - monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) - monkeypatch.setattr(library_reorganize, 'get_album_for_source', - lambda *a: {'id': 'dz-1', 'name': 'A'}) - monkeypatch.setattr( - library_reorganize, 'get_album_tracks_for_source', - lambda *a: {'items': [{'id': 'a1', 'name': 'Track 1', 'track_number': 1}]}, - ) - - pp_calls = [] - - def pp(*a, **k): - pp_calls.append(a) - - summary = library_reorganize.reorganize_album( - album_id='alb-1', db=db, staging_root=str(staging), - resolve_file_path_fn=lambda p: None, # nothing resolves - post_process_fn=pp, - ) - - assert summary['skipped'] == 1 - assert summary['moved'] == 0 - assert pp_calls == [] - assert '/nonexistent/file.flac' in summary['errors'][0]['error'] - - -def test_failed_post_process_leaves_original_in_place(monkeypatch, tmpdirs): - """If post-process fails (AcoustID rejection, exception, anything), - the original file must remain at its location and the DB must NOT - be updated. Worst-case the user retries; we don't lose data.""" - library, staging, _transfer = tmpdirs - db = _FakeDB() - src_file = _make_audio_file(library, 't1.flac') - _setup_album(db, deezer_id='dz-1', tracks=[ - ('t1', 1, 'Track 1', src_file), - ]) - - monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') - monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) - monkeypatch.setattr(library_reorganize, 'get_album_for_source', - lambda *a: {'id': 'dz-1', 'name': 'A'}) - monkeypatch.setattr( - library_reorganize, 'get_album_tracks_for_source', - lambda *a: {'items': [{'id': 'a1', 'name': 'Track 1', 'track_number': 1}]}, - ) - - def pp(key, ctx, fp): - # Simulate AcoustID rejection: don't set _final_processed_path - return - - db_updates = [] - - def update_path(track_id, new_path): - db_updates.append((track_id, new_path)) - - summary = library_reorganize.reorganize_album( - album_id='alb-1', db=db, staging_root=str(staging), - resolve_file_path_fn=lambda p: p, post_process_fn=pp, - update_track_path_fn=update_path, - ) - - assert summary['failed'] == 1 - assert summary['moved'] == 0 - assert os.path.exists(src_file) - assert db_updates == [] - - -def test_post_process_exception_is_caught_and_original_preserved(monkeypatch, tmpdirs): - library, staging, _transfer = tmpdirs - db = _FakeDB() - src_file = _make_audio_file(library, 't1.flac') - _setup_album(db, deezer_id='dz-1', tracks=[ - ('t1', 1, 'Track 1', src_file), - ]) - - monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') - monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) - monkeypatch.setattr(library_reorganize, 'get_album_for_source', - lambda *a: {'id': 'dz-1', 'name': 'A'}) - monkeypatch.setattr( - library_reorganize, 'get_album_tracks_for_source', - lambda *a: {'items': [{'id': 'a1', 'name': 'Track 1', 'track_number': 1}]}, - ) - - def pp(*a, **k): - raise RuntimeError("boom") - - summary = library_reorganize.reorganize_album( - album_id='alb-1', db=db, staging_root=str(staging), - resolve_file_path_fn=lambda p: p, post_process_fn=pp, - ) - - assert summary['failed'] == 1 - assert os.path.exists(src_file) - - -def test_recreates_staging_dir_when_post_process_cleans_it(monkeypatch, tmpdirs): - """Regression test for the "1 moved, 15 failed (path not found)" bug - winecountrygames hit on his first reorganize run. - - Post-processing calls `_cleanup_empty_directories` after each move. - That walks up from the source file removing empties — and since the - only thing in our staging_album_dir is the staged file we just had - post-process consume, the dir is empty after the move and gets - nuked. The next track's `shutil.copy2` then failed with WinError 3 - because the destination directory no longer existed. - - The orchestrator must recreate staging_album_dir before each copy.""" - library, staging, _transfer = tmpdirs - db = _FakeDB() - _setup_album(db, deezer_id='dz-1', tracks=[ - ('t1', 1, 'Track 1', _make_audio_file(library, 't1.flac')), - ('t2', 2, 'Track 2', _make_audio_file(library, 't2.flac')), - ('t3', 3, 'Track 3', _make_audio_file(library, 't3.flac')), - ]) - - monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') - monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) - monkeypatch.setattr(library_reorganize, 'get_album_for_source', - lambda *a: {'id': 'dz-1', 'name': 'A'}) - monkeypatch.setattr( - library_reorganize, 'get_album_tracks_for_source', - lambda *a: {'items': [ - {'id': 'a1', 'name': 'Track 1', 'track_number': 1}, - {'id': 'a2', 'name': 'Track 2', 'track_number': 2}, - {'id': 'a3', 'name': 'Track 3', 'track_number': 3}, - ]}, - ) - - final_dir = library / 'final' - final_dir.mkdir() - pp_count = [0] - - def pp_with_aggressive_cleanup(key, ctx, fp): - """Mimic real post-process: move the file, then walk up from - the source directory removing empties (which includes our - staging_album_dir).""" - pp_count[0] += 1 - final = str(final_dir / f"final_{pp_count[0]}.flac") - shutil.move(fp, final) - ctx['_final_processed_path'] = final - - # Walk up from the staged file's old directory, deleting - # any empty dir until we hit the staging root. - dir_to_check = os.path.dirname(fp) - while os.path.normpath(dir_to_check) != os.path.normpath(str(staging)): - try: - os.rmdir(dir_to_check) - except OSError: - break - dir_to_check = os.path.dirname(dir_to_check) - - summary = library_reorganize.reorganize_album( - album_id='alb-1', db=db, staging_root=str(staging), - resolve_file_path_fn=lambda p: p, - post_process_fn=pp_with_aggressive_cleanup, - ) - - # All three tracks must succeed despite the staging dir being - # nuked between each one. - assert summary['moved'] == 3 - assert summary['failed'] == 0 - - -def test_db_update_failure_leaves_original_in_place(monkeypatch, tmpdirs): - """Safety property: a failing DB write must NOT trigger the original - file's deletion. Otherwise we'd have a library row pointing at a - now-deleted path with no easy recovery. Better: leave the file at - BOTH locations (original + new) so the next library scan re-indexes - from the new path and the user doesn't lose data.""" - library, staging, _transfer = tmpdirs - db = _FakeDB() - src = _make_audio_file(library, 't1.flac') - final_dir = library / 'final' - final_dir.mkdir() - - _setup_album(db, deezer_id='dz-1', tracks=[ - ('t1', 1, 'Track 1', src), - ]) - - monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') - monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) - monkeypatch.setattr(library_reorganize, 'get_album_for_source', - lambda *a: {'id': 'dz-1', 'name': 'A'}) - monkeypatch.setattr( - library_reorganize, 'get_album_tracks_for_source', - lambda *a: {'items': [{'id': 'a1', 'name': 'Track 1', 'track_number': 1}]}, - ) - - final_path = str(final_dir / 't1.flac') - - def pp(key, ctx, fp): - shutil.move(fp, final_path) - ctx['_final_processed_path'] = final_path - - def update_path_explodes(track_id, new_path): - raise RuntimeError("simulated DB failure") - - summary = library_reorganize.reorganize_album( - album_id='alb-1', db=db, staging_root=str(staging), - resolve_file_path_fn=lambda p: p, post_process_fn=pp, - update_track_path_fn=update_path_explodes, - ) - - assert os.path.exists(src), "Original must still exist when DB update failed" - assert os.path.exists(final_path), "New path file should also exist (post-process succeeded)" - # kettui PR #377 review: a DB-update failure must NOT increment - # `moved` — that would overstate how many tracks the UI knows are - # at their new locations. Track is reported as failed instead. - assert summary['moved'] == 0 - assert summary['failed'] == 1 - assert any('DB update failed' in e['error'] for e in summary['errors']) - - -def test_successful_run_removes_original_and_updates_db(monkeypatch, tmpdirs): - library, staging, _transfer = tmpdirs - db = _FakeDB() - src = _make_audio_file(library, 't1.flac') - final_dir = library / 'final' - final_dir.mkdir() - - _setup_album(db, deezer_id='dz-1', tracks=[ - ('t1', 1, 'Track 1', src), - ]) - - monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') - monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) - monkeypatch.setattr(library_reorganize, 'get_album_for_source', - lambda *a: {'id': 'dz-1', 'name': 'A'}) - monkeypatch.setattr( - library_reorganize, 'get_album_tracks_for_source', - lambda *a: {'items': [{'id': 'a1', 'name': 'Track 1', 'track_number': 1}]}, - ) - - def pp(key, ctx, fp): - # Pretend post-processing moved the staged file to a final location - final = str(final_dir / 't1.flac') - shutil.move(fp, final) - ctx['_final_processed_path'] = final - - db_updates = [] - - def update_path(track_id, new_path): - db_updates.append((track_id, new_path)) - - summary = library_reorganize.reorganize_album( - album_id='alb-1', db=db, staging_root=str(staging), - resolve_file_path_fn=lambda p: p, post_process_fn=pp, - update_track_path_fn=update_path, - ) - - assert summary['moved'] == 1 - assert summary['failed'] == 0 - assert not os.path.exists(src) - assert os.path.exists(str(final_dir / 't1.flac')) - assert db_updates == [('t1', str(final_dir / 't1.flac'))] - - -# --- tests: cleanup ------------------------------------------------------- - -def test_staging_dir_cleaned_up_on_success(monkeypatch, tmpdirs): - library, staging, _transfer = tmpdirs - db = _FakeDB() - _setup_album(db, deezer_id='dz-1', tracks=[ - ('t1', 1, 'Track 1', _make_audio_file(library, 't1.flac')), - ]) - - monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') - monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) - monkeypatch.setattr(library_reorganize, 'get_album_for_source', - lambda *a: {'id': 'dz-1', 'name': 'A'}) - monkeypatch.setattr( - library_reorganize, 'get_album_tracks_for_source', - lambda *a: {'items': [{'id': 'a1', 'name': 'Track 1', 'track_number': 1}]}, - ) - - def pp(key, ctx, fp): - final = str(library / 'final.flac') - shutil.move(fp, final) - ctx['_final_processed_path'] = final - - library_reorganize.reorganize_album( - album_id='alb-1', db=db, staging_root=str(staging), - resolve_file_path_fn=lambda p: p, post_process_fn=pp, - ) - - assert os.listdir(str(staging)) == [] - - -def test_staging_dir_cleaned_up_even_on_failure(monkeypatch, tmpdirs): - library, staging, _transfer = tmpdirs - db = _FakeDB() - _setup_album(db, deezer_id='dz-1', tracks=[ - ('t1', 1, 'Track 1', _make_audio_file(library, 't1.flac')), - ]) - - monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') - monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) - monkeypatch.setattr(library_reorganize, 'get_album_for_source', - lambda *a: {'id': 'dz-1', 'name': 'A'}) - monkeypatch.setattr( - library_reorganize, 'get_album_tracks_for_source', - lambda *a: {'items': [{'id': 'a1', 'name': 'Track 1', 'track_number': 1}]}, - ) - - def pp(key, ctx, fp): - raise RuntimeError("simulated post-process explosion") - - library_reorganize.reorganize_album( - album_id='alb-1', db=db, staging_root=str(staging), - resolve_file_path_fn=lambda p: p, post_process_fn=pp, - ) - - assert os.listdir(str(staging)) == [] - - -# --- tests: misc ---------------------------------------------------------- - -def test_deletes_per_track_sidecars_after_successful_move(monkeypatch, tmpdirs): - """Real-world Kendrick-Lamar-deluxe shape: each FLAC has a same-stem - `.lrc` sidecar in the source folder. After the audio is moved to its - new location, the original `.lrc` should be removed too — post-process - handles whatever sidecar policy exists at the new destination.""" - library, staging, _transfer = tmpdirs - db = _FakeDB() - audio = _make_audio_file(library, '01 - Sherane.flac') - lrc_path = library / '01 - Sherane.lrc' - lrc_path.write_text('lyrics') - nfo_path = library / '01 - Sherane.nfo' - nfo_path.write_text('metadata') - _setup_album(db, deezer_id='dz-1', tracks=[ - ('t1', 1, 'Sherane', audio), - ]) - - monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') - monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) - monkeypatch.setattr(library_reorganize, 'get_album_for_source', - lambda *a: {'id': 'dz-1', 'name': 'A'}) - monkeypatch.setattr( - library_reorganize, 'get_album_tracks_for_source', - lambda *a: {'items': [{'id': 'a1', 'name': 'Sherane', 'track_number': 1}]}, - ) - - final_dir = library / 'final' - final_dir.mkdir() - - def pp(key, ctx, fp): - final = str(final_dir / 'out.flac') - shutil.move(fp, final) - ctx['_final_processed_path'] = final - - library_reorganize.reorganize_album( - album_id='alb-1', db=db, staging_root=str(staging), - resolve_file_path_fn=lambda p: p, post_process_fn=pp, - ) - - assert not os.path.exists(audio) - assert not lrc_path.exists() - assert not nfo_path.exists() - - -def test_keeps_track_sidecars_when_track_fails_to_move(monkeypatch, tmpdirs): - """If post-process fails (AcoustID rejection), the original audio is - preserved — and so is its sidecar, because the user might want to - investigate or recover the track.""" - library, staging, _transfer = tmpdirs - db = _FakeDB() - audio = _make_audio_file(library, '01 - Sherane.flac') - lrc_path = library / '01 - Sherane.lrc' - lrc_path.write_text('lyrics') - _setup_album(db, deezer_id='dz-1', tracks=[ - ('t1', 1, 'Sherane', audio), - ]) - - monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') - monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) - monkeypatch.setattr(library_reorganize, 'get_album_for_source', - lambda *a: {'id': 'dz-1', 'name': 'A'}) - monkeypatch.setattr( - library_reorganize, 'get_album_tracks_for_source', - lambda *a: {'items': [{'id': 'a1', 'name': 'Sherane', 'track_number': 1}]}, - ) - - def pp_rejects(key, ctx, fp): - return # don't set _final_processed_path = AcoustID-style rejection - - library_reorganize.reorganize_album( - album_id='alb-1', db=db, staging_root=str(staging), - resolve_file_path_fn=lambda p: p, post_process_fn=pp_rejects, - ) - - assert os.path.exists(audio) - assert lrc_path.exists() - - -def test_deletes_album_level_sidecars_when_directory_emptied(monkeypatch, tmpdirs): - """After every track in a source dir is successfully moved out, the - leftover album-level sidecars (cover.jpg, folder.jpg, etc.) should be - removed too so the empty-dir pruner can take the dir. If even one - track failed to move, leave them — the user might want the cover.""" - library, staging, _transfer = tmpdirs - disc1_dir = library / 'Disc 1' - disc1_dir.mkdir() - a1 = _make_audio_file(disc1_dir, '01.flac') - a2 = _make_audio_file(disc1_dir, '02.flac') - cover = disc1_dir / 'cover.jpg' - cover.write_bytes(b'JPEGdata') - folder = disc1_dir / 'folder.jpg' - folder.write_bytes(b'JPEGdata') - - db = _FakeDB() - _setup_album(db, deezer_id='dz-1', tracks=[ - ('t1', 1, 'Track 1', a1), - ('t2', 2, 'Track 2', a2), - ]) - - monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') - monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) - monkeypatch.setattr(library_reorganize, 'get_album_for_source', - lambda *a: {'id': 'dz-1', 'name': 'A'}) - monkeypatch.setattr( - library_reorganize, 'get_album_tracks_for_source', - lambda *a: {'items': [ - {'id': 'a1', 'name': 'Track 1', 'track_number': 1}, - {'id': 'a2', 'name': 'Track 2', 'track_number': 2}, - ]}, - ) - - final_dir = library / 'final' - final_dir.mkdir() - - def pp(key, ctx, fp): - final = str(final_dir / f"{ctx['track_info']['track_number']}.flac") - shutil.move(fp, final) - ctx['_final_processed_path'] = final - - library_reorganize.reorganize_album( - album_id='alb-1', db=db, staging_root=str(staging), - resolve_file_path_fn=lambda p: p, post_process_fn=pp, - ) - - assert not cover.exists() - assert not folder.exists() - - -def test_keeps_album_sidecars_when_a_track_failed_to_move(monkeypatch, tmpdirs): - """If even one track in the dir failed to move out, leave the album - art alone — user might still want to look at / recover the album.""" - library, staging, _transfer = tmpdirs - a1 = _make_audio_file(library, '01.flac') - a2 = _make_audio_file(library, '02.flac') - cover = library / 'cover.jpg' - cover.write_bytes(b'JPEGdata') - - db = _FakeDB() - _setup_album(db, deezer_id='dz-1', tracks=[ - ('t1', 1, 'Track 1', a1), - ('t2', 2, 'Track 2', a2), - ]) - - monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') - monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) - monkeypatch.setattr(library_reorganize, 'get_album_for_source', - lambda *a: {'id': 'dz-1', 'name': 'A'}) - monkeypatch.setattr( - library_reorganize, 'get_album_tracks_for_source', - lambda *a: {'items': [ - {'id': 'a1', 'name': 'Track 1', 'track_number': 1}, - {'id': 'a2', 'name': 'Track 2', 'track_number': 2}, - ]}, - ) - - final_dir = library / 'final' - final_dir.mkdir() - - def pp(key, ctx, fp): - # Track 1 succeeds, track 2 fails (no _final_processed_path set) - if ctx['track_info']['track_number'] == 1: - final = str(final_dir / '1.flac') - shutil.move(fp, final) - ctx['_final_processed_path'] = final - - library_reorganize.reorganize_album( - album_id='alb-1', db=db, staging_root=str(staging), - resolve_file_path_fn=lambda p: p, post_process_fn=pp, - ) - - # Track 2 still in place → cover preserved - assert os.path.exists(a2) - assert cover.exists() - - -# --- preview function (shared planning with the orchestrator) ----------- - -def _fake_path_builder(context, spotify_artist, _album_info, file_ext, **_kw): - """Stand-in for `_build_final_path_for_track`. Inserts Disc N/ when - total_discs > 1 — same convention the real builder uses. Accepts - **_kw so the preview's create_dirs=False kwarg (#767) passes through.""" - album = context['spotify_album']['name'] - artist = spotify_artist['name'] - track_info = context['track_info'] - title = track_info['name'] - tn = track_info['track_number'] - dn = track_info['disc_number'] - total = context['spotify_album']['total_discs'] - parts = ['/transfer', artist, album] - if total > 1: - parts.append(f'Disc {dn}') - parts.append(f"{tn:02d} - {title}{file_ext}") - return '/'.join(parts), True - - -def _path_builder_album_vs_single(context, spotify_artist, album_info, file_ext, **_kw): - """Stand-in that emulates the real `_build_final_path_for_track` - branch on `album_info.get('is_album')`. ALBUM mode produces an - album folder with disc subfolder + numbered file; SINGLE mode - produces a per-track folder named after the title (the bug - output).""" - artist = spotify_artist['name'] - if album_info and album_info.get('is_album'): - album = album_info['album_name'] - title = album_info['clean_track_name'] - tn = album_info['track_number'] - dn = album_info['disc_number'] - total = context['spotify_album']['total_discs'] - if total > 1: - return (f'/transfer/{artist}/{artist} - {album}/Disc {dn}/{tn:02d} - {title}{file_ext}', True) - return (f'/transfer/{artist}/{artist} - {album}/{tn:02d} - {title}{file_ext}', True) - title = context['track_info']['name'] - return (f'/transfer/{artist}/{artist} - {title}/{title}{file_ext}', True) - - -def test_preview_uses_album_mode_not_single_mode(monkeypatch, tmpdirs): - """Regression for the bug where every track ended up in its own - track-named folder (SINGLE MODE) because we passed None for - album_info to the path builder. Multi-disc deluxe must produce - one shared album folder, not N single folders.""" - library, _staging, _transfer = tmpdirs - db = _FakeDB() - _setup_album(db, deezer_id='dz-1', tracks=[ - ('t1', 1, 'Sherane', _make_audio_file(library, 't1.flac')), - ('t2', 2, 'Bitch Dont Kill My Vibe', _make_audio_file(library, 't2.flac')), - ]) - - monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') - monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) - monkeypatch.setattr(library_reorganize, 'get_album_for_source', - lambda *a: {'id': 'dz-1', 'name': 'good kid, m.A.A.d city'}) - monkeypatch.setattr( - library_reorganize, 'get_album_tracks_for_source', - lambda *a: {'items': [ - {'id': 'a1', 'name': 'Sherane', 'track_number': 1, 'disc_number': 1}, - {'id': 'a2', 'name': 'Bitch Dont Kill My Vibe', 'track_number': 2, 'disc_number': 1}, - ]}, - ) - - result = library_reorganize.preview_album_reorganize( - album_id='alb-1', db=db, transfer_dir='/transfer', - resolve_file_path_fn=lambda p: p, - build_final_path_fn=_path_builder_album_vs_single, - ) - - paths = [it['new_path'] for it in result['tracks']] - # Both tracks land under the SAME album folder, not per-track folders - assert all('good kid, m.A.A.d city' in p for p in paths) - # Files use track-number prefix (album mode), not bare title (single mode) - assert any('01 - Sherane' in p for p in paths) - assert any('02 - Bitch Dont Kill My Vibe' in p for p in paths) - # Reject the single-mode shape explicitly - assert not any(p.endswith('/Sherane.flac') for p in paths) - - -def test_preview_emits_disc_subfolders_for_multi_disc_albums(monkeypatch, tmpdirs): - """The bug winecountrygames hit: preview showed all tracks at the - album root with no Disc N/ subfolders, even on a deluxe edition. - Verify the new planner-backed preview produces disc folders.""" - library, _staging, _transfer = tmpdirs - db = _FakeDB() - _setup_album(db, deezer_id='dz-1', tracks=[ - ('t1d1', 1, 'Sherane', _make_audio_file(library, 'd1t1.flac')), - ('t1d2', 1, 'The Recipe', _make_audio_file(library, 'd2t1.flac')), - ]) - - monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') - monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) - monkeypatch.setattr(library_reorganize, 'get_album_for_source', - lambda *a: {'id': 'dz-1', 'name': 'good kid, m.A.A.d city (Deluxe)'}) - monkeypatch.setattr( - library_reorganize, 'get_album_tracks_for_source', - lambda *a: {'items': [ - {'id': 'a1', 'name': 'Sherane', 'track_number': 1, 'disc_number': 1}, - {'id': 'a2', 'name': 'The Recipe', 'track_number': 1, 'disc_number': 2}, - ]}, - ) - - result = library_reorganize.preview_album_reorganize( - album_id='alb-1', db=db, transfer_dir='/transfer', - resolve_file_path_fn=lambda p: p, - build_final_path_fn=_fake_path_builder, - ) - - assert result['success'] is True - assert result['status'] == 'planned' - - by_title = {it['title']: it for it in result['tracks']} - assert 'Disc 1' in by_title['Sherane']['new_path'] - assert 'Disc 2' in by_title['The Recipe']['new_path'] - # And per-track disc_number is propagated for UI display - assert by_title['Sherane']['disc_number'] == 1 - assert by_title['The Recipe']['disc_number'] == 2 - - -def test_preview_status_no_source_id_when_album_lacks_ids(monkeypatch, tmpdirs): - library, _staging, _transfer = tmpdirs - db = _FakeDB() - _setup_album(db, tracks=[ - ('t1', 1, 'Track 1', _make_audio_file(library, 't1.flac')), - ]) - - monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') - monkeypatch.setattr(library_reorganize, 'get_source_priority', - lambda p: [p, 'spotify', 'itunes', 'discogs', 'hydrabase']) - monkeypatch.setattr(library_reorganize, 'get_album_for_source', lambda *a: None) - monkeypatch.setattr(library_reorganize, 'get_album_tracks_for_source', lambda *a: None) - - result = library_reorganize.preview_album_reorganize( - album_id='alb-1', db=db, transfer_dir='/transfer', - resolve_file_path_fn=lambda p: p, - build_final_path_fn=_fake_path_builder, - ) - - assert result['status'] == 'no_source_id' - assert result['success'] is False - - -def test_preview_marks_unmatched_tracks(monkeypatch, tmpdirs): - """Tracks with no plausible API match (no exact title, no substring, - no track_number) get reported as unmatched with a reason.""" - library, _staging, _transfer = tmpdirs - db = _FakeDB() - _setup_album(db, deezer_id='dz-1', tracks=[ - ('t1', 1, 'A Real Track', _make_audio_file(library, 't1.flac')), - # Use a track_number with no API counterpart and a title that - # has no substring overlap with anything in the API list — so - # no tier matches. - ('t99', 99, 'Completely Unrelated Side Quest', - _make_audio_file(library, 't99.flac')), - ]) - - monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') - monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) - monkeypatch.setattr(library_reorganize, 'get_album_for_source', - lambda *a: {'id': 'dz-1', 'name': 'A'}) - monkeypatch.setattr( - library_reorganize, 'get_album_tracks_for_source', - lambda *a: {'items': [{'id': 'a1', 'name': 'A Real Track', 'track_number': 1}]}, - ) - - result = library_reorganize.preview_album_reorganize( - album_id='alb-1', db=db, transfer_dir='/transfer', - resolve_file_path_fn=lambda p: p, - build_final_path_fn=_fake_path_builder, - ) - - by_title = {it['title']: it for it in result['tracks']} - assert by_title['A Real Track']['matched'] is True - assert by_title['A Real Track']['new_path'] - assert by_title['Completely Unrelated Side Quest']['matched'] is False - assert by_title['Completely Unrelated Side Quest']['reason'] - assert by_title['Completely Unrelated Side Quest']['new_path'] == '' - - -def test_preview_plans_move_out_of_old_template_folder(monkeypatch, tmpdirs): - """TheHomeGuy's report: after changing the album template (dropping the - '$albumartist - ' prefix from the album folder), both the Tools-page job - and Reorganize All did nothing — every track came back `unchanged`. - - Root cause: the preview computes destinations through the REAL - `build_final_path_for_track`, whose #829 existing-folder reuse resolves - the folder the album ALREADY lives in — the old-template folder it's - supposed to move out of — so destination == current location. This wires - the real builder with a poisoned resolver and pins that a reorganize - context never consults it.""" - import core.imports.paths as import_paths - import core.library.existing_album_folder as eaf - import database.music_database as mdb - - library, _staging, transfer = tmpdirs - - class _Cfg: - def __init__(self, values): - self._values = values - - def get(self, key, default=None): - return self._values.get(key, default) - - def get_active_media_server(self): - return None - - monkeypatch.setattr(import_paths, "_get_config_manager", lambda: _Cfg({ - "soulseek.transfer_path": str(transfer), - "file_organization.enabled": True, - "file_organization.templates": { - # The NEW template — no artist prefix on the album folder. - "album_path": "$albumartist/$album/$track - $title", - "single_path": "$artist/$title", - }, - "file_organization.collab_artist_mode": "first", - "file_organization.disc_label": "Disc", - })) - monkeypatch.setattr(import_paths, "_get_album_tracks_for_source", lambda *a: None) - - # The file sits where the OLD template put it: Artist/Artist - Album/. - old_home = transfer / "Aerosmith" / "Aerosmith - Rocks" - old_home.mkdir(parents=True) - current = old_home / "01 - Back in the Saddle.flac" - current.write_bytes(b"fakeflacdata") - - # Poisoned resolver: if the builder consults folder reuse at all, it gets - # the old folder back and the preview would report `unchanged` (the bug). - monkeypatch.setattr(mdb, "get_database", lambda: object(), raising=False) - resolver_calls = [] - monkeypatch.setattr(eaf, "resolve_existing_album_folder", - lambda **kw: resolver_calls.append(kw) or str(old_home)) - - db = _FakeDB() - _setup_album(db, deezer_id='dz-1', tracks=[ - ('t1', 1, 'Back in the Saddle', str(current)), - ]) - monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') - monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) - monkeypatch.setattr(library_reorganize, 'get_album_for_source', - lambda *a: {'id': 'dz-1', 'name': 'Rocks'}) - monkeypatch.setattr( - library_reorganize, 'get_album_tracks_for_source', - lambda *a: {'items': [ - {'id': 'a1', 'name': 'Back in the Saddle', 'track_number': 1, 'disc_number': 1}, - ]}, - ) - - result = library_reorganize.preview_album_reorganize( - album_id='alb-1', db=db, transfer_dir=str(transfer), - resolve_file_path_fn=lambda p: p, - build_final_path_fn=import_paths.build_final_path_for_track, - ) - - assert result['success'] is True - (track,) = result['tracks'] - assert track['matched'] is True - assert resolver_calls == [] # reuse never consulted for reorganize - assert track['unchanged'] is False # the bug reported True here - assert track['new_path_abs'] == str( - transfer / "Aerosmith" / "Rocks" / "01 - Back in the Saddle.flac") - - -def test_reorganize_context_disables_folder_reuse(): - """The contract the preview test above relies on: every reorganize - pipeline context (preview, rename-only apply, full apply — all built by - `_build_post_process_context`) carries the no-reuse flag.""" - context = library_reorganize._build_post_process_context( - {'id': 'dz-1', 'name': 'Rocks'}, - {'id': 'a1', 'name': 'Back in the Saddle', 'track_number': 1}, - 'Aerosmith', 'Rocks', 1, - ) - assert context['_no_album_folder_reuse'] is True - - -def test_reorganize_context_is_a_local_import(): - """Reorganize handles the user's OWN library files — the integrity check's - duration-agreement leg must not apply (#804 semantics). Without this flag, - a file whose duration drifts from the re-resolved API tracklist (a - different master / long version) got a copy QUARANTINED during reorganize - (TheHomeGuy: 'Through Glass' 283s vs Discogs' 241s → quarantine + failed).""" - context = library_reorganize._build_post_process_context( - {'id': 'dz-1', 'name': 'Come What(ever) May'}, - {'id': 'a1', 'name': 'Through Glass', 'track_number': 8, 'duration_ms': 241000}, - 'Stone Sour', 'Come What(ever) May', 1, - ) - assert context['is_local_import'] is True - - # And the pipeline seam this flag drives: a local import never carries an - # expected duration into the integrity check. - from core.imports.file_integrity import expected_duration_for_check - assert expected_duration_for_check(241000, True) is None - assert expected_duration_for_check(241000, False) == 241000 - - -def test_preview_uses_same_logic_as_apply(monkeypatch, tmpdirs): - """Sanity check: a multi-disc album previewed and then applied - should show the same destinations. If preview drift creeps in - again, this fails.""" - library, staging, _transfer = tmpdirs - db = _FakeDB() - _setup_album(db, deezer_id='dz-1', tracks=[ - ('t1d1', 1, 'D1T1', _make_audio_file(library, 'd1t1.flac')), - ('t1d2', 1, 'D2T1', _make_audio_file(library, 'd2t1.flac')), - ]) - - monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') - monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) - monkeypatch.setattr(library_reorganize, 'get_album_for_source', - lambda *a: {'id': 'dz-1', 'name': 'Test Album'}) - monkeypatch.setattr( - library_reorganize, 'get_album_tracks_for_source', - lambda *a: {'items': [ - {'id': 'a1', 'name': 'D1T1', 'track_number': 1, 'disc_number': 1}, - {'id': 'a2', 'name': 'D2T1', 'track_number': 1, 'disc_number': 2}, - ]}, - ) - - preview = library_reorganize.preview_album_reorganize( - album_id='alb-1', db=db, transfer_dir='/transfer', - resolve_file_path_fn=lambda p: p, - build_final_path_fn=_fake_path_builder, - ) - - # Now apply with the same matching logic; assert apply uses the - # same disc_number per track that the preview reported. - apply_disc_per_title = {} - - def pp(key, ctx, fp): - apply_disc_per_title[ctx['track_info']['name']] = ctx['track_info']['disc_number'] - ctx['_final_processed_path'] = fp - with open(fp, 'wb') as f: - f.write(b'final') - - library_reorganize.reorganize_album( - album_id='alb-1', db=db, staging_root=str(staging), - resolve_file_path_fn=lambda p: p, post_process_fn=pp, - ) - - preview_disc_per_title = {it['title']: it['disc_number'] for it in preview['tracks']} - assert preview_disc_per_title == apply_disc_per_title - - -def test_available_sources_only_lists_authed_sources_with_stored_ids(monkeypatch): - """The reorganize modal needs to know which sources the user can - actually pick. A source is pickable iff: (a) we have an album ID - for that source on the local row, AND (b) the user has the source - authed/configured. Empty-ID sources and unauthed sources are - omitted.""" - monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') - monkeypatch.setattr(library_reorganize, 'get_source_priority', - lambda p: [p, 'spotify', 'itunes', 'discogs', 'hydrabase']) - - # Authed: deezer + spotify only. - auth = {'deezer': object(), 'spotify': object()} - monkeypatch.setattr(library_reorganize, 'get_client_for_source', - lambda src: auth.get(src)) - - album = { - 'spotify_album_id': 'sp-1', - 'deezer_id': 'dz-1', - 'itunes_album_id': 'it-1', # has ID but user not authed - 'discogs_id': '', # no ID - 'soul_id': '', # no ID - } - - sources = library_reorganize.available_sources_for_album(album) - names = [s['source'] for s in sources] - - assert names == ['deezer', 'spotify'] - assert all('label' in s for s in sources) - - -def test_authed_sources_lists_all_authed_regardless_of_album_ids(monkeypatch): - """Bulk reorganize uses this — needs the authed sources without - requiring per-album ID coverage. Each album in the bulk run will - do its own per-album ID check at apply time.""" - monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'spotify') - monkeypatch.setattr(library_reorganize, 'get_source_priority', - lambda p: [p, 'deezer', 'itunes', 'discogs', 'hydrabase']) - - # Authed: spotify + deezer + itunes; discogs + hydrabase NOT authed. - auth = {'spotify': object(), 'deezer': object(), 'itunes': object()} - monkeypatch.setattr(library_reorganize, 'get_client_for_source', - lambda src: auth.get(src)) - - sources = library_reorganize.authed_sources() - names = [s['source'] for s in sources] - - # Primary first, then rest of priority chain — only authed ones - assert names == ['spotify', 'deezer', 'itunes'] - assert all('label' in s for s in sources) - - -def test_strict_source_does_not_fall_back(monkeypatch, tmpdirs): - """When the user picks a specific source in the modal, we must NOT - silently fall back to another source if their pick fails. Picking - Spotify means 'use Spotify or fail' — falling back would defeat - the picker's purpose.""" - library, staging, _transfer = tmpdirs - db = _FakeDB() - _setup_album(db, deezer_id='dz-1', spotify_id='sp-1', tracks=[ - ('t1', 1, 'Track 1', _make_audio_file(library, 't1.flac')), - ]) - - monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') - monkeypatch.setattr(library_reorganize, 'get_source_priority', - lambda p: [p, 'deezer', 'itunes']) - - fetched = [] - - def fake_album(src, sid): - fetched.append(('album', src)) - if src == 'deezer': - return {'id': 'dz-1', 'name': 'Album'} - return None # spotify "fails" - - def fake_tracks(src, sid): - fetched.append(('tracks', src)) - if src == 'deezer': - return {'items': [{'id': 'd1', 'name': 'Track 1', 'track_number': 1}]} - return None - - monkeypatch.setattr(library_reorganize, 'get_album_for_source', fake_album) - monkeypatch.setattr(library_reorganize, 'get_album_tracks_for_source', fake_tracks) - - pp_calls = [] - - def pp(*a, **k): - pp_calls.append(a) - - summary = library_reorganize.reorganize_album( - album_id='alb-1', db=db, staging_root=str(staging), - resolve_file_path_fn=lambda p: p, post_process_fn=pp, - primary_source='spotify', strict_source=True, - ) - - # Spotify failed; with strict_source we must NOT have queried Deezer. - assert summary['status'] == 'no_source_id' - assert summary['moved'] == 0 - assert pp_calls == [] - assert all(src == 'spotify' for _kind, src in fetched) - - -def test_non_strict_falls_back_when_primary_returns_nothing(monkeypatch, tmpdirs): - """When the user did NOT pick a specific source (default behavior), - the orchestrator walks the priority chain as before.""" - library, staging, _transfer = tmpdirs - db = _FakeDB() - _setup_album(db, deezer_id='dz-1', spotify_id='sp-1', tracks=[ - ('t1', 1, 'Track 1', _make_audio_file(library, 't1.flac')), - ]) - - monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'spotify') - monkeypatch.setattr(library_reorganize, 'get_source_priority', - lambda p: [p, 'deezer', 'itunes']) - monkeypatch.setattr(library_reorganize, 'get_album_for_source', - lambda src, sid: ({'id': sid, 'name': 'A'} if src == 'deezer' else None)) - monkeypatch.setattr( - library_reorganize, 'get_album_tracks_for_source', - lambda src, sid: ({'items': [{'id': 'a1', 'name': 'Track 1', 'track_number': 1}]} - if src == 'deezer' else None), - ) - - def pp(key, ctx, fp): - ctx['_final_processed_path'] = str(library / 'out.flac') - with open(ctx['_final_processed_path'], 'wb') as f: - f.write(b'final') - - summary = library_reorganize.reorganize_album( - album_id='alb-1', db=db, staging_root=str(staging), - resolve_file_path_fn=lambda p: p, post_process_fn=pp, - # No strict_source → uses default fallback chain - ) - assert summary['source'] == 'deezer' - assert summary['moved'] == 1 - - -def test_returns_no_album_when_id_does_not_exist(tmpdirs): - _library, staging, _transfer = tmpdirs - db = _FakeDB() - cur = db._conn.cursor() - cur.execute("CREATE TABLE artists (id TEXT, name TEXT)") - cur.execute( - "CREATE TABLE albums (id TEXT, artist_id TEXT, title TEXT, " - "spotify_album_id TEXT, deezer_id TEXT, itunes_album_id TEXT, " - "discogs_id TEXT, soul_id TEXT)" - ) - cur.execute( - "CREATE TABLE tracks (id TEXT, album_id TEXT, artist_id TEXT, " - "title TEXT, track_number INTEGER, file_path TEXT, updated_at TEXT)" - ) - db._conn.commit() - - summary = library_reorganize.reorganize_album( - album_id='does-not-exist', db=db, staging_root=str(staging), - resolve_file_path_fn=lambda p: p, post_process_fn=lambda *a: None, - ) - - assert summary['status'] == 'no_album' - - -def test_returns_no_tracks_when_album_has_none(tmpdirs): - _library, staging, _transfer = tmpdirs - db = _FakeDB() - _setup_album(db, deezer_id='dz-1', tracks=[]) - - summary = library_reorganize.reorganize_album( - album_id='alb-1', db=db, staging_root=str(staging), - resolve_file_path_fn=lambda p: p, post_process_fn=lambda *a: None, - ) - - assert summary['status'] == 'no_tracks' - - -def test_processes_tracks_concurrently_with_consistent_state(monkeypatch, tmpdirs): - """Reorganize should run multiple tracks in parallel (matching the - download-side worker count). Verify both the parallelism (we observe - overlapping post-process calls) AND the state consistency (all - tracks are accounted for, no double-counting from races).""" - library, staging, _transfer = tmpdirs - db = _FakeDB() - track_count = 6 - rows = [] - for i in range(1, track_count + 1): - rows.append((f't{i}', i, f'Track {i}', _make_audio_file(library, f't{i}.flac'))) - _setup_album(db, deezer_id='dz-1', tracks=rows) - - monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') - monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) - monkeypatch.setattr(library_reorganize, 'get_album_for_source', - lambda *a: {'id': 'dz-1', 'name': 'A'}) - monkeypatch.setattr( - library_reorganize, 'get_album_tracks_for_source', - lambda *a: {'items': [ - {'id': f'a{i}', 'name': f'Track {i}', 'track_number': i} - for i in range(1, track_count + 1) - ]}, - ) - - import threading - import time - - in_flight = 0 - max_in_flight = 0 - in_flight_lock = threading.Lock() - final_dir = library / 'final' - final_dir.mkdir() - - def slow_pp(key, ctx, fp): - nonlocal in_flight, max_in_flight - with in_flight_lock: - in_flight += 1 - max_in_flight = max(max_in_flight, in_flight) - # Hold the worker briefly so concurrency is observable - time.sleep(0.05) - with in_flight_lock: - in_flight -= 1 - out = str(final_dir / f"out_{ctx['track_info']['track_number']}.flac") - shutil.move(fp, out) - ctx['_final_processed_path'] = out - - summary = library_reorganize.reorganize_album( - album_id='alb-1', db=db, staging_root=str(staging), - resolve_file_path_fn=lambda p: p, post_process_fn=slow_pp, - ) - - # All 6 tracks processed; counts are consistent (no race-induced duplicates) - assert summary['moved'] == track_count - assert summary['skipped'] == 0 - assert summary['failed'] == 0 - # Should have observed at least 2 workers in flight at once - # (3 is the configured cap; some overlap should always occur with 6 slow tracks) - assert max_in_flight >= 2, f"Expected concurrent workers, only saw {max_in_flight} in flight" - - -def test_prunes_empty_destination_album_dirs(monkeypatch, tmpdirs): - """When transfer_dir is provided, the orchestrator must clean up - empty sibling album folders in the artist directory after the run. - Catches both (a) leftovers from previous failed reorganize attempts - that created standalone single-track folders, and (b) dirs created - by `_build_final_path_for_track` that ended up empty when post- - process failed AcoustID. Uses a single-level prune scoped to the - artist folder — won't touch unrelated user dirs.""" - library, staging, transfer = tmpdirs - db = _FakeDB() - src = _make_audio_file(library, 't1.flac') - _setup_album(db, deezer_id='dz-1', tracks=[ - ('t1', 1, 'Track 1', src), - ]) - - monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') - monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) - monkeypatch.setattr(library_reorganize, 'get_album_for_source', - lambda *a: {'id': 'dz-1', 'name': 'A'}) - monkeypatch.setattr( - library_reorganize, 'get_album_tracks_for_source', - lambda *a: {'items': [{'id': 'a1', 'name': 'Track 1', 'track_number': 1}]}, - ) - - # Simulate the user's actual situation: transfer dir already has - # an artist folder with leftover empty single-track album folders - # from previous failed runs, plus an empty Disc-N subfolder. - artist_dir = transfer / 'Artist' - artist_dir.mkdir() - (artist_dir / 'Artist - 2013 Backseat Freestyle').mkdir() - (artist_dir / 'Artist - 2013 Compton').mkdir() - leftover_with_disc = artist_dir / 'Artist - 2012 Old Single-Disc' - leftover_with_disc.mkdir() - (leftover_with_disc / 'Disc 1').mkdir() # empty disc subfolder - - # Successful track lands in the real album folder - real_album = artist_dir / 'Artist - 2013 Real Album' - real_album.mkdir() - - def pp(key, ctx, fp): - final = str(real_album / 'Disc 1' / '01 - Track 1.flac') - os.makedirs(os.path.dirname(final), exist_ok=True) - shutil.move(fp, final) - ctx['_final_processed_path'] = final - - library_reorganize.reorganize_album( - album_id='alb-1', db=db, staging_root=str(staging), - resolve_file_path_fn=lambda p: p, post_process_fn=pp, - transfer_dir=str(transfer), - ) - - # Empty leftover single-track album folders should be gone - assert not (artist_dir / 'Artist - 2013 Backseat Freestyle').exists() - assert not (artist_dir / 'Artist - 2013 Compton').exists() - # The album with an empty Disc subfolder should also be cleaned - # (Disc 1/ is empty → pruned, then Old Single-Disc/ is empty → pruned) - assert not leftover_with_disc.exists() - # Real album with successful track must still exist - assert real_album.exists() - assert (real_album / 'Disc 1' / '01 - Track 1.flac').exists() - # Artist folder itself (still has the real album) untouched - assert artist_dir.exists() - - -def test_context_dict_satisfies_post_process_contract(monkeypatch, tmpdirs): - """Integration-style test: assert the per-track context dict the - orchestrator hands to post-process contains every key - `_post_process_matched_download` and `_build_final_path_for_track` - actually read in production. If the real post-process starts - requiring a new key in a future refactor, this test catches it - BEFORE the user does — unit-mock tests would not. - - Keys verified are taken from a grep of the real functions in - web_server.py at the time this test was written. The list is the - contract; if it grows, the orchestrator's `_build_post_process_context` - needs to grow too.""" - library, staging, _transfer = tmpdirs - db = _FakeDB() - _setup_album(db, deezer_id='dz-1', tracks=[ - ('t1', 1, 'Track 1', _make_audio_file(library, 't1.flac')), - ]) - - monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') - monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) - monkeypatch.setattr(library_reorganize, 'get_album_for_source', - lambda *a: { - 'id': 'dz-1', - 'name': 'Test Album', - 'release_date': '2024-03-15', - 'total_tracks': 12, - 'image_url': 'https://example.com/cover.jpg', - }) - monkeypatch.setattr( - library_reorganize, 'get_album_tracks_for_source', - lambda *a: {'items': [{ - 'id': 'a1', 'name': 'Track 1', - 'track_number': 1, 'disc_number': 1, - 'duration_ms': 240000, - 'artists': [{'name': 'Aerosmith'}], - 'uri': 'spotify:track:abc', - }]}, - ) - - captured_context = {} - - def assert_contract(key, ctx, fp): - captured_context.update(ctx) - # Mimic the bits of real post-process this test cares about - ctx['_final_processed_path'] = str(library / 'out.flac') - with open(ctx['_final_processed_path'], 'wb') as f: - f.write(b'final') - - library_reorganize.reorganize_album( - album_id='alb-1', db=db, staging_root=str(staging), - resolve_file_path_fn=lambda p: p, post_process_fn=assert_contract, - ) - - # Top-level keys the real post-process reads - assert captured_context.get('is_album_download') is True - assert captured_context.get('has_clean_spotify_data') is True - assert captured_context.get('has_full_spotify_metadata') is True - - # spotify_artist (album-level artist context — not per-track) - spotify_artist = captured_context.get('spotify_artist') - assert isinstance(spotify_artist, dict) - assert 'name' in spotify_artist - assert 'id' in spotify_artist - assert 'genres' in spotify_artist - - # spotify_album (used by `_build_final_path_for_track`) - spotify_album = captured_context.get('spotify_album') - assert isinstance(spotify_album, dict) - assert spotify_album.get('id') == 'dz-1' - assert spotify_album.get('name') == 'Test Album' - assert 'release_date' in spotify_album # year extraction - assert 'total_tracks' in spotify_album # ALBUM/EP/Single inference - assert 'total_discs' in spotify_album # Disc N/ subfolder gate - assert 'image_url' in spotify_album # album art - - # track_info (per-track signal — populates filename, tags, disc subfolder) - track_info = captured_context.get('track_info') - assert isinstance(track_info, dict) - assert 'name' in track_info # filename - assert 'id' in track_info # source track id - assert 'track_number' in track_info # filename + tag - assert 'disc_number' in track_info # disc subfolder + tag - assert 'duration_ms' in track_info # tag - assert isinstance(track_info.get('artists'), list) # tag — must be list - assert all(isinstance(a, dict) and 'name' in a for a in track_info['artists']) - - # original_search_result (post-process reads this for fallbacks) - osr = captured_context.get('original_search_result') - assert isinstance(osr, dict) - assert 'title' in osr - assert 'spotify_clean_title' in osr # `_build_final_path_for_track` reads this - assert 'spotify_clean_album' in osr # ditto - assert 'track_number' in osr - assert 'disc_number' in osr - assert 'artists' in osr - - -def test_progress_callback_receives_updates(monkeypatch, tmpdirs): - library, staging, _transfer = tmpdirs - db = _FakeDB() - _setup_album(db, deezer_id='dz-1', tracks=[ - ('t1', 1, 'Track 1', _make_audio_file(library, 't1.flac')), - ('t2', 2, 'Track 2', _make_audio_file(library, 't2.flac')), - ]) - - monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') - monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) - monkeypatch.setattr(library_reorganize, 'get_album_for_source', - lambda *a: {'id': 'dz-1', 'name': 'A'}) - monkeypatch.setattr( - library_reorganize, 'get_album_tracks_for_source', - lambda *a: {'items': [ - {'id': 'a1', 'name': 'Track 1', 'track_number': 1}, - {'id': 'a2', 'name': 'Track 2', 'track_number': 2}, - ]}, - ) - - def pp(key, ctx, fp): - final = str(library / f"final_{ctx['track_info']['track_number']}.flac") - shutil.move(fp, final) - ctx['_final_processed_path'] = final - - progress_log = [] - - def on_progress(updates): - progress_log.append(dict(updates)) - - library_reorganize.reorganize_album( - album_id='alb-1', db=db, staging_root=str(staging), - resolve_file_path_fn=lambda p: p, post_process_fn=pp, - on_progress=on_progress, - ) - - assert any('total' in u for u in progress_log) - assert any('current_track' in u for u in progress_log) - assert any(u.get('moved') == 2 for u in progress_log) - - -def test_watchdog_is_passive_and_lets_stuck_workers_complete(monkeypatch, tmpdirs): - """When a worker exceeds the hung-threshold, the orchestrator's - watchdog must NOT kill the worker — it just logs a warning and - lets the worker keep running. Real threshold is 5 minutes; - monkeypatch it down to ~50ms so the test runs in well under a - second. The previous version of this test also asserted on the - warning log line, but that assertion was flaky in full-suite runs - (caplog records intermittently lost from records emitted by the - `reorganize_album` worker pool's main thread under specific test - orderings — the warning DOES emit, visible in stdout capture, but - the caplog records list reads empty). The behavioural contract - the test exists to pin is "passive watchdog, doesn't abort the - worker"; that's what `summary['moved'] == 1` verifies. The - logging side effect was incidental.""" - import threading - library, staging, _transfer = tmpdirs - - # Tiny watchdog so the test is fast. Interval shorter than threshold - # so the loop checks at least once before the threshold trips. - monkeypatch.setattr(library_reorganize, '_WATCHDOG_INTERVAL_SECONDS', 0.02) - monkeypatch.setattr(library_reorganize, '_HUNG_WORKER_THRESHOLD_SECONDS', 0.05) - - db = _FakeDB() - _setup_album(db, deezer_id='dz-1', tracks=[ - ('t1', 1, 'Stuck Track', _make_audio_file(library, 't1.flac')), - ]) - - monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') - monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) - monkeypatch.setattr(library_reorganize, 'get_album_for_source', - lambda *a: {'id': 'dz-1', 'name': 'A'}) - monkeypatch.setattr( - library_reorganize, 'get_album_tracks_for_source', - lambda *a: {'items': [{'id': 'a1', 'name': 'Stuck Track', 'track_number': 1}]}, - ) - - release = threading.Event() - - def slow_pp(key, ctx, fp): - # Hold long enough for the watchdog to trip the threshold + emit. - # 0.2s vs 0.05s threshold + 0.02s interval = at least one warn pass. - release.wait(timeout=0.25) - ctx['_final_processed_path'] = fp - with open(fp, 'wb') as f: - f.write(b'final') - - summary = library_reorganize.reorganize_album( - album_id='alb-1', db=db, staging_root=str(staging), - resolve_file_path_fn=lambda p: p, post_process_fn=slow_pp, - ) - release.set() - - # Watchdog is passive — must NOT abort the worker even after the - # warning fires. Track must still land on disk + be marked moved - # in the summary. - assert summary['moved'] == 1 - assert summary.get('failed', 0) == 0 - - -def test_stop_check_aborts_remaining_tracks(monkeypatch, tmpdirs): - """With concurrent workers, stop_check can't cancel a task that's - already running — but it MUST prevent tasks that haven't started - yet from running. Use enough tracks that the worker pool can't - drain them all before stop_check trips.""" - import threading - library, staging, _transfer = tmpdirs - db = _FakeDB() - _setup_album(db, deezer_id='dz-1', tracks=[ - (f't{i}', i, f'Track {i}', _make_audio_file(library, f't{i}.flac')) - for i in range(1, 11) - ]) - - monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') - monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) - monkeypatch.setattr(library_reorganize, 'get_album_for_source', - lambda *a: {'id': 'dz-1', 'name': 'A'}) - monkeypatch.setattr( - library_reorganize, 'get_album_tracks_for_source', - lambda *a: {'items': [ - {'id': f'a{i}', 'name': f'Track {i}', 'track_number': i} - for i in range(1, 11) - ]}, - ) - - pp_count = [0] - pp_lock = threading.Lock() - - def pp(key, ctx, fp): - with pp_lock: - pp_count[0] += 1 - ctx['_final_processed_path'] = fp - with open(fp, 'wb') as f: - f.write(b'fake-final') - - stop = [False] - def check_stop(): - with pp_lock: - if pp_count[0] >= 2: - stop[0] = True - return stop[0] - - library_reorganize.reorganize_album( - album_id='alb-1', db=db, staging_root=str(staging), - resolve_file_path_fn=lambda p: p, post_process_fn=pp, - stop_check=check_stop, - ) - - # Some tracks ran (the ones already in flight when stop tripped), - # but not ALL 10 — the stop_check cut off the unstarted ones. - assert pp_count[0] < 10 - assert pp_count[0] >= 2 - - -# --- tests: #746 /deleted-quarantine skip --------------------------------- - -def test_preview_skips_track_in_deleted_quarantine(monkeypatch, tmpdirs): - """A track whose file lives in /deleted (duplicate-cleaner - quarantine) must be surfaced as a non-matched skip in the preview, even - though its title matches the API tracklist — Reorganize must not offer to - move it back out of /deleted (#746).""" - library, _staging, transfer = tmpdirs - db = _FakeDB() - # One normal track in the library, one quarantined track under - # /deleted. Both have titles that match the API list. - quarantine = transfer / 'deleted' / 'Aerosmith' - quarantine.mkdir(parents=True) - deleted_file = quarantine / 'dream.flac' - deleted_file.write_bytes(b'dupe') - _setup_album(db, deezer_id='dz-1', tracks=[ - ('t1', 1, 'Same Old Song And Dance', _make_audio_file(library, 't1.flac')), - ('t2', 2, 'Dream On', str(deleted_file)), - ]) - - monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') - monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) - monkeypatch.setattr(library_reorganize, 'get_album_for_source', - lambda *a: {'id': 'dz-1', 'name': 'Aerosmith'}) - monkeypatch.setattr( - library_reorganize, 'get_album_tracks_for_source', - lambda *a: {'items': [ - {'id': 'a1', 'name': 'Same Old Song And Dance', 'track_number': 1}, - {'id': 'a2', 'name': 'Dream On', 'track_number': 2}, - ]}, - ) - - result = library_reorganize.preview_album_reorganize( - album_id='alb-1', db=db, transfer_dir=str(transfer), - resolve_file_path_fn=lambda p: p, - build_final_path_fn=_fake_path_builder, - ) - - by_title = {it['title']: it for it in result['tracks']} - # Normal track: matched + gets a destination. - assert by_title['Same Old Song And Dance']['matched'] is True - assert by_title['Same Old Song And Dance']['new_path'] - # Quarantined track: skipped despite matching the API tracklist. - assert by_title['Dream On']['matched'] is False - assert 'quarantine' in (by_title['Dream On']['reason'] or '').lower() - assert by_title['Dream On']['new_path'] == '' - - -def test_apply_skips_track_in_deleted_quarantine(monkeypatch, tmpdirs): - """Apply mirrors the preview: post-process is never called for a - quarantined track, the original is left in /deleted, and it's counted as - skipped (not moved, not failed) (#746).""" - library, staging, transfer = tmpdirs - db = _FakeDB() - quarantine = transfer / 'deleted' / 'Aerosmith' - quarantine.mkdir(parents=True) - deleted_file = quarantine / 'dream.flac' - deleted_file.write_bytes(b'dupe') - _setup_album(db, deezer_id='dz-1', tracks=[ - ('t1', 1, 'Same Old Song And Dance', _make_audio_file(library, 't1.flac')), - ('t2', 2, 'Dream On', str(deleted_file)), - ]) - - monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') - monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) - monkeypatch.setattr(library_reorganize, 'get_album_for_source', - lambda *a: {'id': 'dz-1', 'name': 'Aerosmith'}) - monkeypatch.setattr( - library_reorganize, 'get_album_tracks_for_source', - lambda *a: {'items': [ - {'id': 'a1', 'name': 'Same Old Song And Dance', 'track_number': 1}, - {'id': 'a2', 'name': 'Dream On', 'track_number': 2}, - ]}, - ) - - pp_titles = [] - - def pp(key, ctx, fp): - pp_titles.append(ctx['track_info']['name']) - ctx['_final_processed_path'] = fp - with open(fp, 'wb') as f: - f.write(b'final') - - summary = library_reorganize.reorganize_album( - album_id='alb-1', db=db, staging_root=str(staging), - resolve_file_path_fn=lambda p: p, post_process_fn=pp, - transfer_dir=str(transfer), - ) - - # Only the normal track was post-processed; the quarantined one was not. - assert pp_titles == ['Same Old Song And Dance'] - assert summary['moved'] == 1 - assert summary['skipped'] == 1 - # The quarantined file is untouched on disk. - assert deleted_file.exists() diff --git a/tests/test_library_retag_job.py b/tests/test_library_retag_job.py index 40f96d3d6..85b0a9b98 100644 --- a/tests/test_library_retag_job.py +++ b/tests/test_library_retag_job.py @@ -32,7 +32,8 @@ def _db_with_album(path, track_file, current_title='Old Title'): c = conn.cursor() c.execute("CREATE TABLE artists (id INTEGER PRIMARY KEY, name TEXT)") c.execute("""CREATE TABLE albums (id INTEGER PRIMARY KEY, title TEXT, artist_id INTEGER, - spotify_album_id TEXT, itunes_album_id TEXT, deezer_id TEXT, musicbrainz_release_id TEXT)""") + spotify_album_id TEXT, itunes_album_id TEXT, deezer_id TEXT, + discogs_id TEXT, soul_id TEXT, musicbrainz_release_id TEXT)""") c.execute("""CREATE TABLE tracks (id INTEGER PRIMARY KEY, album_id INTEGER, title TEXT, track_number INTEGER, disc_number INTEGER, file_path TEXT)""") c.execute("INSERT INTO artists (id, name) VALUES (1, 'Real Artist')") @@ -225,7 +226,8 @@ def test_scan_skips_album_already_correct(tmp_path, monkeypatch): # cover skipped + tags already match → nothing to do ctx = _context(conn, {'mode': 'overwrite', 'cover_art': 'skip', 'source': 'spotify'}) _patch_source(monkeypatch, { - 'title': 'Real Title', 'album_artist': 'Real Artist', 'album': 'Real Album', + 'title': 'Real Title', 'artist': 'Real Artist', 'album_artist': 'Real Artist', + 'album': 'Real Album', 'year': '2021', 'genre': 'Rock', 'track_number': 1, 'disc_number': 1, }) @@ -394,3 +396,168 @@ def test_apply_art_only_plan_skips_when_cover_download_fails(tmp_path, monkeypat assert res == {'written': 0, 'failed': 0, 'skipped': 1, 'cover_written': False, 'lyrics_written': 0} + + +# ── reading the file: one reader, and only files it could read ── + +def test_current_tags_come_from_the_shared_reader(tmp_path, monkeypatch): + """The job must read tags with the same reader the writer's guards use. + + `core.soulsync_client._read_tags` takes mutagen's easy view and only the + FIRST value of each frame, so a multi-genre file read back as one genre + made the diff disagree with what `write_tags_to_file` would do. + """ + seen = [] + monkeypatch.setattr('core.tag_writer.read_file_tags', + lambda p: seen.append(p) or {'title': 'X'}) + assert lr._read_current_tags('/some/track.flac') == {'title': 'X'} + assert seen == ['/some/track.flac'] + + +def test_unreadable_file_is_not_reported_as_every_tag_being_wrong(tmp_path, monkeypatch): + """A file whose tags cannot be read has no known current value. + + Treating that as "every field is empty" produced a finding claiming the + whole file was mistagged, and applying it would have written the source's + values over tags nobody had seen. + """ + track = tmp_path / 'track.flac'; track.write_bytes(b'') + conn = _db_with_album(str(tmp_path / 'm.db'), str(track)) + ctx = _context(conn, {'mode': 'overwrite', 'cover_art': 'skip', 'source': 'spotify'}) + _patch_source(monkeypatch, {'error': 'not an audio file', 'title': None, + 'artist': None, 'album': None}) + + result = lr.LibraryRetagJob().scan(ctx) + + assert ctx.findings == [] + assert result.findings_created == 0 + + +def test_finding_carries_the_fields_the_writer_holds_back(tmp_path, monkeypatch): + """#800: the source wants "Various Artists" over a real name. + + The writer refuses, so the finding says so instead of listing a change + that will silently not happen — and come back on the next scan. + """ + track = tmp_path / 'track.flac'; track.write_bytes(b'') + conn = _db_with_album(str(tmp_path / 'm.db'), str(track)) + ctx = _context(conn, {'mode': 'overwrite', 'cover_art': 'skip', 'source': 'spotify'}) + monkeypatch.setattr(lr, 'get_album_for_source', lambda s, i: dict( + _ALBUM_META, artists=[{'name': 'Various Artists'}])) + monkeypatch.setattr(lr, 'get_album_tracks_for_source', lambda s, i: list(_SRC_TRACKS)) + monkeypatch.setattr(lr, '_read_current_tags', lambda p: { + 'title': 'Old Title', 'artist': 'Real Artist', 'album_artist': 'Real Artist', + 'album': 'Real Album', 'year': '2021', 'genre': 'Rock', + 'track_number': 1, 'disc_number': 1}) + + result = lr.LibraryRetagJob().scan(ctx) + + assert result.findings_created == 1 + tp = ctx.findings[0]['details']['tracks'][0] + assert set(tp['changes']) == {'title'} # the real change + assert 'album_artist' in tp['protected'] # held back, not promised + assert 'artist_name' not in tp['db_data'] + assert 'held back' in ctx.findings[0]['description'].lower() + + +# ── cover art: the default must not flag the whole library ── + +def test_default_settings_leave_a_correct_album_alone(tmp_path, monkeypatch): + """With no settings of its own the job must find nothing on an album whose + tags match and whose art is present. + + The default used to be ``cover_art: 'replace'``, and a cover action alone + is enough to create a finding — so every matched album in the library got + one, on every scan, forever. + """ + track = tmp_path / 'track.flac'; track.write_bytes(b'') + (tmp_path / 'cover.jpg').write_bytes(b'\xff\xd8\xff') + conn = _db_with_album(str(tmp_path / 'm.db'), str(track), current_title='Real Title') + ctx = _context(conn, {}) # nothing configured + _patch_source(monkeypatch, { + 'title': 'Real Title', 'artist': 'Real Artist', 'album_artist': 'Real Artist', + 'album': 'Real Album', 'year': '2021', 'genre': 'Rock', + 'track_number': 1, 'disc_number': 1, + }) + + result = lr.LibraryRetagJob().scan(ctx) + + assert ctx.findings == [] + assert result.findings_created == 0 + + +def test_fill_missing_looks_for_art_at_the_resolved_path(tmp_path, monkeypatch): + """The "has this album got art?" question was asked of the RAW db path. + + On a path-mapped setup that folder does not exist in this process, so the + answer was always "no art" and fill-missing behaved like replace. + """ + real = tmp_path / 'track.flac'; real.write_bytes(b'') + (tmp_path / 'cover.jpg').write_bytes(b'\xff\xd8\xff') # the album HAS art + raw = '/container/music/track.flac' + conn = _db_with_album(str(tmp_path / 'm.db'), raw, current_title='Real Title') + ctx = _context(conn, {'mode': 'overwrite', 'cover_art': 'fill_missing', + 'source': 'spotify'}) + _patch_source(monkeypatch, { + 'title': 'Real Title', 'artist': 'Real Artist', 'album_artist': 'Real Artist', + 'album': 'Real Album', 'year': '2021', 'genre': 'Rock', + 'track_number': 1, 'disc_number': 1, + }) + monkeypatch.setattr(lr, 'resolve_library_file_path', + lambda p, **k: str(real) if p == raw else None) + + result = lr.LibraryRetagJob().scan(ctx) + + assert ctx.findings == [] + assert result.findings_created == 0 + + +# ── which albums are eligible ── + +def test_an_album_matched_only_via_discogs_is_eligible(tmp_path, monkeypatch): + """Reorganize accepts six sources, this job accepted four. + + A Discogs- or Hydrabase-matched album was dropped without a word, so the + same album could be reorganized but never re-tagged. + """ + track = tmp_path / 'track.flac'; track.write_bytes(b'') + conn = _db_with_album(str(tmp_path / 'm.db'), str(track), current_title='Old Title') + conn.execute("UPDATE albums SET spotify_album_id = NULL, discogs_id = 'dg_alb' WHERE id = 1") + conn.commit() + ctx = _context(conn, {'mode': 'overwrite', 'cover_art': 'skip', 'source': 'discogs'}) + _patch_source(monkeypatch, { + 'title': 'Old Title', 'artist': 'Real Artist', 'album_artist': 'Real Artist', + 'album': 'Real Album', 'year': '2021', 'genre': 'Rock', + 'track_number': 1, 'disc_number': 1, + }) + + result = lr.LibraryRetagJob().scan(ctx) + + assert result.findings_created == 1 + assert ctx.findings[0]['details']['source'] == 'discogs' + + +# ── cover.jpg lands in every folder the album occupies ── + +def test_cover_jpg_is_written_beside_every_disc(tmp_path, monkeypatch): + """A multi-disc album lives in more than one folder. + + The sidecar went to the directory of the LAST track written, so on a + Disc 1/ + Disc 2/ layout exactly one disc got a cover.jpg. + """ + d1 = tmp_path / 'Disc 1'; d1.mkdir(); (d1 / 'a.flac').write_bytes(b'') + d2 = tmp_path / 'Disc 2'; d2.mkdir(); (d2 / 'b.flac').write_bytes(b'') + monkeypatch.setattr('core.tag_writer.download_cover_art', + lambda url: (b'img-bytes', 'image/jpeg')) + monkeypatch.setattr('core.tag_writer.write_tags_to_file', + lambda fp, db_data, **k: {'success': True}) + + res = lr.apply_track_plans( + [{'file_path': str(d1 / 'a.flac'), 'db_data': {'title': 'A'}}, + {'file_path': str(d2 / 'b.flac'), 'db_data': {'title': 'B'}}], + cover_action='replace', cover_url='http://art/cover.jpg', + ) + + assert res['cover_written'] is True + assert (d1 / 'cover.jpg').read_bytes() == b'img-bytes' + assert (d2 / 'cover.jpg').read_bytes() == b'img-bytes' diff --git a/tests/test_reorganize_alternate_edition.py b/tests/test_reorganize_alternate_edition.py deleted file mode 100644 index 045df6450..000000000 --- a/tests/test_reorganize_alternate_edition.py +++ /dev/null @@ -1,119 +0,0 @@ -"""#767-2: the reorganizer's on-demand alternate-edition path. - -When the walked edition (the first source we have an ID for) clearly misfits the -on-disk files — e.g. a 1-track single whose only ID points at the 10-track deluxe -— `_resolve_source` must find a better-fitting edition, use it for the plan, and -(on apply) persist the canonical pin. A well-fitting album must keep today's exact -behavior and never trigger an alternate fetch.""" - -from __future__ import annotations - -import core.library_reorganize as lr -import core.metadata.canonical_resolver as cr - -# Provider-shaped raw tracklists (what get_album_tracks_for_source returns). -SINGLE_RAW = [{"name": "Scatterbrain", "track_number": 1, "duration_ms": 129_000}] -DELUXE_RAW = [{"name": "Intro", "track_number": 1, "duration_ms": 200_000}] + [ - {"name": "Scatterbrain", "track_number": 2, "duration_ms": 130_000} -] + [ - {"name": f"Bonus {i}", "track_number": i + 2, "duration_ms": 180_000} - for i in range(1, 9) -] -# Resolver-normalised shape (what default_fetch_tracklist returns). -SINGLE_NORM = [{"title": "Scatterbrain", "track_number": 1, "duration_ms": 129_000}] -DELUXE_NORM = [{"title": t["name"], "duration_ms": t["duration_ms"]} for t in DELUXE_RAW] - -ALBUM_META = { - "sp_deluxe": {"name": "Scatterbrain (Deluxe)"}, - "sp_single": {"name": "Scatterbrain - Single"}, -} -TRACKLISTS = {"sp_deluxe": DELUXE_RAW, "sp_single": SINGLE_RAW} - - -def _wire(monkeypatch, *, alternates): - """Patch the source-API seams the reorganizer + resolver funnel through.""" - monkeypatch.setattr(lr, "get_source_priority", lambda primary: ["spotify"]) - monkeypatch.setattr(lr, "get_album_for_source", lambda s, aid: ALBUM_META.get(aid)) - monkeypatch.setattr(lr, "get_album_tracks_for_source", lambda s, aid: TRACKLISTS.get(aid)) - # Resolver-internal fetchers (imported by name inside _resolve_better_edition). - norm = {"sp_deluxe": DELUXE_NORM, "sp_single": SINGLE_NORM} - monkeypatch.setattr(cr, "default_fetch_tracklist", lambda s, aid: norm.get(aid)) - monkeypatch.setattr(cr, "default_fetch_alternates", alternates) - - -def test_misfit_single_resolves_to_the_single_edition(monkeypatch): - alt_calls = [] - - def alternates(source, aid, **kw): - alt_calls.append((source, aid)) - return [ - {"album_id": "sp_single", "tracks": SINGLE_NORM}, - {"album_id": "sp_deluxe", "tracks": DELUXE_NORM}, - ] - - _wire(monkeypatch, alternates=alternates) - pins = [] - album_data = { - "spotify_album_id": "sp_deluxe", "title": "Scatterbrain", - "artist_id": "a1", "artist_name": "The Band", - } - file_tracks = [{"duration_ms": 129_000, "title": "Scatterbrain"}] # owns the single - - source, api_album, items = lr._resolve_source( - album_data, "spotify", - file_tracks=file_tracks, - on_better_edition=lambda s, aid, sc: pins.append((s, aid, sc)), - ) - - assert source == "spotify" - assert api_album == ALBUM_META["sp_single"] # used the single, not the deluxe - assert len(items) == 1 - assert alt_calls, "misfit must trigger an alternate-edition fetch" - assert pins and pins[0][1] == "sp_single", "apply must persist the better pin" - - -def test_well_fitting_album_keeps_walk_and_never_expands(monkeypatch): - alt_calls = [] - - def alternates(source, aid, **kw): - alt_calls.append((source, aid)) - return [{"album_id": "sp_single", "tracks": SINGLE_NORM}] - - _wire(monkeypatch, alternates=alternates) - pins = [] - # The library actually IS the deluxe (10 matching tracks) -> walk fits -> no expand. - album_data = { - "spotify_album_id": "sp_deluxe", "title": "Scatterbrain (Deluxe)", - "artist_id": "a1", "artist_name": "The Band", - } - file_tracks = [{"duration_ms": t["duration_ms"], "title": t["name"]} for t in DELUXE_RAW] - - source, api_album, items = lr._resolve_source( - album_data, "spotify", - file_tracks=file_tracks, - on_better_edition=lambda s, aid, sc: pins.append((s, aid, sc)), - ) - - assert source == "spotify" and api_album == ALBUM_META["sp_deluxe"] - assert alt_calls == [], "a well-fitting edition must not trigger any alternate fetch" - assert pins == [], "no pin written when the walk already fits" - - -def test_strict_source_never_expands(monkeypatch): - # User explicitly picked the source in the modal -> their choice wins, even on - # a misfit. No alternate search. - alt_calls = [] - - def alternates(source, aid, **kw): - alt_calls.append((source, aid)) - return [{"album_id": "sp_single", "tracks": SINGLE_NORM}] - - _wire(monkeypatch, alternates=alternates) - album_data = {"spotify_album_id": "sp_deluxe", "title": "Scatterbrain"} - file_tracks = [{"duration_ms": 129_000, "title": "Scatterbrain"}] - - source, api_album, items = lr._resolve_source( - album_data, "spotify", strict_source=True, file_tracks=file_tracks, - ) - assert source == "spotify" and api_album == ALBUM_META["sp_deluxe"] - assert alt_calls == [], "strict_source must not trigger alternate expansion" diff --git a/tests/test_reorganize_canonical_source.py b/tests/test_reorganize_canonical_source.py deleted file mode 100644 index f7a2939be..000000000 --- a/tests/test_reorganize_canonical_source.py +++ /dev/null @@ -1,158 +0,0 @@ -"""_resolve_source honors a pinned canonical release (#765 Stage 3, read side). - -Gated + side-effect-free: only changes behavior for albums that already carry a -canonical_source/canonical_album_id, and an explicit user source pick -(strict_source) still wins. No canonical -> byte-identical to before. -""" - -from __future__ import annotations - -from unittest.mock import MagicMock - -import core.library_reorganize as lr -import core.metadata.registry as metadata_registry -from core.musicbrainz_search import MusicBrainzSearchClient - - -def _patch_fetch(monkeypatch, tracklists): - """tracklists: {(source, album_id): items_or_None}. Patches the album + - tracklist fetchers and the normaliser (pass-through).""" - def get_album(source, aid): - return {"name": f"{source}:{aid}"} if tracklists.get((source, aid)) else None - - def get_tracks(source, aid): - return tracklists.get((source, aid)) - - monkeypatch.setattr(lr, "get_album_for_source", get_album) - monkeypatch.setattr(lr, "get_album_tracks_for_source", get_tracks) - monkeypatch.setattr(lr, "_normalize_album_tracks", lambda items: items or []) - monkeypatch.setattr(lr, "get_source_priority", lambda primary: ["spotify", "itunes", "deezer"]) - - -def test_canonical_source_preferred_over_priority(monkeypatch): - # Album has spotify (priority winner) AND a pinned canonical = deezer. - _patch_fetch(monkeypatch, { - ("spotify", "sp1"): [{"name": "x"}], - ("deezer", "dz1"): [{"name": "y"}], - }) - album_data = { - "spotify_album_id": "sp1", "deezer_id": "dz1", - "canonical_source": "deezer", "canonical_album_id": "dz1", - } - source, api_album, items = lr._resolve_source(album_data, "spotify") - assert source == "deezer" # canonical beats the priority walk - - -def test_canonical_fetch_failure_falls_back_to_priority(monkeypatch): - # Canonical points at musicbrainz but that fetch yields nothing -> fall back. - _patch_fetch(monkeypatch, { - ("spotify", "sp1"): [{"name": "x"}], - # no entry for ('musicbrainz', 'mb1') -> get_tracks returns None - }) - album_data = { - "spotify_album_id": "sp1", - "canonical_source": "musicbrainz", "canonical_album_id": "mb1", - } - source, _, _ = lr._resolve_source(album_data, "spotify") - assert source == "spotify" # fell back to priority - - -def test_strict_source_ignores_canonical(monkeypatch): - # User explicitly picked spotify in the modal — their choice wins over canonical. - _patch_fetch(monkeypatch, { - ("spotify", "sp1"): [{"name": "x"}], - ("deezer", "dz1"): [{"name": "y"}], - }) - album_data = { - "spotify_album_id": "sp1", "deezer_id": "dz1", - "canonical_source": "deezer", "canonical_album_id": "dz1", - } - source, _, _ = lr._resolve_source(album_data, "spotify", strict_source=True) - assert source == "spotify" - - -def test_no_canonical_unchanged(monkeypatch): - # No canonical set -> identical to legacy priority resolution. - _patch_fetch(monkeypatch, {("spotify", "sp1"): [{"name": "x"}]}) - album_data = {"spotify_album_id": "sp1"} - source, _, _ = lr._resolve_source(album_data, "spotify") - assert source == "spotify" - - -def test_musicbrainz_release_id_is_used_by_priority_walk(monkeypatch): - client = MusicBrainzSearchClient() - client._client = MagicMock() - client._client.get_release_group.return_value = None - client._client.get_release.return_value = { - "id": "mb-release-1", - "title": "Test Album", - "date": "2024-01-01", - "artist-credit": [{"name": "Test Artist"}], - "release-group": { - "id": "mb-group-1", - "primary-type": "Album", - "secondary-types": [], - }, - "media": [ - { - "position": 1, - "tracks": [ - { - "id": "track-1", - "number": "1", - "position": 1, - "length": 180000, - "recording": { - "id": "recording-1", - "title": "Test Track", - "artist-credit": [{"name": "Test Artist"}], - "length": 180000, - }, - }, - ], - }, - ], - } - - monkeypatch.setattr( - metadata_registry, - "get_musicbrainz_client", - lambda *args, **kwargs: client, - ) - monkeypatch.setattr( - lr, - "get_source_priority", - lambda primary: ["musicbrainz", "spotify"], - ) - - album_data = { - "musicbrainz_release_id": "mb-release-1", - } - - source, api_album, items = lr._resolve_source( - album_data, - "musicbrainz", - ) - - assert source == "musicbrainz" - assert api_album["id"] == "mb-release-1" - assert api_album["name"] == "Test Album" - assert items == api_album["tracks"] - assert items == [ - { - "id": "recording-1", - "name": "Test Track", - "artists": [{"name": "Test Artist"}], - "duration_ms": 180000, - "track_number": 1, - "disc_number": 1, - }, - ] - client._client.get_release_group.assert_any_call( - "mb-release-1", - includes=["releases", "artist-credits"], - ) - client._client.get_release.assert_any_call( - "mb-release-1", - includes=["recordings", "artist-credits", "release-groups"], - ) diff --git a/tests/test_reorganize_disc_layout.py b/tests/test_reorganize_disc_layout.py deleted file mode 100644 index 63d1eab00..000000000 --- a/tests/test_reorganize_disc_layout.py +++ /dev/null @@ -1,167 +0,0 @@ -"""Library Organize must not stamp a bogus disc prefix on a single-disc album -(#1080 QT3496: 'Oldorado' single-disc, track 11, re-matched a 2-disc source -edition → proposed '0211 - Oldorado'). - -The plan reads the user's REAL disc layout from their own track numbers and -only caps to single-disc when it's unambiguous, so genuine multi-disc is never -flattened: - * repeating track numbers (a box set: 1..13 / 1..14) stay multi-disc (#1009); - * continuously-numbered multi-disc (1..25 > disc-1 count) stays multi-disc. -Gated on the same preserve-my-organization setting as casing/year. -""" - -from __future__ import annotations - -import pytest - -import core.library_reorganize as lr - - -@pytest.fixture(autouse=True) -def _preserve_on(monkeypatch): - monkeypatch.setattr(lr, "_preserve_casing_enabled", lambda: True) - - -@pytest.fixture() -def plan_with(monkeypatch): - """Run plan_album_reorganize with a stubbed source resolver — monkeypatch - so the stub is auto-restored and never leaks into the next test file.""" - def _run(user_tracks, api_tracks): - album_data = {"id": "AL1", "title": "A", "artist_name": "X", - "artist_id": "AR1", "spotify_album_id": "sp1"} - api_album = {"id": "sp1", "name": "A", "release_date": "2018", - "total_tracks": len(api_tracks), "images": [{"url": ""}]} - monkeypatch.setattr(lr, "_resolve_source", - lambda ad, ps, strict_source=False, **kw: ("spotify", api_album, api_tracks)) - return lr.plan_album_reorganize(album_data, user_tracks, "spotify") - return _run - - -def _u(nums): - return [{"id": "T%d" % i, "title": "S%d" % n, "track_number": n} for i, n in enumerate(nums)] - - -def _a(pairs): # (track_number, disc_number) - return [{"name": "S%d" % tn, "track_number": tn, "disc_number": dn, - "artists": [{"name": "X"}]} for tn, dn in pairs] - - -# ── the fix ────────────────────────────────────────────────────────────────── - -def test_single_disc_user_album_not_given_a_disc_prefix(plan_with): - """User 1..11 (unique), source is 2-disc with disc 1 = 13 tracks → the user - clearly has the single-disc version → capped to one disc, matched discs = 1.""" - user = _u(range(1, 12)) - api = _a([(n, 1) for n in range(1, 14)] + [(n, 2) for n in range(1, 6)]) - plan = plan_with(user, api) - assert plan["total_discs"] == 1 - assert all(it["api_track"]["disc_number"] == 1 for it in plan["items"] if it["matched"]) - - -def test_matched_disc2_track_is_flattened_to_disc1(plan_with): - """A same-titled track existing on disc 2 (the reason the matcher grabbed a - disc-2 entry in the first place) is still flattened once the album is - detected as single-disc — so the path renders '11', not '0211'.""" - user = _u(range(1, 12)) # 1..11, fits the 13-track disc 1 - api = _a([(n, 1) for n in range(1, 14)]) + _a([(11, 2)]) # a dup 'S11' on disc 2 - plan = plan_with(user, api) - assert plan["total_discs"] == 1 - assert all(it["api_track"]["disc_number"] == 1 for it in plan["items"] if it["matched"]) - - -def test_setting_off_keeps_source_disc_structure(monkeypatch, plan_with): - monkeypatch.setattr(lr, "_preserve_casing_enabled", lambda: False) - user = _u(range(1, 12)) - api = _a([(n, 1) for n in range(1, 14)] + [(n, 2) for n in range(1, 6)]) - assert plan_with(user, api)["total_discs"] == 2 - - -# ── no regression: genuine multi-disc is never flattened ───────────────────── - -def test_box_set_repeating_numbers_stays_multi_disc(plan_with): - """Per-disc numbering (1..3 / 1..3) REPEATS → box set → left multi-disc - (protects #1009).""" - user = _u([1, 2, 3, 1, 2, 3]) - api = _a([(1, 1), (2, 1), (3, 1), (1, 2), (2, 2), (3, 2)]) - assert plan_with(user, api)["total_discs"] == 2 - - -def test_continuously_numbered_multi_disc_stays_multi_disc(plan_with): - """1..6 unique but disc 1 only holds 3 → the tracks spill past disc 1 → a - genuine 2-disc set, not flattened.""" - user = _u(range(1, 7)) - api = _a([(n, 1) for n in range(1, 4)] + [(n, 2) for n in range(4, 7)]) - assert plan_with(user, api)["total_discs"] == 2 - - -def test_genuinely_single_disc_source_is_untouched(plan_with): - """A single-disc source (total_discs already 1) is a no-op for the cap.""" - user = _u(range(1, 6)) - api = _a([(n, 1) for n in range(1, 6)]) - assert plan_with(user, api)["total_discs"] == 1 - - -def test_non_numeric_track_numbers_never_crash(plan_with): - """Belt-and-suspenders: odd track_numbers ('11/13', None) must not crash - the plan. With NO usable numeric track number the cap can't run, so the - source's disc structure stands.""" - user = [{"id": "T0", "title": "S1", "track_number": "1/13"}, - {"id": "T1", "title": "S2", "track_number": None}] - api = _a([(1, 1), (2, 1), (3, 1), (1, 2), (2, 2)]) - plan = plan_with(user, api) # must not raise - assert plan["status"] == "planned" - assert plan["total_discs"] == 2 # no numeric nums → heuristic skipped - - -# ── a library already organized by disc must not be flattened ──────────────── -# -# The cap reads the user's disc layout from their track NUMBERS, which cannot -# tell "a single-disc edition mis-matched to a deluxe" from "a multi-disc album -# that is still downloading". A freshly downloaded box set has only disc 1 on -# disk, uniquely numbered and inside disc 1 — so the cap fired every time and -# Reorganize proposed moving the album straight back OUT of the "Disc N" folders -# the download pipeline had just created. Pressing Reorganize after a download -# was never a no-op, and the layout flipped again once disc 2 arrived. -# -# The files themselves settle it: SoulSync only writes a disc folder when the -# release IS multi-disc, so a library already sitting in one is organized, not -# mis-matched — and the setting that gates this cap is "preserve my -# organization". - -def _u_in(nums, folder): - return [{"id": "T%d" % i, "title": "S%d" % n, "track_number": n, - "file_path": "/music/X/A/%s/%02d - S%d.flac" % (folder, n, n)} - for i, n in enumerate(nums)] - - -def test_a_part_downloaded_multi_disc_album_keeps_its_disc_structure(plan_with): - """Only disc 1 has landed so far — numbers are unique and fit inside disc 1, - which is exactly what the cap keys on.""" - user = _u_in([1, 2, 3], "Disc 1") - api = _a([(n, 1) for n in range(1, 26)] + [(n, 2) for n in range(1, 21)]) - plan = plan_with(user, api) - assert plan["total_discs"] == 2 - - -def test_a_cd_style_disc_folder_counts_too(plan_with): - """`$cdnum` writes "CD01"; the label setting can also be "CD".""" - for folder in ("CD01", "CD 1", "Disk 2", "Vol. 3"): - plan = plan_with(_u_in([1, 2, 3], folder), - _a([(n, 1) for n in range(1, 26)] + [(n, 2) for n in range(1, 21)])) - assert plan["total_discs"] == 2, folder - - -def test_a_flat_single_disc_album_is_still_capped(plan_with): - """The #1080 case is unchanged: no disc folder on disk, so the track numbers - remain the only evidence and they say single-disc.""" - user = _u_in(range(1, 12), "X - A") - api = _a([(n, 1) for n in range(1, 14)] + [(n, 2) for n in range(1, 6)]) - plan = plan_with(user, api) - assert plan["total_discs"] == 1 - - -def test_a_track_without_a_file_path_does_not_block_the_cap(plan_with): - """Missing files carry no layout evidence — they must not veto the cap.""" - user = _u(range(1, 12)) - api = _a([(n, 1) for n in range(1, 14)] + [(n, 2) for n in range(1, 6)]) - assert plan_with(user, api)["total_discs"] == 1 diff --git a/tests/test_reorganize_feat_matching.py b/tests/test_reorganize_feat_matching.py deleted file mode 100644 index f5aca187a..000000000 --- a/tests/test_reorganize_feat_matching.py +++ /dev/null @@ -1,153 +0,0 @@ -"""Reorganize title matcher: featured-artist credits must not block a match (#914). - -iTunes appends "(feat. X)" to track titles while a user's file is often just the -bare title. Before the fix that extra credit dropped the substring ratio below the -match threshold, so a correctly-identified track was reported as "no matching track -in the iTunes tracklist". The credit is metadata, so it's stripped before scoring. -""" - -from __future__ import annotations - -from core.library_reorganize import _find_api_track, _normalize_title - - -# ── normalization ──────────────────────────────────────────────────────────── -def test_feat_paren_stripped_equals_bare(): - assert _normalize_title('The Chase (feat. Big Artist)') == _normalize_title('The Chase') - assert _normalize_title('The Chase (feat. Big Artist)') == 'the chase' - - -def test_feat_variants_all_stripped(): - for v in ('Song (feat. A)', 'Song (ft. A)', 'Song [ft A]', - 'Song (featuring A & B)', 'Song feat. A', 'Song ft. A & B'): - assert _normalize_title(v) == 'song', v - - -def test_feat_strip_preserves_version_differentiator(): - # The remix tag must survive so the hard-reject still distinguishes recordings. - assert _normalize_title('Song (feat. A) - Remix') == 'song remix' - - -def test_bare_feat_word_not_overstripped(): - # "The Feat" (nothing after) and words containing the letters are left alone. - assert _normalize_title('The Feat') == 'the feat' - assert _normalize_title('Defeat') == 'defeat' - assert _normalize_title('Lift Off') == 'lift off' - - -# ── matcher (the #914 failure) ─────────────────────────────────────────────── -def _api(name, tn): - return {'name': name, 'track_number': tn} - - -def test_bare_local_matches_feat_titled_api_track_without_tn(): - # The exact bug: long featured-artist name pushed the ratio below threshold and - # there was no track-number rescue. After stripping feat it's an EXACT match. - api = [_api('The Chase (feat. Somebody Very Famous)', 9)] - assert _find_api_track(api, 'The Chase', None) is api[0] - - -def test_bare_local_matches_feat_titled_api_track_with_tn(): - api = [_api('Money Trees (feat. Jay Rock)', 6), _api('Poetic Justice (feat. Drake)', 7)] - assert _find_api_track(api, 'Money Trees', 6) is api[0] - assert _find_api_track(api, 'Poetic Justice', 7) is api[1] - - -def test_feat_strip_does_not_cross_match_different_songs(): - # Stripping feat must not collapse two genuinely different titles together. - api = [_api('The Chase (feat. X)', 1), _api('The Race (feat. Y)', 2)] - assert _find_api_track(api, 'The Race', None) is api[1] - assert _find_api_track(api, 'Nonexistent Song', None) is None - - -def test_remix_still_hard_rejected_even_with_feat(): - # A bare "Song" must NOT match an API "Song (feat. X) [Remix]" — different recording. - api = [_api('Song (feat. X) - Remix', 1)] - assert _find_api_track(api, 'Song', 1) is None - - -# ── #1078: feat_in_title must not be STRIPPED by the reorganize ────────────── -# QT3496: files already correctly titled "Song (feat. X)" were flagged and -# "corrected" to "Song" — the clean title (which builds the filename) came -# straight from the API track name, and feat_in_title only ever re-added the -# credit to the TAG, never the filename, and only when the API carried >1 -# artist. The helpers below carry the credit onto the clean title itself. - -from core.library_reorganize import ( # noqa: E402 - _apply_feat_credit, - _build_album_info, - _build_post_process_context, - _extract_feat_credit, -) - - -def test_extract_feat_credit_variants(): - assert _extract_feat_credit('The Chase (feat. Big Artist)') == '(feat. Big Artist)' - assert _extract_feat_credit('Song [ft A]') == '[ft A]' - assert _extract_feat_credit('Song ft. A & B') == 'ft. A & B' - assert _extract_feat_credit('The Chase') == '' - assert _extract_feat_credit('') == '' - - -def test_apply_feat_credit_from_api_artists(): - # API name lacks the credit but the track lists featured artists → rebuild it - out = _apply_feat_credit('The Chase', - [{'name': 'Main Artist'}, {'name': 'Big Artist'}], - 'The Chase (feat. Big Artist)') - assert out == 'The Chase (feat. Big Artist)' - - -def test_apply_feat_credit_preserves_local_when_api_has_one_artist(): - # API only knows the primary → carry the user's own credit forward - out = _apply_feat_credit('The Chase', [{'name': 'Main Artist'}], - 'The Chase (feat. Obscure Guest)') - assert out == 'The Chase (feat. Obscure Guest)' - - -def test_apply_feat_credit_never_double_credits(): - out = _apply_feat_credit('The Chase (feat. Big Artist)', - [{'name': 'Main Artist'}, {'name': 'Big Artist'}], - 'The Chase (feat. Big Artist)') - assert out == 'The Chase (feat. Big Artist)' - - -def test_apply_feat_credit_leaves_clean_titles_clean(): - out = _apply_feat_credit('Plain Song', [{'name': 'Main Artist'}], 'Plain Song') - assert out == 'Plain Song' - - -def _album(): - return {'id': 'AL1', 'name': 'The Album', 'release_date': '2020-01-01', - 'total_tracks': 10, 'images': [{'url': ''}]} - - -def test_reorganize_keeps_feat_in_filename_when_setting_on(monkeypatch): - """The reported bug end to end: with feat_in_title ON, the clean title the - filename is built from must carry the credit, so an already-correct file - isn't flagged for stripping.""" - monkeypatch.setattr('core.library_reorganize._feat_in_title_enabled', lambda: True) - api_track = {'name': 'The Chase', 'track_number': 3, 'disc_number': 1, - 'artists': [{'name': 'Main Artist'}, {'name': 'Big Artist'}]} - ctx = _build_post_process_context(_album(), api_track, 'Main Artist', - 'The Album', 1, - local_title='The Chase (feat. Big Artist)') - assert ctx['original_search_result']['title'] == 'The Chase (feat. Big Artist)' - assert ctx['original_search_result']['spotify_clean_title'] == 'The Chase (feat. Big Artist)' - from core.imports.paths import build_final_path_for_track - ai = _build_album_info(ctx) - path, _ = build_final_path_for_track(ctx, ctx['spotify_artist'], ai, '.flac', - create_dirs=False) - import os - assert os.path.basename(path) == '03 - The Chase (feat. Big Artist).flac' - - -def test_reorganize_leaves_title_alone_when_setting_off(monkeypatch): - """feat_in_title OFF is unchanged behavior — featured artists belong in the - ARTIST tag, so the title/filename stays the bare API name.""" - monkeypatch.setattr('core.library_reorganize._feat_in_title_enabled', lambda: False) - api_track = {'name': 'The Chase', 'track_number': 3, 'disc_number': 1, - 'artists': [{'name': 'Main Artist'}, {'name': 'Big Artist'}]} - ctx = _build_post_process_context(_album(), api_track, 'Main Artist', - 'The Album', 1, - local_title='The Chase (feat. Big Artist)') - assert ctx['original_search_result']['title'] == 'The Chase' diff --git a/tests/test_reorganize_from_catalogue.py b/tests/test_reorganize_from_catalogue.py new file mode 100644 index 000000000..57856cf10 --- /dev/null +++ b/tests/test_reorganize_from_catalogue.py @@ -0,0 +1,96 @@ +"""The reorganize plan is computed from the library, not from a provider. + +Reorganize moves files. Where they belong is a question about the album the +user already has, so the tracklist it names them after is the one in the +library — the same values the Library page shows. + +Asking a metadata source instead is what produced the exceptions that +accumulated around the old planner (`_keep_user_casing` #1078 twice, +`_keep_user_year` #1080), each one added after a report, each one saying the +same thing: where the catalogue and the provider disagreed, the catalogue was +right. It is also why an album with no stored source id could not be +reorganized at all — for an operation that needs no provider. +""" + +import core.library_reorganize as lr + + +def _album(**kw): + base = { + 'id': 1, 'title': 'Real Album', 'artist_name': 'Real Artist', + 'year': '2021', 'release_date': '2021-05-01', 'track_count': 2, + 'spotify_album_id': None, 'itunes_album_id': None, 'deezer_id': None, + 'discogs_id': None, 'soul_id': None, 'musicbrainz_release_id': None, + } + base.update(kw) + return base + + +def _track(n, title, disc=1, **kw): + row = {'id': n, 'title': title, 'track_number': n, 'disc_number': disc, + 'artist_name': 'Real Artist', 'file_path': f'/music/{title}.flac', + 'duration': 200000} + row.update(kw) + return row + + +def test_the_provider_planner_no_longer_exists(): + assert not hasattr(lr, '_resolve_source') + assert not hasattr(lr, '_plan_from_tags') + + +def test_an_album_with_no_source_id_is_still_planned(): + plan = lr.plan_album_reorganize(_album(), [_track(1, 'One'), _track(2, 'Two')]) + assert plan['status'] == 'planned' + assert [it['matched'] for it in plan['items']] == [True, True] + + +def test_track_names_come_from_the_library(): + plan = lr.plan_album_reorganize(_album(), [_track(1, 'My Own Title')]) + assert plan['items'][0]['api_track']['name'] == 'My Own Title' + assert plan['api_album']['name'] == 'Real Album' + + +def test_disc_count_comes_from_the_catalogue(): + plan = lr.plan_album_reorganize( + _album(), [_track(1, 'One', disc=1), _track(2, 'Two', disc=2)]) + assert plan['total_discs'] == 2 + + +def test_a_track_the_library_cannot_name_is_unmatched(): + plan = lr.plan_album_reorganize(_album(), [_track(1, ''), _track(2, 'Two')]) + assert plan['items'][0]['matched'] is False + assert plan['items'][0]['reason'] + assert plan['items'][1]['matched'] is True + + +def test_the_album_keeps_its_own_year(): + """#1080 was a patch on the provider planner. Reading the catalogue makes + the user's own release year the value by construction.""" + plan = lr.plan_album_reorganize(_album(release_date='1999-01-01'), [_track(1, 'One')]) + assert plan['api_album']['release_date'] == '1999-01-01' + + +def test_an_album_with_no_tracks_is_reported_as_such(): + assert lr.plan_album_reorganize(_album(), [])['status'] == 'no_tracks' + + +def test_an_album_already_filed_by_disc_keeps_its_disc_folders(): + """A half-downloaded 2-disc album has only disc-1 rows, so the catalogue's + highest disc number is 1 and the plan would file the tracks flat. + + The download that put them there knew better: it asked a provider, got + total_discs=2, and wrote `Album/Disc 1/…`. Reorganize would move them out, + and back in again once disc 2 arrives — the exact flip-flop this whole + change exists to stop. The folder the files are in is evidence about the + album that the imported rows do not carry. + """ + tracks = [_track(1, 'One', file_path='/music/Alb/Disc 1/01 - One.flac'), + _track(2, 'Two', file_path='/music/Alb/Disc 1/02 - Two.flac')] + plan = lr.plan_album_reorganize(_album(), tracks) + assert plan['total_discs'] > 1 + + +def test_a_flat_album_is_not_given_disc_folders(): + tracks = [_track(1, 'One', file_path='/music/Alb/01 - One.flac')] + assert lr.plan_album_reorganize(_album(), tracks)['total_discs'] == 1 diff --git a/tests/test_reorganize_no_acoustid_quarantine.py b/tests/test_reorganize_no_acoustid_quarantine.py index 35dea8adb..89c1f6b38 100644 --- a/tests/test_reorganize_no_acoustid_quarantine.py +++ b/tests/test_reorganize_no_acoustid_quarantine.py @@ -1,61 +1,66 @@ """Reorganize must not re-adjudicate the identity of a file already in the library. -A reorganize stages a COPY of the user's own library file and runs it through the -full download post-process, AcoustID identity check included. When the fingerprint -disagrees, that check quarantines the file — so moving a track you already own -into a differently-named folder ended in: +A reorganize used to stage a COPY of the user's own library file and run it +through the full download post-process, AcoustID identity check included. When +the fingerprint disagreed, that check quarantined the file — so moving a track +you already own into a differently-named folder ended in: AcoustID verification result: fail - Audio mismatch: 'APETITAN' by '澤野弘之' — expected artist not found File quarantined: downloads/ss_quarantine/...02 - Apetitan.flac.quarantined [Queue] Finished ... status=failed, moved=0, failed=2 -The library original survives (only the staged copy is quarantined), which is why -the run had to be repeated with "Rename only" to get anywhere — the reported +The library original survived (only the staged copy was quarantined), which is +why the run had to be repeated with "Rename only" to get anywhere — the reported "reorganize only works the second time". It also left a ~40 MB quarantined copy per attempt and a quarantine list full of files the user still owns. -The duration leg was excluded from this pipeline for exactly the same reason -(#804): a re-resolved API tracklist may legitimately disagree with the user's -copy. A fingerprint may too — a different master, a regional release, or an -artist credited in a different script, as here. Identity of library files is -adjudicated by the AcoustID Scanner, which raises a finding instead of moving -anyone's audio. +#1182 answered this with an opt-out: `_skip_quarantine_check: 'acoustid'` in the +reorganize context, alongside `is_local_import` (#804) for the duration leg, +which was excluded for the same reason — a re-resolved API tracklist may +legitimately disagree with the user's copy, and so may a fingerprint (a +different master, a regional release, or an artist credited in another script, +as here). + +The answer now is structural rather than a flag: a reorganize MOVES files and +runs no acceptance check at all, so there is nothing to opt out of. Identity of +library files is adjudicated by the AcoustID Scanner, which raises a finding +instead of moving anyone's audio. """ from __future__ import annotations -import pytest - from core.imports.pipeline import _should_skip_quarantine_check from core.library_reorganize import _build_post_process_context -@pytest.fixture(autouse=True) -def _preserve(monkeypatch): - monkeypatch.setattr("core.library_reorganize._preserve_casing_enabled", lambda: True) - monkeypatch.setattr("core.library_reorganize._feat_in_title_enabled", lambda: False) - - def _ctx(): return _build_post_process_context( {"id": "sp1", "name": "AoT S2 OST", "release_date": "2017", "total_tracks": 45, "images": [{"url": ""}]}, {"name": "Apetitan", "track_number": 2, "disc_number": 1, "artists": [{"name": "Sawano Hiroyuki"}]}, - "Sawano Hiroyuki", "AoT S2 OST", 2, local_title="Apetitan") - - -def test_the_acoustid_quarantine_leg_is_skipped(): - assert _should_skip_quarantine_check(_ctx(), "acoustid") is True + "Sawano Hiroyuki", "AoT S2 OST", 2) -def test_the_corruption_legs_still_run(): - """Skipping identity is not skipping safety: a truncated or unparseable file - must still be caught before it is moved anywhere.""" +def test_no_acceptance_check_runs_so_there_is_nothing_to_opt_out_of(): + """The context carries neither flag, because it no longer reaches a + pipeline that reads them. The reorganize context exists for one purpose + now: handing the shared path builder the shape a download hands it.""" ctx = _ctx() - assert _should_skip_quarantine_check(ctx, "integrity") is False - assert _should_skip_quarantine_check(ctx, "bit_depth") is False + assert "_skip_quarantine_check" not in ctx + assert "is_local_import" not in ctx + + +def test_the_executor_that_ran_the_check_is_gone(): + """The quarantine happened inside `reorganize_album`, which staged a copy + and called `_post_process_matched_download`. Both are gone; the only + executor moves the file the user already has.""" + import core.library_reorganize as lr + assert not hasattr(lr, "reorganize_album") + assert not hasattr(lr, "_stage_track") + assert not hasattr(lr, "_run_post_process_for_track") + assert hasattr(lr, "reorganize_album_rename_only") def test_a_normal_download_still_gets_the_identity_check(): diff --git a/tests/test_reorganize_preserve_casing.py b/tests/test_reorganize_preserve_casing.py deleted file mode 100644 index a4df08ab2..000000000 --- a/tests/test_reorganize_preserve_casing.py +++ /dev/null @@ -1,190 +0,0 @@ -"""Library Organize must not churn on cosmetic casing (#1078, QT3496). - -The reorganize rebuilds each title/album from the metadata source verbatim, -so it adopted the SOURCE's casing — Spotify capitalizing prepositions, an -ALL-CAPS artist, iTunes vs Deezer conventions — and flagged already-organized -files for a rename that only changed letter-case. With -`library.reorganize_preserve_casing` on (default), a difference that is ONLY -case keeps the user's own casing, so both the filename and the title tag stay -put; genuine edits still adopt the source. -""" - -from __future__ import annotations - -import os - -import pytest - -from core.library_reorganize import ( - _build_album_info, - _build_post_process_context, - _keep_user_casing, -) -from core.imports.paths import build_final_path_for_track - - -@pytest.fixture(autouse=True) -def _casing_on(monkeypatch): - monkeypatch.setattr("core.library_reorganize._preserve_casing_enabled", lambda: True) - monkeypatch.setattr("core.library_reorganize._feat_in_title_enabled", lambda: False) - - -# ── the pure rule ──────────────────────────────────────────────────────────── - -def test_keep_user_casing_case_only_keeps_user(): - assert _keep_user_casing("The Chase", "the chase") == "the chase" - assert _keep_user_casing("GREATEST HITS", "Greatest Hits") == "Greatest Hits" - assert _keep_user_casing("A Song In The Key", "A Song in the Key") == "A Song in the Key" - - -def test_keep_user_casing_real_difference_adopts_source(): - # punctuation / words / additions are NOT case-only → source wins - assert _keep_user_casing("Song (Remix)", "Song") == "Song (Remix)" - assert _keep_user_casing("Dont Stop", "Don't Stop") == "Dont Stop" - assert _keep_user_casing("The Chase", "") == "The Chase" - assert _keep_user_casing("The Chase", None) == "The Chase" - - -def test_keep_user_casing_disabled_passthrough(monkeypatch): - monkeypatch.setattr("core.library_reorganize._preserve_casing_enabled", lambda: False) - assert _keep_user_casing("The Chase", "the chase") == "The Chase" - - -# ── end to end: title + album ──────────────────────────────────────────────── - -def _album(name="Greatest Hits"): - return {"id": "AL1", "name": name, "release_date": "2020-01-01", - "total_tracks": 10, "images": [{"url": ""}]} - - -def _ctx(api_title, local_title, api_album="Greatest Hits", db_album="Greatest Hits"): - return _build_post_process_context( - _album(api_album), - {"name": api_title, "track_number": 1, "disc_number": 1, "artists": [{"name": "A"}]}, - "A", db_album, 1, local_title=local_title) - - -def test_title_casing_preserved_in_filename_and_tag(): - ctx = _ctx("The Chase", "the chase") - # tag title keeps the user's case - assert ctx["original_search_result"]["title"] == "the chase" - assert ctx["original_search_result"]["spotify_clean_title"] == "the chase" - # ...and so does the filename built from it - ai = _build_album_info(ctx) - path, _ = build_final_path_for_track(ctx, ctx["spotify_artist"], ai, ".flac", create_dirs=False) - assert os.path.basename(path) == "01 - the chase.flac" - - -def test_album_folder_casing_preserved(): - ctx = _ctx("Song", "Song", api_album="GREATEST HITS", db_album="Greatest Hits") - assert ctx["spotify_album"]["name"] == "Greatest Hits" - - -def test_real_title_edit_still_adopts_source(): - ctx = _ctx("Song (Remix)", "Song") - assert ctx["original_search_result"]["title"] == "Song (Remix)" - - -def test_disabled_setting_canonicalizes_to_source(monkeypatch): - monkeypatch.setattr("core.library_reorganize._preserve_casing_enabled", lambda: False) - ctx = _ctx("The Chase", "the chase", api_album="GREATEST HITS", db_album="Greatest Hits") - assert ctx["original_search_result"]["title"] == "The Chase" - assert ctx["spotify_album"]["name"] == "GREATEST HITS" - - -def test_casing_preserve_composes_with_feat(monkeypatch): - """Casing preserve runs AFTER feat: a bare source title vs a user's - feat-tagged title is a real change (feat added), not case-only.""" - monkeypatch.setattr("core.library_reorganize._feat_in_title_enabled", lambda: True) - ctx = _build_post_process_context( - _album(), {"name": "The Chase", "track_number": 1, "disc_number": 1, - "artists": [{"name": "A"}, {"name": "Big Artist"}]}, - "A", "Greatest Hits", 1, local_title="the chase (feat. big artist)") - # feat re-added from the API artists, then the WHOLE thing is a case-only - # match to the user's title → user's casing kept - assert ctx["original_search_result"]["title"] == "the chase (feat. big artist)" - - -# ── #1080: the user's own album year is kept, not the source's original ────── - -def test_keep_user_year_prefers_user_when_preserving(): - from core.library_reorganize import _keep_user_year - assert _keep_user_year("2020-05-01", "2023") == "2023" # reissue year kept - assert _keep_user_year("2020-05-01", "2020") == "2020-05-01" # same → source - assert _keep_user_year("2020-05-01", None) == "2020-05-01" # no user year - assert _keep_user_year("2020-05-01", "bogus") == "2020-05-01" # not a 4-digit year - - -def test_keep_user_year_disabled_passthrough(monkeypatch): - monkeypatch.setattr("core.library_reorganize._preserve_casing_enabled", lambda: False) - from core.library_reorganize import _keep_user_year - assert _keep_user_year("2020-05-01", "2023") == "2020-05-01" - - -def test_context_carries_user_year_into_release_date(): - ctx = _build_post_process_context( - _album(), {"name": "Song", "track_number": 1, "disc_number": 1, "artists": [{"name": "A"}]}, - "A", "Alb", 1, local_year="2023") - assert ctx["spotify_album"]["release_date"] == "2023" - - -# ── end-to-end preview: an already-organized file is left UNCHANGED (#1080) ── - -def test_preview_leaves_already_organized_file_unchanged(monkeypatch, tmp_path): - """The real complaint: run the actual preview against a file already - organized with the user's casing + year, and confirm it reports NO change - (not just that the path builder emits the right string). Mirrors QT3496's - 'The Violence (Sikdope Remix) [2019]' (casing) + 'Best Of Underoath [2014]' - (casing + year) screenshots — the source returns lowercase 'remix'/'of' - and year 2013/2020, which used to churn.""" - import core.library_reorganize as lr - from core.imports.paths import build_final_path_for_track - - monkeypatch.setattr(lr, "_preserve_casing_enabled", lambda: True) - monkeypatch.setattr("core.imports._get_config_manager" if False else - "core.imports.paths._get_config_manager", - lambda: _TemplateCM()) - - album_data = {"id": "AL1", "title": "The Violence (Sikdope Remix)", - "artist_name": "Asking Alexandria", "artist_id": "AR1", - "year": 2019, "spotify_album_id": "sp1"} - tracks = [{"id": "T1", "title": "The Violence (Sikdope Remix)", - "track_number": 1, "file_path": "X", "duration": 200}] - api_album = {"id": "sp1", "name": "The Violence (Sikdope remix)", - "release_date": "2020-01-01", "album_type": "single", - "total_tracks": 1, "images": [{"url": ""}]} - api_tracks = [{"name": "The Violence (Sikdope remix)", "track_number": 1, - "disc_number": 1, "artists": [{"name": "Asking Alexandria"}]}] - monkeypatch.setattr(lr, "load_album_and_tracks", - lambda db, aid: (dict(album_data), [dict(t) for t in tracks])) - monkeypatch.setattr(lr, "_resolve_source", - lambda ad, ps, strict_source=False, **kw: ("spotify", api_album, api_tracks)) - monkeypatch.setattr(lr, "_feat_in_title_enabled", lambda: False) - - organized = ("/xfer/A/Asking Alexandria/The Violence (Sikdope Remix) " - "[2019] [Single]/01 - The Violence (Sikdope Remix).flac") - - def _preview(): - return lr.preview_album_reorganize( - album_id="AL1", db=None, transfer_dir="/xfer", - resolve_file_path_fn=lambda p: organized, - build_final_path_fn=build_final_path_for_track)["tracks"][0] - - assert _preview()["unchanged"] is True # preserve on → no rename - - monkeypatch.setattr(lr, "_preserve_casing_enabled", lambda: False) - assert _preview()["unchanged"] is False # off → would churn to the source - - -class _TemplateCM: - """Minimal config-manager stub feeding QT3496's templates + transfer dir.""" - _vals = { - "file_organization.templates": { - "album_path": "$artistletter/$albumartist/$album [$year] [$albumtype]/$disc$track - $title", - "single_path": "$artistletter/$albumartist/$album [$year] [Single]/$track - $title", - }, - "soulseek.transfer_path": "/xfer", - } - - def get(self, key, default=None): - return self._vals.get(key, default) diff --git a/tests/test_reorganize_preview.py b/tests/test_reorganize_preview.py new file mode 100644 index 000000000..10579fe16 --- /dev/null +++ b/tests/test_reorganize_preview.py @@ -0,0 +1,395 @@ +"""The reorganize preview — what the apply will do, computed from the library. + +This file is what remains of `test_library_reorganize_orchestrator.py`, which +tested `reorganize_album`: the executor that copied each file into a staging +folder and pushed it through the download post-process. That executor is gone +(see the module docstring of `core/library_reorganize.py`), and with it the +35 tests that pinned staging, per-track post-processing, quarantine handling on +a COPY, and source resolution against a provider. + +What survives is the part a reorganize still does: work out where each file +belongs and say so before touching anything. +""" + +import sqlite3 +import sys +import types + +import pytest + + +# --- module stubs (same shape used elsewhere in the test suite) ----------- +if "spotipy" not in sys.modules: + spotipy = types.ModuleType("spotipy") + + class _DummySpotify: + def __init__(self, *args, **kwargs): + pass + + oauth2 = types.ModuleType("spotipy.oauth2") + + class _DummyOAuth: + def __init__(self, *args, **kwargs): + pass + + spotipy.Spotify = _DummySpotify + oauth2.SpotifyOAuth = _DummyOAuth + oauth2.SpotifyClientCredentials = _DummyOAuth + spotipy.oauth2 = oauth2 + sys.modules["spotipy"] = spotipy + sys.modules["spotipy.oauth2"] = oauth2 + +if "core.settings" not in sys.modules: + config_pkg = types.ModuleType("config") + settings_mod = types.ModuleType("core.settings") + + class _DummyConfigManager: + def get(self, key, default=None): + return default + + def get_active_media_server(self): + return "primary" + + settings_mod.config_manager = _DummyConfigManager() + config_pkg.settings = settings_mod + sys.modules["config"] = config_pkg + sys.modules["core.settings"] = settings_mod + + +from core import library_reorganize # noqa: E402 + + +# --- helpers -------------------------------------------------------------- + +class _FakeDB: + """Wraps a sqlite3 in-memory connection that survives `close()` calls + so the tests can reuse it for assertions afterwards.""" + + def __init__(self): + self._conn = sqlite3.connect(":memory:") + self._conn.row_factory = sqlite3.Row + + def _get_connection(self): + return _NonClosingConnWrapper(self._conn) + + +class _NonClosingConnWrapper: + def __init__(self, real): + self._real = real + + def cursor(self): + return self._real.cursor() + + def execute(self, *args, **kwargs): + return self._real.execute(*args, **kwargs) + + def commit(self): + return self._real.commit() + + def close(self): + pass + + def __enter__(self): + return self + + def __exit__(self, *args): + pass + + +def _setup_album(db, *, album_id='alb-1', album_title='Aerosmith (1973)', + spotify_id='', deezer_id='', itunes_id='', discogs_id='', + soul_id='', tracks=()): + """Minimal artists/albums/tracks schema seeded with one album. + + `tracks` items are `(track_id, track_number, title, file_path)` or + `(track_id, track_number, disc_number, title, file_path)`. + """ + cur = db._conn.cursor() + cur.execute("CREATE TABLE artists (id TEXT PRIMARY KEY, name TEXT)") + cur.execute(""" + CREATE TABLE albums ( + id TEXT PRIMARY KEY, artist_id TEXT, title TEXT, + release_date TEXT, track_count INTEGER, + spotify_album_id TEXT, deezer_id TEXT, itunes_album_id TEXT, + discogs_id TEXT, soul_id TEXT + ) + """) + cur.execute(""" + CREATE TABLE tracks ( + id TEXT PRIMARY KEY, album_id TEXT, artist_id TEXT, title TEXT, + track_number INTEGER, disc_number INTEGER DEFAULT 1, + file_path TEXT, updated_at TEXT + ) + """) + cur.execute("INSERT INTO artists VALUES (?, ?)", ('artist-1', 'Aerosmith')) + cur.execute( + "INSERT INTO albums (id, artist_id, title, release_date, track_count, " + "spotify_album_id, deezer_id, itunes_album_id, discogs_id, soul_id) " + "VALUES (?,?,?,?,?,?,?,?,?,?)", + (album_id, 'artist-1', album_title, '1973-01-05', len(tracks), + spotify_id, deezer_id, itunes_id, discogs_id, soul_id), + ) + for row in tracks: + tid, tn, disc, title, fp = row if len(row) == 5 else (row[0], row[1], 1, row[2], row[3]) + cur.execute( + "INSERT INTO tracks (id, album_id, artist_id, title, track_number, " + "disc_number, file_path) VALUES (?,?,?,?,?,?,?)", + (tid, album_id, 'artist-1', title, tn, disc, fp), + ) + db._conn.commit() + + +@pytest.fixture +def tmpdirs(tmp_path): + library = tmp_path / "library" + transfer = tmp_path / "transfer" + library.mkdir() + transfer.mkdir() + return library, transfer + + +def _make_audio_file(library_dir, name='song.flac', content=b'fakeflacdata'): + p = library_dir / name + p.write_bytes(content) + return str(p) + + +def _fake_path_builder(context, spotify_artist, _album_info, file_ext, **_kw): + """Stand-in for `build_final_path_for_track`. Inserts Disc N/ when + total_discs > 1 — same convention the real builder uses.""" + album = context['spotify_album']['name'] + artist = spotify_artist['name'] + track_info = context['track_info'] + title = track_info['name'] + tn = track_info['track_number'] + dn = track_info['disc_number'] + total = context['spotify_album']['total_discs'] + parts = ['/transfer', artist, album] + if total > 1: + parts.append(f'Disc {dn}') + parts.append(f"{tn:02d} - {title}{file_ext}") + return '/'.join(parts), True + + +def _path_builder_album_vs_single(context, spotify_artist, album_info, file_ext, **_kw): + """Stand-in that emulates the real builder's branch on + `album_info.get('is_album')`. SINGLE mode produces a per-track folder + named after the title — the bug output.""" + artist = spotify_artist['name'] + if album_info and album_info.get('is_album'): + album = album_info['album_name'] + title = album_info['clean_track_name'] + tn = album_info['track_number'] + dn = album_info['disc_number'] + total = context['spotify_album']['total_discs'] + if total > 1: + return (f'/transfer/{artist}/{artist} - {album}/Disc {dn}/{tn:02d} - {title}{file_ext}', True) + return (f'/transfer/{artist}/{artist} - {album}/{tn:02d} - {title}{file_ext}', True) + title = context['track_info']['name'] + return (f'/transfer/{artist}/{artist} - {title}/{title}{file_ext}', True) + + +def _preview(db, transfer_dir='/transfer', build=_fake_path_builder): + return library_reorganize.preview_album_reorganize( + album_id='alb-1', db=db, transfer_dir=str(transfer_dir), + resolve_file_path_fn=lambda p: p, + build_final_path_fn=build, + ) + + +# --- the plan comes from the library --------------------------------------- + +def test_an_album_with_no_source_id_gets_a_plan(tmpdirs): + """The old preview refused it outright (`status: 'no_source_id'`, "run + enrichment first") because it needed a provider to ask for a tracklist. + Moving a file needs no provider.""" + library, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, tracks=[ + ('t1', 1, 'Same Old Song And Dance', _make_audio_file(library, 't1.flac')), + ]) + + result = _preview(db) + + assert result['success'] is True + assert result['status'] == 'planned' + assert result['source'] == 'catalogue' + (track,) = result['tracks'] + assert track['matched'] is True + assert track['new_path'] + + +def test_preview_uses_album_mode_not_single_mode(tmpdirs): + """Regression for the bug where every track ended up in its own + track-named folder (SINGLE MODE) because we passed None for album_info to + the path builder.""" + library, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, album_title='good kid, m.A.A.d city', tracks=[ + ('t1', 1, 'Sherane', _make_audio_file(library, 't1.flac')), + ('t2', 2, 'Bitch Dont Kill My Vibe', _make_audio_file(library, 't2.flac')), + ]) + + result = _preview(db, build=_path_builder_album_vs_single) + + paths = [it['new_path'] for it in result['tracks']] + assert all('good kid, m.A.A.d city' in p for p in paths) + assert any('01 - Sherane' in p for p in paths) + assert any('02 - Bitch Dont Kill My Vibe' in p for p in paths) + assert not any(p.endswith('/Sherane.flac') for p in paths) + + +def test_preview_emits_disc_subfolders_for_multi_disc_albums(tmpdirs): + """The bug winecountrygames hit: preview showed all tracks at the album + root with no Disc N/ subfolders. The disc layout is the library's own now, + so it cannot change between two previews of the same album.""" + library, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, album_title='good kid, m.A.A.d city (Deluxe)', tracks=[ + ('t1d1', 1, 1, 'Sherane', _make_audio_file(library, 'd1t1.flac')), + ('t1d2', 1, 2, 'The Recipe', _make_audio_file(library, 'd2t1.flac')), + ]) + + result = _preview(db) + + assert result['success'] is True + by_title = {it['title']: it for it in result['tracks']} + assert 'Disc 1' in by_title['Sherane']['new_path'] + assert 'Disc 2' in by_title['The Recipe']['new_path'] + assert by_title['Sherane']['disc_number'] == 1 + assert by_title['The Recipe']['disc_number'] == 2 + + +def test_preview_marks_a_track_the_library_cannot_name(tmpdirs): + """A track with no title has no filename to build. It is surfaced with a + reason rather than dropped or given a guess.""" + library, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, tracks=[ + ('t1', 1, 'A Real Track', _make_audio_file(library, 't1.flac')), + ('t99', 99, '', _make_audio_file(library, 't99.flac')), + ]) + + result = _preview(db) + + by_id = {it['track_id']: it for it in result['tracks']} + assert by_id['t1']['matched'] is True + assert by_id['t1']['new_path'] + assert by_id['t99']['matched'] is False + assert by_id['t99']['reason'] + assert by_id['t99']['new_path'] == '' + + +def test_preview_skips_track_in_deleted_quarantine(tmpdirs): + """A track whose file lives in /deleted (duplicate-cleaner + quarantine) is surfaced as a non-matched skip — Reorganize must not offer + to move it back out of /deleted (#746).""" + library, transfer = tmpdirs + db = _FakeDB() + quarantine = transfer / 'deleted' / 'Aerosmith' + quarantine.mkdir(parents=True) + deleted_file = quarantine / 'dream.flac' + deleted_file.write_bytes(b'dupe') + _setup_album(db, tracks=[ + ('t1', 1, 'Same Old Song And Dance', _make_audio_file(library, 't1.flac')), + ('t2', 2, 'Dream On', str(deleted_file)), + ]) + + result = _preview(db, transfer_dir=transfer) + + by_title = {it['title']: it for it in result['tracks']} + assert by_title['Same Old Song And Dance']['matched'] is True + assert by_title['Same Old Song And Dance']['new_path'] + assert by_title['Dream On']['matched'] is False + assert 'quarantine' in (by_title['Dream On']['reason'] or '').lower() + assert by_title['Dream On']['new_path'] == '' + + +def test_preview_plans_move_out_of_old_template_folder(monkeypatch, tmpdirs): + """TheHomeGuy's report: after changing the album template, both the + Tools-page job and Reorganize All did nothing — every track came back + `unchanged`, because #829's existing-folder reuse resolved the folder the + album was being moved OUT of. Wires the REAL builder with a poisoned + resolver and pins that a reorganize context never consults it.""" + import core.imports.paths as import_paths + import core.library.existing_album_folder as eaf + import database.music_database as mdb + + _library, transfer = tmpdirs + + class _Cfg: + def __init__(self, values): + self._values = values + + def get(self, key, default=None): + return self._values.get(key, default) + + def get_active_media_server(self): + return None + + monkeypatch.setattr(import_paths, "_get_config_manager", lambda: _Cfg({ + "soulseek.transfer_path": str(transfer), + "file_organization.enabled": True, + "file_organization.templates": { + "album_path": "$albumartist/$album/$track - $title", + "single_path": "$artist/$title", + }, + "file_organization.collab_artist_mode": "first", + "file_organization.disc_label": "Disc", + })) + monkeypatch.setattr(import_paths, "_get_album_tracks_for_source", lambda *a: None) + + old_home = transfer / "Aerosmith" / "Aerosmith - Rocks" + old_home.mkdir(parents=True) + current = old_home / "01 - Back in the Saddle.flac" + current.write_bytes(b"fakeflacdata") + + monkeypatch.setattr(mdb, "get_database", lambda: object(), raising=False) + resolver_calls = [] + monkeypatch.setattr(eaf, "resolve_existing_album_folder", + lambda **kw: resolver_calls.append(kw) or str(old_home)) + + db = _FakeDB() + _setup_album(db, album_title='Rocks', tracks=[ + ('t1', 1, 'Back in the Saddle', str(current)), + ]) + + result = _preview(db, transfer_dir=transfer, + build=import_paths.build_final_path_for_track) + + assert result['success'] is True + (track,) = result['tracks'] + assert track['matched'] is True + assert resolver_calls == [] # reuse never consulted for reorganize + assert track['unchanged'] is False # the bug reported True here + assert track['new_path_abs'] == str( + transfer / "Aerosmith" / "Rocks" / "01 - Back in the Saddle.flac") + + +# --- the context handed to the shared path builder ------------------------- + +def test_reorganize_context_disables_folder_reuse(): + """Every reorganize context carries the no-reuse flag: the destination + comes from the CURRENT template alone, never from where the album already + sits (#829).""" + context = library_reorganize._build_post_process_context( + {'id': 'dz-1', 'name': 'Rocks'}, + {'id': 'a1', 'name': 'Back in the Saddle', 'track_number': 1}, + 'Aerosmith', 'Rocks', 1, + ) + assert context['_no_album_folder_reuse'] is True + + +def test_reorganize_context_no_longer_opts_out_of_an_acceptance_check(): + """`is_local_import` (#804) and `_skip_quarantine_check` (#1182) were + opt-outs FROM the download post-process. A reorganize does not run it any + more, so a flag that says "skip this check" would describe a check that no + longer happens.""" + context = library_reorganize._build_post_process_context( + {'id': 'dz-1', 'name': 'Rocks'}, + {'id': 'a1', 'name': 'Back in the Saddle', 'track_number': 1}, + 'Aerosmith', 'Rocks', 1, + ) + assert 'is_local_import' not in context + assert '_skip_quarantine_check' not in context + diff --git a/tests/test_reorganize_queue.py b/tests/test_reorganize_queue.py index 8e2707b47..73a47020d 100644 --- a/tests/test_reorganize_queue.py +++ b/tests/test_reorganize_queue.py @@ -6,9 +6,9 @@ running returns ``{'queued': False, 'reason': 'already_queued'}`` and the existing queue_id, never a duplicate. 2. **FIFO order** — the worker drains items in submission order. -3. **Per-item source preserved** — the source string the user picked at - enqueue time is what the runner sees, even when multiple items with - different sources are interleaved. +3. **What the runner sees is what was enqueued** — the item reaching the + runner is the one submitted, in order. (There is no per-item ``source`` + any more: a reorganize plans from the catalogue and asks no provider.) 4. **Continue on failure** — a runner that raises (or one whose summary reports a non-completed status) marks that item failed and the worker moves to the next item, it does not stall. @@ -42,7 +42,7 @@ def _make_runner(record, *, raise_on=None, summary_factory=None, """Build a runner closure that records what it was called with. Args: - record: list to append `(queue_id, source)` to per call. + record: list to append each call's queue_id to. raise_on: queue_id (or set of queue_ids) for which the runner should raise — used to test continue-on-failure. summary_factory: optional callable `(item) -> summary dict` to @@ -59,7 +59,7 @@ def _make_runner(record, *, raise_on=None, summary_factory=None, raise_set = set(raise_on) def runner(item): - record.append((item.queue_id, item.source)) + record.append(item.queue_id) if block_event is not None: block_event.wait(timeout=2.0) if runtime: @@ -70,7 +70,6 @@ def runner(item): return summary_factory(item) return { 'status': 'completed', - 'source': item.source or 'spotify', 'total': 1, 'moved': 1, 'skipped': 0, @@ -80,13 +79,12 @@ def runner(item): return runner -def _enqueue(queue, *, album_id, source=None, title=None, artist='Aerosmith'): +def _enqueue(queue, *, album_id, title=None, artist='Aerosmith'): return queue.enqueue( album_id=album_id, album_title=title or f"Album {album_id}", artist_id='artist-1', artist_name=artist, - source=source, ) @@ -128,8 +126,8 @@ def test_enqueue_returns_queued_with_position(queue): def test_enqueue_same_album_dedupes(queue): queue.set_runner(_make_runner([], block_event=threading.Event())) - r1 = _enqueue(queue, album_id='alb-1', source='spotify') - r2 = _enqueue(queue, album_id='alb-1', source='deezer') # different source + r1 = _enqueue(queue, album_id='alb-1') + r2 = _enqueue(queue, album_id='alb-1') assert r1['queued'] is True assert r2['queued'] is False assert r2['reason'] == 'already_queued' @@ -142,7 +140,7 @@ def test_dedupe_releases_after_completion(queue): record = [] queue.set_runner(_make_runner(record)) r1 = _enqueue(queue, album_id='alb-1') - assert _wait_for(lambda: any(r[0] == r1['queue_id'] for r in record)) + assert _wait_for(lambda: r1['queue_id'] in record) # Wait for the item to flip into the recent bucket. assert _wait_for(lambda: queue.snapshot()['active'] is None) r2 = _enqueue(queue, album_id='alb-1') @@ -155,17 +153,7 @@ def test_fifo_order(queue): queue.set_runner(_make_runner(record)) ids = [_enqueue(queue, album_id=f'alb-{i}')['queue_id'] for i in range(5)] assert _wait_for(lambda: len(record) == 5) - assert [r[0] for r in record] == ids - - -def test_per_item_source_preserved(queue): - record = [] - queue.set_runner(_make_runner(record)) - sources = ['spotify', 'deezer', 'itunes', None, 'discogs'] - for i, src in enumerate(sources): - _enqueue(queue, album_id=f'alb-{i}', source=src) - assert _wait_for(lambda: len(record) == len(sources)) - assert [r[1] for r in record] == sources + assert record == ids def test_continue_on_runner_exception(queue): @@ -179,12 +167,12 @@ def test_continue_on_runner_exception(queue): raise_target = {} def runner(item): - record.append((item.queue_id, item.source)) + record.append(item.queue_id) block.wait(timeout=2.0) if item.queue_id == raise_target.get('id'): raise RuntimeError(f"Simulated failure for {item.queue_id}") return { - 'status': 'completed', 'source': 'spotify', + 'status': 'completed', 'total': 1, 'moved': 1, 'skipped': 0, 'failed': 0, 'errors': [], } @@ -194,7 +182,7 @@ def runner(item): block.set() assert _wait_for(lambda: len(record) == 3) - assert [r[0] for r in record] == ids + assert record == ids assert _wait_for(lambda: queue.snapshot()['active'] is None) snap = queue.snapshot() @@ -209,7 +197,6 @@ def test_failed_status_when_runner_reports_failed_tracks(queue): 'failed' even if the runner returned normally.""" queue.set_runner(_make_runner([], summary_factory=lambda item: { 'status': 'completed', - 'source': 'spotify', 'total': 5, 'moved': 4, 'skipped': 0, @@ -230,11 +217,10 @@ def test_failed_status_when_runner_reports_failed_tracks(queue): def test_failed_status_when_runner_reports_non_completed_status(queue): - """``status='no_source_id'`` and friends are setup-failures — they + """``status='setup_failed'`` and friends are setup-failures — they leave failed=0 but the item is still NOT a success.""" queue.set_runner(_make_runner([], summary_factory=lambda item: { - 'status': 'no_source_id', - 'source': None, + 'status': 'setup_failed', 'total': 0, 'moved': 0, 'skipped': 0, @@ -246,7 +232,7 @@ def test_failed_status_when_runner_reports_non_completed_status(queue): snap = queue.snapshot() item = next(r for r in snap['recent'] if r['queue_id'] == qid) assert item['status'] == 'failed' - assert item['result_status'] == 'no_source_id' + assert item['result_status'] == 'setup_failed' def test_cancel_queued_item(queue): @@ -365,7 +351,7 @@ def test_enqueue_many_tallies_enqueued_and_dedupes(queue): # Pre-existing item — should appear as already_queued. queue.enqueue(album_id='alb-existing', album_title='X', - artist_id='ar-1', artist_name='A', source=None) + artist_id='ar-1', artist_name='A') # Wait for it to be running so the dedupe path triggers. assert _wait_for(lambda: queue.snapshot()['active'] is not None) @@ -379,19 +365,20 @@ def test_enqueue_many_tallies_enqueued_and_dedupes(queue): block.set() -def test_enqueue_many_carries_source_per_item(queue): - """Each dict's ``source`` is honoured independently — the bulk - helper doesn't collapse them to one value.""" +def test_enqueue_many_runs_every_item_in_order(queue): + """The bulk helper enqueues each dict as its own item; extra keys a caller + happens to carry (a leftover ``source``) are ignored rather than fatal.""" record = [] queue.set_runner(_make_runner(record)) items = [ - {'album_id': 'a', 'album_title': 'A', 'artist_id': 'x', 'artist_name': 'X', 'source': 'spotify'}, - {'album_id': 'b', 'album_title': 'B', 'artist_id': 'x', 'artist_name': 'X', 'source': 'deezer'}, - {'album_id': 'c', 'album_title': 'C', 'artist_id': 'x', 'artist_name': 'X', 'source': None}, + {'album_id': 'a', 'album_title': 'A', 'artist_id': 'x', 'artist_name': 'X'}, + {'album_id': 'b', 'album_title': 'B', 'artist_id': 'x', 'artist_name': 'X'}, + {'album_id': 'c', 'album_title': 'C', 'artist_id': 'x', 'artist_name': 'X', + 'source': 'spotify'}, ] queue.enqueue_many(items) assert _wait_for(lambda: len(record) == 3) - assert [r[1] for r in record] == ['spotify', 'deezer', None] + assert len(set(record)) == 3 def test_enqueue_many_handles_empty_list(queue): @@ -441,7 +428,7 @@ def runner(item): # could (incorrectly) fire on a running item. time.sleep(0.002) return { - 'status': 'completed', 'source': 'spotify', + 'status': 'completed', 'total': 1, 'moved': 1, 'skipped': 0, 'failed': 0, 'errors': [], } diff --git a/tests/test_reorganize_rename_only.py b/tests/test_reorganize_rename_only.py index 2fd027747..738fcf0e5 100644 --- a/tests/test_reorganize_rename_only.py +++ b/tests/test_reorganize_rename_only.py @@ -7,6 +7,7 @@ import os from core.library_reorganize import ( + _move_album_sidecars, _rename_track_in_place, reorganize_album_rename_only, ) @@ -230,3 +231,160 @@ def test_an_unresolvable_source_is_skipped_without_creating_folders(tmp_path): assert not new.parent.exists(), "an empty destination tree was created" assert out["moved"] == 0 and out["failed"] == 0 and out["skipped"] == 1 + + +# ── sidecars travel with the file they belong to ── + +def test_lyrics_sidecar_follows_the_track(tmp_path): + """A reorganize that leaves the .lrc behind has lost it. + + The full-mode reorganize could DELETE per-track sidecars at the source + because post-processing re-created them at the destination. A move has no + such second half, so it has to carry them. + """ + src = tmp_path / "old" / "01 - Song.flac" + src.parent.mkdir(parents=True) + src.write_bytes(b"audio") + (tmp_path / "old" / "01 - Song.lrc").write_text("[00:01.00] a line") + dst = tmp_path / "new" / "Song - Artist.flac" + + ok, err = _rename_track_in_place(str(src), str(dst)) + + assert ok and err is None + assert (tmp_path / "new" / "Song - Artist.lrc").read_text() == "[00:01.00] a line" + assert not (tmp_path / "old" / "01 - Song.lrc").exists() + + +def test_other_per_track_sidecars_follow_too(tmp_path): + src = tmp_path / "old" / "01 - Song.flac" + src.parent.mkdir(parents=True) + src.write_bytes(b"audio") + for ext in ('.nfo', '.cue'): + (tmp_path / "old" / f"01 - Song{ext}").write_text(ext) + dst = tmp_path / "new" / "Song - Artist.flac" + + ok, _ = _rename_track_in_place(str(src), str(dst)) + + assert ok + for ext in ('.nfo', '.cue'): + assert (tmp_path / "new" / f"Song - Artist{ext}").read_text() == ext + + +def test_a_sidecar_already_at_the_destination_is_not_clobbered(tmp_path): + src = tmp_path / "old" / "01 - Song.flac" + src.parent.mkdir(parents=True) + src.write_bytes(b"audio") + (tmp_path / "old" / "01 - Song.lrc").write_text("mine") + dst_dir = tmp_path / "new"; dst_dir.mkdir() + (dst_dir / "Song - Artist.lrc").write_text("already there") + + ok, _ = _rename_track_in_place(str(src), str(dst_dir / "Song - Artist.flac")) + + assert ok + assert (dst_dir / "Song - Artist.lrc").read_text() == "already there" + + +# ── album-level artwork follows once the source album is empty ───────────── + +def test_album_art_and_album_sidecars_follow_the_album(tmp_path): + src = tmp_path / "old album" + dst = tmp_path / "new album" + src.mkdir(); dst.mkdir() + for name in ("cover.jpg", "folder.png", "album.nfo", "playlist.m3u"): + (src / name).write_text(name) + + moved = _move_album_sidecars(str(src), str(dst)) + + assert moved == 4 + for name in ("cover.jpg", "folder.png", "album.nfo", "playlist.m3u"): + assert (dst / name).read_text() == name + assert not (src / name).exists() + + +def test_album_sidecars_wait_until_no_audio_remains(tmp_path): + src = tmp_path / "old album" + dst = tmp_path / "new album" + src.mkdir(); dst.mkdir() + (src / "cover.jpg").write_bytes(b"cover") + (src / "track-still-here.flac").write_bytes(b"audio") + + assert _move_album_sidecars(str(src), str(dst)) == 0 + assert (src / "cover.jpg").read_bytes() == b"cover" + assert not (dst / "cover.jpg").exists() + + +def test_album_sidecars_never_overwrite_destination_files(tmp_path): + src = tmp_path / "old album" + dst = tmp_path / "new album" + src.mkdir(); dst.mkdir() + (src / "cover.jpg").write_bytes(b"old") + (dst / "cover.jpg").write_bytes(b"new") + + assert _move_album_sidecars(str(src), str(dst)) == 0 + assert (src / "cover.jpg").read_bytes() == b"old" + assert (dst / "cover.jpg").read_bytes() == b"new" + + +def test_album_sidecar_move_keeps_real_documents_at_source(tmp_path): + src = tmp_path / "old album" + dst = tmp_path / "new album" + src.mkdir(); dst.mkdir() + (src / "cover.jpg").write_bytes(b"cover") + (src / "booklet.pdf").write_bytes(b"booklet") + + assert _move_album_sidecars(str(src), str(dst)) == 1 + assert (dst / "cover.jpg").exists() + assert (src / "booklet.pdf").read_bytes() == b"booklet" + + +# ── a sibling-format file is part of the track, not a separate move ── + +def test_a_sibling_never_overwrites_a_file_already_at_the_destination(tmp_path): + """The canonical move refuses a destination that already holds a different + file ('destination already exists'). The sibling move used `shutil.move` + with no check at all, which on one filesystem is `os.rename` and clobbers. + """ + src = tmp_path / "old" / "01 - Song.flac" + src.parent.mkdir(parents=True) + src.write_bytes(b"audio") + (tmp_path / "old" / "01 - Song.opus").write_bytes(b"lossy copy") + dst_dir = tmp_path / "new"; dst_dir.mkdir() + (dst_dir / "Song - Artist.opus").write_bytes(b"someone else") + + ok, _ = _rename_track_in_place(str(src), str(dst_dir / "Song - Artist.flac")) + + assert ok + assert (dst_dir / "Song - Artist.opus").read_bytes() == b"someone else" + assert (tmp_path / "old" / "01 - Song.opus").read_bytes() == b"lossy copy" + + +def test_a_failed_move_leaves_the_sibling_where_it_was(tmp_path, monkeypatch): + """Siblings were carried BEFORE the canonical rename. When that rename then + failed the track was reported failed, but the `.opus` was already at the new + location while the `.flac` and the catalogue row still named the old one. + + Same rule the sidecar helper states for itself: a failed move must leave the + whole track where it was. + """ + src = tmp_path / "old" / "01 - Song.flac" + src.parent.mkdir(parents=True) + src.write_bytes(b"audio") + sibling = tmp_path / "old" / "01 - Song.opus" + sibling.write_bytes(b"lossy copy") + dst = tmp_path / "new" / "Song - Artist.flac" + + real_rename = os.rename + + def _rename(a, b, *args, **kwargs): + if str(a) == str(src): + raise OSError(13, "Permission denied") + return real_rename(a, b, *args, **kwargs) + + monkeypatch.setattr(os, "rename", _rename) + + ok, err = _rename_track_in_place(str(src), str(dst)) + + assert not ok and err + assert src.exists() + assert sibling.exists() # not carried ahead of the audio + assert not (tmp_path / "new" / "Song - Artist.opus").exists() diff --git a/tests/test_reorganize_runner.py b/tests/test_reorganize_runner.py index 22fdf7971..3aeaf6e1e 100644 --- a/tests/test_reorganize_runner.py +++ b/tests/test_reorganize_runner.py @@ -4,15 +4,16 @@ 1. **Runner is a closure** — calling `build_runner` returns a callable that takes a queue item and returns a summary dict matching - `reorganize_album`'s shape. + the executor's shape. 2. **Config is read per-run, not at factory time** — changing the download/transfer path between runs is honoured. Web server config should never need a restart for this to take effect. -3. **Setup failure surfaces a clean summary** — if the staging dir - cannot be created, the runner returns `status='setup_failed'` - instead of raising (so the queue marks the item failed cleanly). +3. **Setup failure surfaces a clean summary** — with no path builder + there is nothing to compute a destination with, so the runner + returns `status='setup_failed'` instead of raising (the queue then + marks the item failed cleanly). 4. **Progress callbacks fan out into the queue** — the runner wires - `reorganize_album`'s `on_progress` to `update_active_progress` on + the executor's `on_progress` to `update_active_progress` on the live singleton queue, so the status panel sees per-track state. 5. **Dependencies are injected, not imported** — the factory takes every external dependency as a callable so tests can run without @@ -80,13 +81,12 @@ def _make_item(*, queue_id='qid-1', album_id='alb-1', source=None): def _build(monkeypatch, *, download_path_fn, transfer_path_fn, - reorganize_album_fn, get_database=lambda: object()): - """Helper: stub out the heavy reorganize_album call so we can test - the wiring without a real DB / post-process pipeline.""" - # Patch the import inside reorganize_runner.build_runner. - import core.reorganize_runner as mod + reorganize_album_fn, get_database=lambda: object(), + build_final_path_fn=lambda *a, **k: (None, True)): + """Helper: stub out the executor so we can test the wiring without a + real DB.""" monkeypatch.setattr( - 'core.library_reorganize.reorganize_album', + 'core.library_reorganize.reorganize_album_rename_only', reorganize_album_fn, raising=True, ) @@ -94,15 +94,15 @@ def _build(monkeypatch, *, download_path_fn, transfer_path_fn, return build_runner( get_database=get_database, resolve_file_path_fn=lambda p: p, - post_process_fn=lambda *a, **k: None, cleanup_empty_directories_fn=lambda *a, **k: None, is_shutting_down_fn=lambda: False, get_download_path=download_path_fn, get_transfer_path=transfer_path_fn, + build_final_path_fn=build_final_path_fn, ) -def test_runner_invokes_reorganize_album_with_injected_deps(monkeypatch, tmp_path): +def test_runner_invokes_the_executor_with_injected_deps(monkeypatch, tmp_path): captured = {} def fake_reorganize_album(**kwargs): @@ -123,10 +123,10 @@ def fake_reorganize_album(**kwargs): assert summary['status'] == 'completed' assert captured['album_id'] == 'alb-X' - assert captured['primary_source'] == 'deezer' - assert captured['strict_source'] is True - # staging_root is download_path / ssync_staging - assert captured['staging_root'].endswith('ssync_staging') + # No source to pass on: the plan comes from the catalogue. + assert 'primary_source' not in captured + assert 'strict_source' not in captured + assert callable(captured['build_final_path_fn']) assert callable(captured['on_progress']) assert callable(captured['stop_check']) @@ -136,10 +136,10 @@ def test_runner_reads_config_per_call(monkeypatch, tmp_path): the path-resolver lambda AT call time — not at build_runner time. This is the explicit fix for kettui-style "config change requires server restart" feedback.""" - seen_staging_roots = [] + seen = [] def fake_reorganize_album(**kwargs): - seen_staging_roots.append(kwargs['staging_root']) + seen.append(kwargs['transfer_dir']) return { 'status': 'completed', 'source': None, 'total': 0, 'moved': 0, 'skipped': 0, 'failed': 0, 'errors': [], @@ -148,8 +148,8 @@ def fake_reorganize_album(**kwargs): current_path = {'value': str(tmp_path / 'first')} runner = _build( monkeypatch, - download_path_fn=lambda: current_path['value'], - transfer_path_fn=lambda: '/tmp/transfer', + download_path_fn=lambda: str(tmp_path), + transfer_path_fn=lambda: current_path['value'], reorganize_album_fn=fake_reorganize_album, ) @@ -157,27 +157,24 @@ def fake_reorganize_album(**kwargs): current_path['value'] = str(tmp_path / 'second') runner(_make_item()) - assert len(seen_staging_roots) == 2 - assert 'first' in seen_staging_roots[0] - assert 'second' in seen_staging_roots[1] + assert len(seen) == 2 + assert 'first' in seen[0] + assert 'second' in seen[1] -def test_runner_returns_setup_failed_on_unwritable_path(monkeypatch, tmp_path): - """If the staging dir can't be created (permission denied, etc.), - the runner returns a clean ``setup_failed`` summary so the queue - marks the item failed without an unhandled exception.""" +def test_runner_returns_setup_failed_without_a_path_builder(monkeypatch, tmp_path): + """No path builder means no destination to compute. The runner returns a + clean ``setup_failed`` summary so the queue marks the item failed without + an unhandled exception.""" def fake_reorganize_album(**kwargs): - pytest.fail("reorganize_album should not run when setup fails") - - # Point at a child of an existing FILE — makedirs will raise OSError. - blocking_file = tmp_path / 'blocker' - blocking_file.write_text('x') + pytest.fail("the executor should not run when setup fails") runner = _build( monkeypatch, - download_path_fn=lambda: str(blocking_file), # makedirs fails here + download_path_fn=lambda: str(tmp_path), transfer_path_fn=lambda: '/tmp/transfer', reorganize_album_fn=fake_reorganize_album, + build_final_path_fn=None, ) summary = runner(_make_item()) assert summary['status'] == 'setup_failed' @@ -185,7 +182,7 @@ def fake_reorganize_album(**kwargs): def test_runner_progress_callback_forwards_to_queue(monkeypatch, tmp_path): - """When reorganize_album fires its on_progress callback, the runner + """When the executor fires its on_progress callback, the runner must forward into the live queue's update_active_progress so the status panel sees per-track updates.""" from core.reorganize_queue import get_queue, ReorganizeQueue @@ -217,7 +214,7 @@ def fake_reorganize_album(*, on_progress, **kwargs): q = get_queue() q.set_runner(runner) enq = q.enqueue(album_id='alb-1', album_title='good kid', - artist_id='ar-1', artist_name='Kendrick Lamar', source=None) + artist_id='ar-1', artist_name='Kendrick Lamar') # Wait for the worker to finish (fake_reorganize_album is fast). deadline_passes = 0 @@ -239,8 +236,8 @@ def fake_reorganize_album(*, on_progress, **kwargs): def test_rename_only_item_routes_to_rename_executor(monkeypatch, tmp_path): - """#875: an item with rename_only=True invokes the rename-only executor (NOT the - full reorganize_album), and never creates a staging dir.""" + """#875 asked for a mode that only moves. It is the whole behaviour now, so + an item still carrying the old flag lands in exactly the same place.""" captured = {} def fake_rename_only(**kwargs): @@ -248,10 +245,6 @@ def fake_rename_only(**kwargs): return {'status': 'completed', 'source': 'deezer', 'total': 1, 'moved': 1, 'skipped': 0, 'failed': 0, 'errors': []} - def fail_full(**kwargs): - raise AssertionError("full reorganize_album must NOT run for rename_only") - - monkeypatch.setattr('core.library_reorganize.reorganize_album', fail_full, raising=True) monkeypatch.setattr('core.library_reorganize.reorganize_album_rename_only', fake_rename_only, raising=True) @@ -281,7 +274,6 @@ def test_rename_only_without_path_builder_fails_cleanly(monkeypatch, tmp_path): runner = build_runner( get_database=lambda: object(), resolve_file_path_fn=lambda p: p, - post_process_fn=lambda *a, **k: None, cleanup_empty_directories_fn=lambda *a, **k: None, is_shutting_down_fn=lambda: False, get_download_path=lambda: str(tmp_path), @@ -504,3 +496,50 @@ def test_a_successful_update_still_does_not_raise(repoint): assert conn.execute( "SELECT file_path FROM tracks WHERE id = 't1'").fetchone()[0] == NEW + + +# ── a reorganize moves files, and that is all it does ──────────────────────── + +def test_every_item_routes_to_the_mover(monkeypatch, tmp_path): + """There is one executor now, and it moves. + + The full mode staged a COPY of a file the user already owns and pushed it + through the download post-process — an ACCEPTANCE check for files of + unknown origin. It re-tagged (a job already does that), it ran the AcoustID + identity leg against a library file (the scanner's job, and that one raises + a finding instead of moving anyone's audio), and it cost ~800MB of I/O for + a 20-track FLAC album. Nothing it added belonged to "put this file where + the template says". + """ + captured = {} + + def fake_rename_only(**kwargs): + captured.update(kwargs) + return {'status': 'completed', 'source': 'deezer', + 'total': 1, 'moved': 1, 'skipped': 0, 'failed': 0, 'errors': []} + + monkeypatch.setattr('core.library_reorganize.reorganize_album_rename_only', + fake_rename_only, raising=True) + + runner = build_runner( + get_database=lambda: object(), + resolve_file_path_fn=lambda p: p, + cleanup_empty_directories_fn=lambda *a, **k: None, + is_shutting_down_fn=lambda: False, + get_download_path=lambda: str(tmp_path), + get_transfer_path=lambda: str(tmp_path / 'transfer'), + build_final_path_fn=lambda *a, **k: (None, True), + ) + item = _make_item(album_id='alb-F', source='deezer') + item.rename_only = False # the old "full" request + summary = runner(item) + + assert summary['moved'] == 1 + assert captured['album_id'] == 'alb-F' + assert not (tmp_path / 'ssync_staging').exists() # nothing is staged, ever + + +def test_the_staging_executor_is_gone(): + import core.library_reorganize as lr + assert not hasattr(lr, 'reorganize_album') + assert not hasattr(lr, '_stage_track') diff --git a/tests/test_reorganize_tag_source.py b/tests/test_reorganize_tag_source.py deleted file mode 100644 index 9eee1ff52..000000000 --- a/tests/test_reorganize_tag_source.py +++ /dev/null @@ -1,602 +0,0 @@ -"""Boundary tests for ``core.library.reorganize_tag_source``. - -Pin every shape the embedded-tag → reorganize-context adapter has to -handle so future drift fails here instead of at runtime against a -real library: empty / missing essentials, multi-value vs single-string -artist tags, ID3-style ``"5/12"`` track-number values, year -normalization across date shapes, releasetype validation, multi-disc -parsing, defensive paths against bad input. - -The wrapper :func:`read_album_track_from_file` is tested against a -fake ``read_embedded_tags_fn`` so no real mutagen IO happens here. -""" - -from __future__ import annotations - -import sys -import types -from typing import Any, Dict - -import pytest - - -# ── stubs (other tests rely on these too — keep the shape consistent) ── -if 'utils.logging_config' not in sys.modules: - utils_mod = types.ModuleType('utils') - logging_mod = types.ModuleType('utils.logging_config') - logging_mod.get_logger = lambda name: type('L', (), { - 'debug': lambda *a, **k: None, - 'info': lambda *a, **k: None, - 'warning': lambda *a, **k: None, - 'error': lambda *a, **k: None, - })() - sys.modules['utils'] = utils_mod - sys.modules['utils.logging_config'] = logging_mod - - -from core.library.reorganize_tag_source import ( - extract_album_meta_from_tags, - extract_track_meta_from_tags, - read_album_track_from_file, - normalize_resolved_path, -) - - -# ─── extract_track_meta_from_tags ───────────────────────────────────── - - -class TestExtractTrackMeta: - def test_full_set_returns_canonical_shape(self): - out = extract_track_meta_from_tags({ - 'title': 'HUMBLE.', - 'artist': 'Kendrick Lamar', - 'tracknumber': '4', - 'discnumber': '1', - }) - assert out is not None - assert out['name'] == 'HUMBLE.' - assert out['title'] == 'HUMBLE.' - assert out['track_number'] == 4 - assert out['disc_number'] == 1 - assert out['artists'] == [{'name': 'Kendrick Lamar'}] - assert out['duration_ms'] == 0 - assert out['id'] == '' - assert out['uri'] == '' - - def test_missing_title_returns_none(self): - assert extract_track_meta_from_tags({'artist': 'foo'}) is None - assert extract_track_meta_from_tags({'title': '', 'artist': 'foo'}) is None - assert extract_track_meta_from_tags({'title': ' ', 'artist': 'foo'}) is None - - def test_missing_artist_returns_none(self): - assert extract_track_meta_from_tags({'title': 'Song'}) is None - assert extract_track_meta_from_tags({'title': 'Song', 'artist': ''}) is None - - def test_multi_value_artists_field_takes_precedence(self): - out = extract_track_meta_from_tags({ - 'title': 'Collab', - 'artist': 'Foo Bar', - 'artists': 'Foo, Bar, Baz', # multi-value tag joined by reader - }) - assert out is not None - assert out['artists'] == [{'name': 'Foo'}, {'name': 'Bar'}, {'name': 'Baz'}] - - def test_artist_string_split_on_known_separators(self): - for sep_input, expected in [ - ('Foo, Bar', ['Foo', 'Bar']), - ('Foo & Bar', ['Foo', 'Bar']), - ('Foo feat. Bar', ['Foo', 'Bar']), - ('Foo ft Bar', ['Foo', 'Bar']), - ('Foo featuring Bar', ['Foo', 'Bar']), - ('Foo / Bar', ['Foo', 'Bar']), - ('Foo; Bar', ['Foo', 'Bar']), - ('Foo x Bar', ['Foo', 'Bar']), - ('Foo with Bar', ['Foo', 'Bar']), - ]: - out = extract_track_meta_from_tags({'title': 't', 'artist': sep_input}) - assert out is not None - names = [a['name'] for a in out['artists']] - assert names == expected, f"failed for {sep_input!r}: got {names}" - - def test_artist_dedup_case_insensitive(self): - out = extract_track_meta_from_tags({ - 'title': 't', - 'artists': 'Foo, foo, FOO, Bar', - }) - assert out is not None - names = [a['name'] for a in out['artists']] - assert names == ['Foo', 'Bar'] - - def test_id3_track_total_shape(self): - # ID3 stores TRCK as "5/12" — caller must use the head only. - out = extract_track_meta_from_tags({ - 'title': 't', 'artist': 'a', - 'tracknumber': '5/12', - }) - assert out['track_number'] == 5 - - def test_disc_total_shape(self): - out = extract_track_meta_from_tags({ - 'title': 't', 'artist': 'a', - 'discnumber': '2/2', - }) - assert out['disc_number'] == 2 - - def test_track_number_default_to_one(self): - out = extract_track_meta_from_tags({ - 'title': 't', 'artist': 'a', - }) - assert out['track_number'] == 1 - assert out['disc_number'] == 1 - - def test_track_number_zero_or_negative_falls_back_to_one(self): - out = extract_track_meta_from_tags({ - 'title': 't', 'artist': 'a', 'tracknumber': '0', - }) - assert out['track_number'] == 1 # or-default of 0 → 1 - - def test_track_number_unparseable_falls_back_to_one(self): - out = extract_track_meta_from_tags({ - 'title': 't', 'artist': 'a', - 'tracknumber': 'side-a-2', - }) - assert out['track_number'] == 1 - - def test_int_track_number(self): - out = extract_track_meta_from_tags({ - 'title': 't', 'artist': 'a', - 'tracknumber': 5, - 'discnumber': 2, - }) - assert out['track_number'] == 5 - assert out['disc_number'] == 2 - - def test_float_track_number_truncated(self): - out = extract_track_meta_from_tags({ - 'title': 't', 'artist': 'a', - 'tracknumber': 5.7, - }) - assert out['track_number'] == 5 - - def test_zero_padded_track_number(self): - out = extract_track_meta_from_tags({ - 'title': 't', 'artist': 'a', - 'tracknumber': '03', - }) - assert out['track_number'] == 3 - - def test_non_dict_input(self): - assert extract_track_meta_from_tags(None) is None - assert extract_track_meta_from_tags([]) is None - assert extract_track_meta_from_tags('') is None - - def test_empty_dict(self): - assert extract_track_meta_from_tags({}) is None - - -# ─── extract_album_meta_from_tags ───────────────────────────────────── - - -class TestExtractAlbumMeta: - def test_full_set(self): - out = extract_album_meta_from_tags({ - 'album': 'DAMN.', - 'albumartist': 'Kendrick Lamar', - 'date': '2017-04-14', - 'totaltracks': '14', - 'releasetype': 'Album', - }) - assert out['name'] == 'DAMN.' - assert out['title'] == 'DAMN.' - assert out['album_artist'] == 'Kendrick Lamar' - assert out['release_date'] == '2017' - assert out['total_tracks'] == 14 - assert out['album_type'] == 'album' - assert out['image_url'] == '' - assert out['id'] == '' - - def test_year_normalization_from_full_date(self): - for date_input, expected_year in [ - ('2020-01-15', '2020'), - ('2020', '2020'), - ('2020-01', '2020'), - ('Jan 5, 2020', '2020'), - ('1999/12/31', '1999'), - ]: - out = extract_album_meta_from_tags({'album': 'a', 'date': date_input}) - assert out['release_date'] == expected_year, f"date={date_input!r}" - - def test_year_falls_back_to_year_field(self): - out = extract_album_meta_from_tags({'album': 'a', 'year': '2018'}) - assert out['release_date'] == '2018' - - def test_year_falls_back_to_originaldate(self): - out = extract_album_meta_from_tags({'album': 'a', 'originaldate': '2010'}) - assert out['release_date'] == '2010' - - def test_year_missing_returns_empty(self): - out = extract_album_meta_from_tags({'album': 'a'}) - assert out['release_date'] == '' - - def test_totaltracks_from_id3_shape(self): - # ID3 may store track_number as "5/12" — use the trailing 12. - out = extract_album_meta_from_tags({ - 'album': 'a', 'tracknumber': '5/12', - }) - assert out['total_tracks'] == 12 - - def test_totaltracks_explicit_field_wins(self): - out = extract_album_meta_from_tags({ - 'album': 'a', 'totaltracks': '14', 'tracknumber': '5/12', - }) - assert out['total_tracks'] == 14 - - def test_totaltracks_tracktotal_alias(self): - out = extract_album_meta_from_tags({'album': 'a', 'tracktotal': '8'}) - assert out['total_tracks'] == 8 - - def test_releasetype_canonical(self): - for input_val, expected in [ - ('album', 'album'), ('Album', 'album'), - ('single', 'single'), ('Single', 'single'), - ('ep', 'ep'), ('EP', 'ep'), - ('compilation', 'compilation'), - ('soundtrack', ''), # not in canonical set - ('mixtape', ''), - ('', ''), - ]: - out = extract_album_meta_from_tags({ - 'album': 'a', 'releasetype': input_val, - }) - assert out['album_type'] == expected, f"releasetype={input_val!r}" - - def test_total_discs_explicit_field(self): - out = extract_album_meta_from_tags({ - 'album': 'a', 'totaldiscs': '2', - }) - assert out['total_discs'] == 2 - - def test_total_discs_from_id3_disc_form(self): - out = extract_album_meta_from_tags({ - 'album': 'a', 'discnumber': '1/2', - }) - assert out['total_discs'] == 2 - - def test_total_discs_explicit_wins_over_disc_form(self): - # When both present, take the larger (defensive against drift). - out = extract_album_meta_from_tags({ - 'album': 'a', 'discnumber': '1/2', 'totaldiscs': '3', - }) - assert out['total_discs'] == 3 - - def test_total_discs_missing_zero(self): - out = extract_album_meta_from_tags({ - 'album': 'a', 'discnumber': '1', - }) - assert out['total_discs'] == 0 # caller defaults via max() with disc count - - def test_album_artist_underscore_alias(self): - out = extract_album_meta_from_tags({ - 'album': 'a', 'album_artist': 'Foo', - }) - assert out['album_artist'] == 'Foo' - - def test_missing_album_returns_empty_name(self): - out = extract_album_meta_from_tags({}) - assert out['name'] == '' - # All other fields should still be present (zero/empty), so the - # caller's downstream consumer doesn't KeyError. - assert 'release_date' in out - assert 'total_tracks' in out - - def test_non_dict_input_safe(self): - out = extract_album_meta_from_tags(None) # type: ignore - assert out['name'] == '' - - -# ─── read_album_track_from_file ─────────────────────────────────────── - - -class TestReadAlbumTrackFromFile: - def test_unavailable_result_returns_reason(self): - def fake_reader(_p): - return {'available': False, 'reason': 'No file.'} - - a, t, err = read_album_track_from_file('fake', read_embedded_tags_fn=fake_reader) - assert a is None and t is None - assert err == 'No file.' - - def test_unavailable_no_reason_falls_back(self): - def fake_reader(_p): - return {'available': False} - - _, _, err = read_album_track_from_file('fake', read_embedded_tags_fn=fake_reader) - assert 'Could not read embedded tags' in (err or '') - - def test_non_dict_result_safe(self): - def fake_reader(_p): - return None - - a, t, err = read_album_track_from_file('fake', read_embedded_tags_fn=fake_reader) - assert a is None and t is None - assert err - - def test_essentials_missing_track_returns_reason(self): - # Title missing → unmatched. - def fake_reader(_p): - return {'available': True, 'tags': {'artist': 'a', 'album': 'b'}} - - a, t, err = read_album_track_from_file('fake', read_embedded_tags_fn=fake_reader) - assert a is None and t is None - assert 'title' in (err or '').lower() - - def test_essentials_missing_album_returns_reason(self): - # Album missing → unmatched even if track meta extracted. - def fake_reader(_p): - return {'available': True, 'tags': {'title': 't', 'artist': 'a'}} - - a, t, err = read_album_track_from_file('fake', read_embedded_tags_fn=fake_reader) - assert a is None and t is None - assert 'album' in (err or '').lower() - - def test_full_extraction(self): - def fake_reader(_p): - return { - 'available': True, - 'duration': 234.5, - 'tags': { - 'title': 'HUMBLE.', - 'artist': 'Kendrick Lamar', - 'album': 'DAMN.', - 'albumartist': 'Kendrick Lamar', - 'tracknumber': '4/14', - 'discnumber': '1/1', - 'date': '2017-04-14', - 'releasetype': 'Album', - }, - } - - album, track, err = read_album_track_from_file( - '/fake.flac', read_embedded_tags_fn=fake_reader, - ) - assert err is None - assert track is not None and album is not None - assert track['name'] == 'HUMBLE.' - assert track['track_number'] == 4 - assert track['disc_number'] == 1 - assert track['duration_ms'] == 234500 - assert album['name'] == 'DAMN.' - assert album['release_date'] == '2017' - assert album['total_tracks'] == 14 - assert album['album_type'] == 'album' - - def test_duration_zero_when_missing(self): - def fake_reader(_p): - return { - 'available': True, - 'tags': { - 'title': 't', 'artist': 'a', 'album': 'b', - }, - } - - _, track, err = read_album_track_from_file('fake', read_embedded_tags_fn=fake_reader) - assert err is None - assert track['duration_ms'] == 0 - - def test_duration_unparseable_zero(self): - def fake_reader(_p): - return { - 'available': True, - 'duration': 'banana', - 'tags': {'title': 't', 'artist': 'a', 'album': 'b'}, - } - - _, track, err = read_album_track_from_file('fake', read_embedded_tags_fn=fake_reader) - assert err is None - assert track['duration_ms'] == 0 - - def test_empty_path(self): - a, t, err = read_album_track_from_file('') - assert a is None and t is None - assert err - - def test_non_string_path(self): - a, t, err = read_album_track_from_file(None) # type: ignore - assert a is None and t is None - assert err - - -# ─── normalize_resolved_path ────────────────────────────────────────── - - -class TestNormalizeResolvedPath: - def test_returns_path_when_exists(self, tmp_path): - f = tmp_path / 'x.flac' - f.write_bytes(b'') - assert normalize_resolved_path(str(f)) == str(f) - - def test_none_when_missing(self, tmp_path): - assert normalize_resolved_path(str(tmp_path / 'no.flac')) is None - - def test_empty_input_safe(self): - assert normalize_resolved_path('') is None - assert normalize_resolved_path(None) is None - - -# ─── plan_album_reorganize (tag-mode integration) ──────────────────── -# -# Pin the wiring between the planner branch and the tag-source helper -# so the additive-and-optional contract holds: API mode unchanged, -# tag mode produces matched plan items shaped like API mode (so -# downstream post-process treats them identically). - - -def _stub_metadata_service(monkeypatch): - """Inject a minimal `core.metadata_service` so `library_reorganize` - imports cleanly even in the test process where the real metadata - clients aren't wired.""" - if 'core' not in sys.modules: - sys.modules['core'] = types.ModuleType('core') - if 'core.metadata_service' in sys.modules: - return - fake = types.ModuleType('core.metadata_service') - fake.get_album_for_source = lambda *a, **k: {} - fake.get_album_tracks_for_source = lambda *a, **k: [] - fake.get_client_for_source = lambda *a, **k: None - fake.get_primary_source = lambda: 'deezer' - fake.get_source_priority = lambda primary=None: ['deezer', 'spotify', 'itunes'] - sys.modules['core.metadata_service'] = fake - - -class TestPlannerTagModeIntegration: - def test_tag_mode_planner_matches_every_track_with_good_tags(self, monkeypatch): - _stub_metadata_service(monkeypatch) - from core import library_reorganize as lr - - per_path = { - '/a/track1.flac': { - 'available': True, 'duration': 200, - 'tags': { - 'title': 'Song A', 'artist': 'Foo', 'album': 'AlbumX', - 'tracknumber': '1/3', 'discnumber': '1/1', - 'date': '2020', 'releasetype': 'Album', - }, - }, - '/a/track2.flac': { - 'available': True, 'duration': 230, - 'tags': { - 'title': 'Song B', 'artist': 'Foo', 'album': 'AlbumX', - 'tracknumber': '2/3', 'discnumber': '1/1', - 'date': '2020', 'releasetype': 'Album', - }, - }, - } - monkeypatch.setattr( - 'core.library.file_tags.read_embedded_tags', - lambda p: per_path.get(p, {'available': False, 'reason': 'missing'}), - ) - plan = lr.plan_album_reorganize( - album_data={'artist_name': 'Foo', 'title': 'AlbumX'}, - tracks=[ - {'id': 't1', 'title': 'Song A', 'track_number': 1, 'file_path': '/a/track1.flac'}, - {'id': 't2', 'title': 'Song B', 'track_number': 2, 'file_path': '/a/track2.flac'}, - ], - metadata_source='tags', - resolve_file_path_fn=lambda p: p, - ) - assert plan['status'] == 'planned' - assert plan['source'] == 'tags' - assert plan['total_discs'] == 1 - assert len(plan['items']) == 2 - for it in plan['items']: - assert it['matched'] is True - assert it['api_track']['name'] in ('Song A', 'Song B') - assert it['api_album']['name'] == 'AlbumX' - assert it['api_album']['album_type'] == 'album' - - def test_tag_mode_partial_disc_uses_tagged_total_discs(self, monkeypatch): - # User has only disc 2 of a 2-disc album; tags say so. - # max_disc must reflect tagged total so path builder still - # routes into the multi-disc subfolder. - _stub_metadata_service(monkeypatch) - from core import library_reorganize as lr - - monkeypatch.setattr( - 'core.library.file_tags.read_embedded_tags', - lambda p: { - 'available': True, - 'tags': { - 'title': 'Song A', 'artist': 'Foo', 'album': 'Y', - 'tracknumber': '1/8', 'discnumber': '2/2', - 'totaldiscs': '2', - }, - }, - ) - plan = lr.plan_album_reorganize( - album_data={'artist_name': 'Foo', 'title': 'Y'}, - tracks=[{'id': 't1', 'title': 'Song A', 'track_number': 1, 'file_path': '/a.flac'}], - metadata_source='tags', - resolve_file_path_fn=lambda p: p, - ) - assert plan['status'] == 'planned' - assert plan['total_discs'] == 2 - - def test_tag_mode_file_missing_unmatched_with_reason(self, monkeypatch): - _stub_metadata_service(monkeypatch) - from core import library_reorganize as lr - - plan = lr.plan_album_reorganize( - album_data={'artist_name': 'Foo', 'title': 'X'}, - tracks=[{'id': 't1', 'title': 'Song', 'track_number': 1, 'file_path': '/missing.flac'}], - metadata_source='tags', - resolve_file_path_fn=lambda p: None, # always missing - ) - # All tracks unmatched → no_source_id status, source='tags'. - assert plan['status'] == 'no_source_id' - assert plan['source'] == 'tags' - assert plan['items'][0]['matched'] is False - assert 'no longer exists' in plan['items'][0]['reason'].lower() - - def test_tag_mode_some_match_some_unreadable(self, monkeypatch): - _stub_metadata_service(monkeypatch) - from core import library_reorganize as lr - - per_path = { - '/good.flac': { - 'available': True, - 'tags': {'title': 'Good', 'artist': 'A', 'album': 'X', 'tracknumber': '1/2'}, - }, - '/bad.flac': {'available': False, 'reason': 'unreadable'}, - } - monkeypatch.setattr( - 'core.library.file_tags.read_embedded_tags', - lambda p: per_path.get(p, {'available': False, 'reason': 'missing'}), - ) - plan = lr.plan_album_reorganize( - album_data={'artist_name': 'A', 'title': 'X'}, - tracks=[ - {'id': 'g', 'title': 'Good', 'track_number': 1, 'file_path': '/good.flac'}, - {'id': 'b', 'title': 'Bad', 'track_number': 2, 'file_path': '/bad.flac'}, - ], - metadata_source='tags', - resolve_file_path_fn=lambda p: p, - ) - assert plan['status'] == 'planned' - assert plan['source'] == 'tags' - matched = [it for it in plan['items'] if it['matched']] - unmatched = [it for it in plan['items'] if not it['matched']] - assert len(matched) == 1 - assert len(unmatched) == 1 - assert unmatched[0]['reason'] == 'unreadable' - - def test_tag_mode_without_resolver_returns_no_source_id(self, monkeypatch): - # Defensive: caller forgot to pass resolve_file_path_fn. - _stub_metadata_service(monkeypatch) - from core import library_reorganize as lr - - plan = lr.plan_album_reorganize( - album_data={'artist_name': 'A', 'title': 'X'}, - tracks=[{'id': 't1', 'title': 'Song', 'track_number': 1, 'file_path': '/a.flac'}], - metadata_source='tags', - resolve_file_path_fn=None, - ) - assert plan['status'] == 'no_source_id' - assert plan['items'][0]['matched'] is False - assert 'requires the file path resolver' in plan['items'][0]['reason'] - - def test_api_mode_unchanged_default(self, monkeypatch): - # Regression guard: omitting metadata_source preserves the API - # path — calls _resolve_source which calls our stubbed - # metadata_service. Should land in 'no_source_id' since stubs - # return empty. - _stub_metadata_service(monkeypatch) - from core import library_reorganize as lr - - plan = lr.plan_album_reorganize( - album_data={'artist_name': 'Foo', 'title': 'Bar'}, - tracks=[{'id': 't1', 'title': 'Song', 'track_number': 1, 'file_path': '/a.flac'}], - ) - # No metadata_source param → defaults to 'api' → empty stubs - # produce no_source_id. - assert plan['status'] == 'no_source_id' - assert plan['source'] is None # never reached the tags branch diff --git a/tests/test_reorganize_unknown_artist_hint.py b/tests/test_reorganize_unknown_artist_hint.py deleted file mode 100644 index 5f745e0a0..000000000 --- a/tests/test_reorganize_unknown_artist_hint.py +++ /dev/null @@ -1,139 +0,0 @@ -"""Pin the unresolvable-reason hint in library_reorganize. - -Discord report (Foxxify) — Phase B: stuck "Unknown Artist / " -folders left over from the pre-#524 manual-import bug. Reorganize -couldn't move them (no usable metadata source ID) and emitted a -generic "run enrichment first" message — but enrichment can't fix -these rows. The right tool is the existing Unknown Artist Fixer -repair job (reads file tags, re-resolves metadata, re-tags + moves -file). These tests pin the detection helpers + reason text so the -hint stays correct as the file evolves. -""" - -from __future__ import annotations - -from core.library_reorganize import ( - _is_unknown_artist, - _looks_like_album_id_title, - _unresolvable_reason, -) - - -class TestIsUnknownArtist: - def test_unknown_artist_string(self): - assert _is_unknown_artist("Unknown Artist") is True - - def test_unknown_artist_lowercase(self): - assert _is_unknown_artist("unknown artist") is True - - def test_unknown_artist_with_whitespace(self): - assert _is_unknown_artist(" Unknown Artist ") is True - - def test_unknown_alone(self): - """Some import paths set just 'Unknown' (no 'Artist' suffix).""" - assert _is_unknown_artist("Unknown") is True - - def test_empty_string(self): - assert _is_unknown_artist("") is True - - def test_none(self): - assert _is_unknown_artist(None) is True - - def test_real_artist(self): - assert _is_unknown_artist("Radiohead") is False - - def test_artist_containing_unknown_substring(self): - """Substring 'unknown' shouldn't trigger — only the exact - placeholder names. Real artists can contain that word.""" - assert _is_unknown_artist("Unknown Mortal Orchestra") is False - - -class TestLooksLikeAlbumIdTitle: - def test_long_numeric_string_is_album_id(self): - """Reporter's case: album.title set to the numeric album_id - by the pre-#524 manual-import bug.""" - assert _looks_like_album_id_title("1234567890") is True - - def test_six_digit_minimum(self): - """Edge: 5 digits is too short to be a real album_id pattern - — could just be an album titled '12345'. Cutoff is 6+.""" - assert _looks_like_album_id_title("12345") is False - assert _looks_like_album_id_title("123456") is True - - def test_alphanumeric_is_not_album_id(self): - """Real album titles with numbers (Blink-182, Sum 41, etc.) - must not trigger.""" - assert _looks_like_album_id_title("Sum 41") is False - assert _looks_like_album_id_title("1999") is False # short - - def test_empty_string(self): - assert _looks_like_album_id_title("") is False - - def test_none(self): - assert _looks_like_album_id_title(None) is False - - def test_real_album_title(self): - assert _looks_like_album_id_title("In Rainbows") is False - - def test_whitespace_stripped(self): - """Defensive: leading/trailing whitespace shouldn't fool the - detector.""" - assert _looks_like_album_id_title(" 1234567890 ") is True - - -class TestUnresolvableReason: - def test_unknown_artist_routes_to_fixer_hint(self): - """Reporter's exact case — Unknown Artist row should point - at the Fix Unknown Artists repair job, not generic - enrichment advice.""" - reason = _unresolvable_reason( - {'artist_name': 'Unknown Artist', 'title': 'Some Album'}, - primary_source='deezer', - strict_source=False, - ) - assert "Fix Unknown Artists" in reason - assert "placeholder metadata" in reason - - def test_album_id_title_routes_to_fixer_hint(self): - """Reverse case — album.title is a numeric album_id.""" - reason = _unresolvable_reason( - {'artist_name': 'Real Artist', 'title': '9876543210'}, - primary_source='deezer', - strict_source=False, - ) - assert "Fix Unknown Artists" in reason - - def test_real_album_with_no_source_id_keeps_enrichment_hint(self): - """Sanity: real artist + real title but no source ID still - gets the generic enrichment hint. Don't mis-route normal - no-source-ID albums into the fixer flow.""" - reason = _unresolvable_reason( - {'artist_name': 'Radiohead', 'title': 'In Rainbows'}, - primary_source='deezer', - strict_source=False, - ) - assert "Fix Unknown Artists" not in reason - assert "No metadata source ID" in reason - - def test_strict_source_path_keeps_strict_text(self): - """When strict_source=True and the row is fine (real artist - + real title), the existing strict-source message is - preserved. Hint only fires for the bad-metadata shape.""" - reason = _unresolvable_reason( - {'artist_name': 'Radiohead', 'title': 'In Rainbows'}, - primary_source='spotify', - strict_source=True, - ) - assert "Fix Unknown Artists" not in reason - assert "spotify" in reason.lower() - assert "tracklist" in reason - - def test_strict_source_with_unknown_artist_prefers_fixer_hint(self): - """Bad-metadata shape wins over strict-source — Unknown - Artist always needs the fixer regardless of source mode.""" - reason = _unresolvable_reason( - {'artist_name': 'Unknown Artist', 'title': 'Whatever'}, - primary_source='spotify', - strict_source=True, - ) - assert "Fix Unknown Artists" in reason diff --git a/tests/test_retag_planner.py b/tests/test_retag_planner.py index 2137c425b..8d8fe13ba 100644 --- a/tests/test_retag_planner.py +++ b/tests/test_retag_planner.py @@ -55,7 +55,8 @@ def test_source_track_consumed_once(): def test_overwrite_reports_changed_fields_only(): - current = {'title': 'Old Title', 'album_artist': 'Real Artist', + current = {'title': 'Old Title', 'artist': 'Real Artist', + 'album_artist': 'Real Artist', 'album': 'Real Album', 'year': '2021', 'genre': 'Rock, Indie', 'track_number': 3, 'disc_number': 1} plan = rp.plan_track(current, SRC, ALBUM, mode=rp.MODE_OVERWRITE) @@ -67,13 +68,15 @@ def test_overwrite_reports_changed_fields_only(): assert 'album_title' not in plan['db_data'] -def test_overwrite_writes_album_artist_via_artist_name_key(): - current = {'title': 'Real Title', 'album_artist': 'WRONG Artist', +def test_wrong_album_artist_writes_via_artist_name_key(): + current = {'title': 'Real Title', 'artist': 'Real Artist', + 'album_artist': 'WRONG Artist', 'album': 'Real Album', 'year': '2021', 'genre': 'Rock, Indie', 'track_number': 3, 'disc_number': 1} plan = rp.plan_track(current, SRC, ALBUM, mode=rp.MODE_OVERWRITE) - assert plan['changes']['artist'] == {'old': 'WRONG Artist', 'new': 'Real Artist'} + assert plan['changes']['album_artist'] == {'old': 'WRONG Artist', 'new': 'Real Artist'} assert plan['db_data']['artist_name'] == 'Real Artist' # writer uses artist_name = album artist + assert 'artist' not in plan['changes'] # the track artist was already right def test_track_number_write_carries_track_count(): @@ -85,7 +88,8 @@ def test_track_number_write_carries_track_count(): def test_no_changes_when_everything_matches(): - current = {'title': 'Real Title', 'album_artist': 'Real Artist', 'album': 'Real Album', + current = {'title': 'Real Title', 'artist': 'Real Artist', + 'album_artist': 'Real Artist', 'album': 'Real Album', 'year': '2021', 'genre': 'Rock, Indie', 'track_number': 3, 'disc_number': 1} plan = rp.plan_track(current, SRC, ALBUM, mode=rp.MODE_OVERWRITE) assert plan['changes'] == {} @@ -94,7 +98,8 @@ def test_no_changes_when_everything_matches(): def test_source_blank_field_never_written(): album = {'name': 'Real Album', 'artists': [{'name': 'Real Artist'}]} # no year/genres - current = {'title': 'Real Title', 'album_artist': 'Real Artist', 'album': 'Real Album', + current = {'title': 'Real Title', 'artist': 'Real Artist', + 'album_artist': 'Real Artist', 'album': 'Real Album', 'year': '', 'genre': '', 'track_number': 3, 'disc_number': 1} plan = rp.plan_track(current, SRC, album, mode=rp.MODE_OVERWRITE) assert 'year' not in plan['changes'] and 'year' not in plan['db_data'] @@ -104,11 +109,114 @@ def test_source_blank_field_never_written(): # ── fill-missing mode ── def test_fill_missing_only_writes_blanks(): - current = {'title': 'Keep My Title', 'album_artist': '', 'album': 'Real Album', + current = {'title': 'Keep My Title', 'artist': '', 'album_artist': '', + 'album': 'Real Album', 'year': '', 'genre': 'Rock, Indie', 'track_number': 3, 'disc_number': 1} plan = rp.plan_track(current, SRC, ALBUM, mode=rp.MODE_FILL_MISSING) - # title is present (kept), artist + year are blank (filled). genre present (kept). - assert set(plan['changes']) == {'artist', 'year'} + # title is present (kept), both artist fields + year are blank (filled). + # genre present (kept). + assert set(plan['changes']) == {'artist', 'album_artist', 'year'} assert 'title' not in plan['db_data'] # not overwritten in fill-missing assert plan['db_data']['artist_name'] == 'Real Artist' + assert plan['db_data']['track_artist'] == 'Real Artist' assert plan['db_data']['year'] == '2021' + + +# ── the shared tag engine's guards (core/tag_writer) ── +# +# The planner used to carry its own diff, so every rule `build_tag_diff` and +# `write_tags_to_file` share was invisible to it: it promised changes the +# writer then refused, and the finding came back every scan because +# `_create_finding` refreshes a pending row in place. + +def test_genre_already_containing_the_source_genres_is_not_a_change(): + """A generic source genre must not narrow a richer file tag. + + `write_tags_to_file` keeps the existing value here + (`genre_write_value_is_subset_of_existing`), so reporting a change + produces a finding whose fix can never resolve it. + """ + current = {'title': 'Real Title', 'artist': 'Real Artist', + 'album_artist': 'Real Artist', 'album': 'Real Album', + 'year': '2021', 'genre': 'Rock, Indie, Shoegaze', + 'track_number': 3, 'disc_number': 1} + plan = rp.plan_track(current, SRC, ALBUM, mode=rp.MODE_OVERWRITE) + assert 'genre' not in plan['changes'] + assert 'genres' not in plan['db_data'] + + +def test_wrong_track_artist_is_reported_even_when_album_artist_matches(): + """The ARTIST tag and the ALBUM ARTIST tag are two fields. + + Comparing only against album_artist left a file whose artist tag was + wrong looking perfectly tagged. + """ + current = {'title': 'Real Title', 'artist': 'WRONG Artist', + 'album_artist': 'Real Artist', 'album': 'Real Album', + 'year': '2021', 'genre': 'Rock, Indie', + 'track_number': 3, 'disc_number': 1} + plan = rp.plan_track(current, SRC, ALBUM, mode=rp.MODE_OVERWRITE) + assert plan['changes']['artist'] == {'old': 'WRONG Artist', 'new': 'Real Artist'} + assert plan['db_data']['track_artist'] == 'Real Artist' + assert 'album_artist' not in plan['changes'] + + +def test_placeholder_source_value_is_held_back_not_promised(): + """#800: a compilation's "Various Artists" must not replace a real name. + + The writer refuses it, so the plan reports it as held back rather than as + a pending change. + """ + album = {'name': 'Real Album', 'artists': [{'name': 'Various Artists'}], + 'year': '2021', 'genres': ['Rock', 'Indie'], 'total_tracks': 10} + src = {'name': 'Real Title', 'track_number': 3, 'disc_number': 1} + current = {'title': 'Real Title', 'artist': 'Real Artist', + 'album_artist': 'Real Artist', 'album': 'Real Album', + 'year': '2021', 'genre': 'Rock, Indie', + 'track_number': 3, 'disc_number': 1} + plan = rp.plan_track(current, src, album, mode=rp.MODE_OVERWRITE) + assert 'artist' not in plan['changes'] + assert 'album_artist' not in plan['changes'] + assert 'artist_name' not in plan['db_data'] + assert set(plan['protected']) == {'artist', 'album_artist'} + + +def test_a_more_specific_file_date_is_preserved(): + """#824: the album gives a year, the file carries a full date. + + Overwriting 2021-05-01 with 2021 loses information the source never had. + """ + current = {'title': 'Real Title', 'artist': 'Real Artist', + 'album_artist': 'Real Artist', 'album': 'Real Album', + 'year': '2021-05-01', 'genre': 'Rock, Indie', + 'track_number': 3, 'disc_number': 1} + plan = rp.plan_track(current, SRC, ALBUM, mode=rp.MODE_OVERWRITE) + assert 'year' not in plan['changes'] + assert 'year' not in plan['db_data'] + + +def test_a_wrong_album_artist_does_not_take_the_track_artist_with_it(): + """The ARTIST tag and the ALBUM ARTIST tag are written from two db_data keys, + and `write_tags_to_file` falls back: `track_artist or artist_name`. So a + payload carrying only `artist_name` puts the ALBUM artist into the track's + ARTIST tag as well. + + On a compilation or DJ mix whose per-track artists are correct, a finding + that shows only "Album Artist" would have replaced every one of them. + """ + album = {'name': 'Real Album', 'artists': [{'name': 'DJ Alpha'}], + 'year': '2021', 'genres': ['Rock', 'Indie'], 'total_tracks': 10} + src = {'name': 'Real Title', 'track_number': 3, 'disc_number': 1, + 'artists': [{'name': 'Guest Band'}]} + current = {'title': 'Real Title', 'artist': 'Guest Band', + 'album_artist': 'WRONG Artist', 'album': 'Real Album', + 'year': '2021', 'genre': 'Rock, Indie', + 'track_number': 3, 'disc_number': 1} + + plan = rp.plan_track(current, src, album, mode=rp.MODE_OVERWRITE) + + assert set(plan['changes']) == {'album_artist'} + assert plan['db_data']['artist_name'] == 'DJ Alpha' + # what the writer will actually put in ARTIST: + written_artist = plan['db_data'].get('track_artist') or plan['db_data'].get('artist_name') + assert written_artist == 'Guest Band' diff --git a/web_server.py b/web_server.py index a4b59ce31..ac5e5dab7 100644 --- a/web_server.py +++ b/web_server.py @@ -12422,35 +12422,6 @@ def get_tracks_replaygain_batch_status(): # :mod:`core.library_reorganize`. -@app.route('/api/library/reorganize/sources', methods=['GET']) -def reorganize_sources_global(): - """List metadata sources the user has authed on this instance. - Used by the bulk "Reorganize All" modal where per-album ID coverage - varies. No network calls.""" - try: - from core.library_reorganize import authed_sources - return jsonify({"success": True, "sources": authed_sources()}) - except Exception as e: - logger.error(f"Reorganize sources (global) error: {e}") - return jsonify({"success": False, "error": str(e)}), 500 - - -@app.route('/api/library/album//reorganize/sources', methods=['GET']) -def reorganize_album_sources(album_id): - """List metadata sources the user can pick for this album's - reorganize — every entry has both a stored album ID on the local - row AND an authenticated client. No network calls.""" - try: - from core.library_reorganize import available_sources_for_album, load_album_and_tracks - album_data, _tracks = load_album_and_tracks(get_database(), album_id) - if album_data is None: - return jsonify({"success": False, "error": "Album not found"}), 404 - return jsonify({"success": True, "sources": available_sources_for_album(album_data)}) - except Exception as e: - logger.error(f"Reorganize sources error: {e}") - return jsonify({"success": False, "error": str(e)}), 500 - - @app.route('/api/library/album//reorganize/preview', methods=['POST']) def reorganize_album_preview(album_id): """Preview file reorganization for an album — returns current vs @@ -12459,20 +12430,12 @@ def reorganize_album_preview(album_id): the apply endpoint, so the preview is guaranteed to match what apply would actually produce. - 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).""" + No body params. The plan is computed from the library's own rows, so + there is no metadata source to pick and no live call to make: an album + with no stored source id previews like any other, and the preview is + offline.""" 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 = config_root_path( config_manager.get('soulseek.transfer_path', './Transfer'), './Transfer') result = preview_album_reorganize( @@ -12481,9 +12444,6 @@ def reorganize_album_preview(album_id): transfer_dir=transfer_dir, resolve_file_path_fn=_resolve_library_file_path, 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 @@ -12502,25 +12462,13 @@ def reorganize_album_files(album_id): that's already queued or running are deduped (returns ``{queued: false, reason: 'already_queued'}``). - Body params: - 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'. + No body params. A reorganize moves the album's files to the paths the + current template dictates, computed from the library's own rows. There is + no source to pick (nothing is fetched) and no mode to choose: #875's + "rename only" is the whole behaviour now. """ 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. @@ -12533,11 +12481,6 @@ def reorganize_album_files(album_id): album_title=meta['album_title'], artist_id=meta['artist_id'], artist_name=meta['artist_name'], - source=chosen_source, - metadata_source=metadata_source, - # Rename-only (#875): just move files to the current naming scheme — skip - # the copy + post-processing (re-tag / quality / AcoustID) of the full flow. - rename_only=bool(data.get('rename_only')), ) return jsonify({"success": True, **result}) except Exception as e: @@ -12551,33 +12494,15 @@ def reorganize_all_artist_albums(artist_id): bulk-loop. Each album becomes its own queue item, processed FIFO. Albums already queued or running are deduped silently. - Body params: - 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. + No body params — see 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 + mode to every album, then - # hand off to the queue's bulk-enqueue helper which owns the - # loop+tally. - for album in albums: - album['source'] = chosen_source - album['metadata_source'] = metadata_source result = get_queue().enqueue_many(albums) return jsonify({ diff --git a/webui/src/routes/artist-detail/-artist-detail.reorganize.test.ts b/webui/src/routes/artist-detail/-artist-detail.reorganize.test.ts index f4e83dbf6..63a73dfc4 100644 --- a/webui/src/routes/artist-detail/-artist-detail.reorganize.test.ts +++ b/webui/src/routes/artist-detail/-artist-detail.reorganize.test.ts @@ -7,14 +7,12 @@ import { formatReorganizeResultMessage, queueReorganizeAllRequest, queueReorganizeRequest, - readReorganizeMode, refreshReorganizeQueue, reorganizeStateForAlbum, reorgDisplayLabel, startReorganizeQueuePolling, stopReorganizeQueuePolling, summarizeReorganizePreview, - writeReorganizeMode, } from './-artist-detail.reorganize'; /** @@ -27,7 +25,6 @@ afterEach(() => { _resetReorganizePolling(); vi.unstubAllGlobals(); vi.useRealTimers(); - localStorage.removeItem('soulsync-reorganize-mode'); }); describe('preview row classification', () => { @@ -45,7 +42,7 @@ describe('preview row classification', () => { }); // Unmatched: reason with a default. expect(classifyPreviewTrack({ file_exists: true, matched: false })).toMatchObject({ - newCell: { kind: 'reason', text: "Not in selected source's tracklist" }, + newCell: { kind: 'reason', text: 'The library cannot name this track' }, }); // Matched but no path computed. expect(classifyPreviewTrack({ file_exists: true, matched: true })).toMatchObject({ @@ -67,14 +64,14 @@ describe('preview row classification', () => { { file_exists: true, new_path: '/a' }, // will move { file_exists: true, unchanged: true, new_path: '/b' }, // unchanged { file_exists: false }, // missing on disk - { file_exists: true, matched: false }, // not in source + { file_exists: true, matched: false }, // the library cannot name it { file_exists: true, matched: true }, // no destination ]; const summary = summarizeReorganizePreview(tracks); expect(summary.chips.map((c) => c.text)).toEqual([ '1 will move', '1 unchanged', - '1 not in source — try a different source', + '1 the library cannot name', "1 couldn't compute destination", '1 missing on disk', ]); @@ -103,11 +100,11 @@ describe('outcome classification (#377)', () => { }); it('formats each skip status and the moved/skipped/failed line', () => { - expect(formatReorganizeResultMessage({ result_status: 'no_source_id' })).toBe( - 'Reorganize skipped — album has no metadata source ID. Run enrichment first.', + expect(formatReorganizeResultMessage({ result_status: 'no_album' })).toBe( + 'Reorganize skipped — album not found in DB.', ); expect(formatReorganizeResultMessage({ result_status: 'setup_failed' })).toBe( - "Reorganize failed — couldn't create staging directory.", + "Reorganize failed — couldn't compute destinations.", ); expect( formatReorganizeResultMessage({ @@ -121,14 +118,6 @@ describe('outcome classification (#377)', () => { }); }); -describe('mode persistence', () => { - it('defaults to api and round-trips through localStorage', () => { - expect(readReorganizeMode()).toBe('api'); - writeReorganizeMode('tags'); - expect(readReorganizeMode()).toBe('tags'); - }); -}); - describe('queue requests', () => { function stubFetch(body: unknown) { const spy = vi.fn( @@ -140,42 +129,33 @@ describe('queue requests', () => { it('single album: queued with position, already queued, and the fallback', async () => { const spy = stubFetch({ success: true, queued: true, position: 3 }); - expect( - await queueReorganizeRequest(7, 'SAW 85-92', { source: '', mode: 'api', renameOnly: true }), - ).toBe('Queued: SAW 85-92 (#3 in queue)'); - expect(JSON.parse(String(spy.mock.calls[0]?.[1]?.body))).toEqual({ - source: '', - mode: 'api', - rename_only: true, - }); + expect(await queueReorganizeRequest(7, 'SAW 85-92')).toBe('Queued: SAW 85-92 (#3 in queue)'); + // No body at all: there is no source to pick and no mode to choose. + expect(spy.mock.calls[0]?.[1]?.body).toBeUndefined(); stubFetch({ success: true, queued: true, position: 1 }); - expect( - await queueReorganizeRequest(7, 'SAW 85-92', { source: '', mode: 'api', renameOnly: false }), - ).toBe('Queued: SAW 85-92'); + expect(await queueReorganizeRequest(7, 'SAW 85-92')).toBe('Queued: SAW 85-92'); stubFetch({ success: true, reason: 'already_queued' }); - expect( - await queueReorganizeRequest(7, 'SAW 85-92', { source: '', mode: 'api', renameOnly: false }), - ).toBe('Already queued: SAW 85-92'); + expect(await queueReorganizeRequest(7, 'SAW 85-92')).toBe('Already queued: SAW 85-92'); }); it('reorganize-all: the four toast combos', async () => { stubFetch({ success: true, enqueued: 3, already_queued: 2 }); - expect(await queueReorganizeAllRequest(42, 'Aphex Twin', { source: '', mode: 'api' })).toEqual({ + expect(await queueReorganizeAllRequest(42, 'Aphex Twin')).toEqual({ message: 'Queued 3 albums; 2 already in queue', tone: 'info', }); stubFetch({ success: true, enqueued: 1 }); - expect( - (await queueReorganizeAllRequest(42, 'Aphex Twin', { source: '', mode: 'api' })).message, - ).toBe('Queued 1 album for Aphex Twin'); + expect((await queueReorganizeAllRequest(42, 'Aphex Twin')).message).toBe( + 'Queued 1 album for Aphex Twin', + ); stubFetch({ success: true, already_queued: 4 }); - expect( - (await queueReorganizeAllRequest(42, 'Aphex Twin', { source: '', mode: 'api' })).message, - ).toBe('All 4 albums already in queue'); + expect((await queueReorganizeAllRequest(42, 'Aphex Twin')).message).toBe( + 'All 4 albums already in queue', + ); stubFetch({ success: true }); - expect(await queueReorganizeAllRequest(42, 'Aphex Twin', { source: '', mode: 'api' })).toEqual({ + expect(await queueReorganizeAllRequest(42, 'Aphex Twin')).toEqual({ message: 'No albums to queue', tone: 'warning', }); diff --git a/webui/src/routes/artist-detail/-artist-detail.reorganize.ts b/webui/src/routes/artist-detail/-artist-detail.reorganize.ts index ac75fea2d..7c1ca7326 100644 --- a/webui/src/routes/artist-detail/-artist-detail.reorganize.ts +++ b/webui/src/routes/artist-detail/-artist-detail.reorganize.ts @@ -57,7 +57,7 @@ export function classifyPreviewTrack(t: ReorganizePreviewTrack): PreviewRowView const newCell: PreviewRowView['newCell'] = noFile ? { kind: 'none', text: '', collision: false } : unmatched - ? { kind: 'reason', text: t.reason || "Not in selected source's tracklist", collision: false } + ? { kind: 'reason', text: t.reason || 'The library cannot name this track', collision: false } : missingPath ? { kind: 'reason', @@ -94,7 +94,7 @@ export function summarizeReorganizePreview(tracks: ReorganizePreviewTrack[]): Pr if (unmatched > 0) { chips.push({ className: 'missing', - text: `${unmatched} not in source — try a different source`, + text: `${unmatched} the library cannot name`, }); } if (noPath > 0) @@ -136,12 +136,9 @@ export function formatReorganizeResultMessage(state: { errors?: { error?: string }[]; }): string { const status = state.result_status; - if (status === 'no_source_id') { - return 'Reorganize skipped — album has no metadata source ID. Run enrichment first.'; - } if (status === 'no_album') return 'Reorganize skipped — album not found in DB.'; if (status === 'no_tracks') return 'Reorganize skipped — album has no tracks.'; - if (status === 'setup_failed') return "Reorganize failed — couldn't create staging directory."; + if (status === 'setup_failed') return "Reorganize failed — couldn't compute destinations."; if (status === 'error') return 'Reorganize failed — see server logs for details.'; let msg = `Reorganized: ${state.moved || 0} moved`; if ((state.skipped || 0) > 0) msg += `, ${state.skipped} skipped`; @@ -152,56 +149,19 @@ export function formatReorganizeResultMessage(state: { return msg; } -// ---- Mode persistence (#592) ---- - -const MODE_KEY = 'soulsync-reorganize-mode'; - -export function readReorganizeMode(): string { - try { - return localStorage.getItem(MODE_KEY) || 'api'; - } catch { - return 'api'; - } -} - -export function writeReorganizeMode(mode: string): void { - try { - localStorage.setItem(MODE_KEY, mode); - } catch { - /* localStorage unavailable, ignore */ - } -} - // ---- Requests ---- - -export interface ReorganizeSource { - source: string; - label?: string; -} - -export async function fetchAlbumReorganizeSources(albumId: unknown): Promise { - const response = await fetch(`/api/library/album/${albumId}/reorganize/sources`); - if (!response.ok) return []; - const data = await response.json(); - return data.sources || []; -} - -export async function fetchGlobalReorganizeSources(): Promise { - const response = await fetch('/api/library/reorganize/sources'); - if (!response.ok) return []; - const data = await response.json(); - return data.sources || []; -} +// +// No source and no mode. The plan is computed from the library's own rows, so +// there is nothing to pick: the preview is offline, an album with no stored +// source id previews like any other, and #592's "read the file tags instead" +// and #875's "rename only" both describe what a reorganize now always does. export async function fetchReorganizePreview( albumId: unknown, - source: string, - mode: string, ): Promise<{ tracks: ReorganizePreviewTrack[]; error?: string }> { const response = await fetch(`/api/library/album/${albumId}/reorganize/preview`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ source, mode }), }); const result = await response.json(); if (!result.success) return { tracks: [], error: result.error || 'Preview failed' }; @@ -212,16 +172,10 @@ export async function fetchReorganizePreview( export async function queueReorganizeRequest( albumId: unknown, albumTitle: string, - options: { source: string; mode: string; renameOnly: boolean }, ): Promise { const response = await fetch(`/api/library/album/${albumId}/reorganize`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - source: options.source, - mode: options.mode, - rename_only: options.renameOnly, - }), }); const result = await response.json(); if (!result.success) throw new Error(result.error); @@ -238,12 +192,10 @@ export async function queueReorganizeRequest( export async function queueReorganizeAllRequest( artistId: unknown, artistName: string, - options: { source: string; mode: string }, ): Promise<{ message: string; tone: 'info' | 'warning' }> { const response = await fetch(`/api/library/artist/${artistId}/reorganize-all`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ source: options.source, mode: options.mode }), }); const result = await response.json(); if (!result.success) throw new Error(result.error || 'Queue request failed'); diff --git a/webui/src/routes/artist-detail/-ui/expanded-album-header.test.tsx b/webui/src/routes/artist-detail/-ui/expanded-album-header.test.tsx index a4a9bcf0a..1e13a43d3 100644 --- a/webui/src/routes/artist-detail/-ui/expanded-album-header.test.tsx +++ b/webui/src/routes/artist-detail/-ui/expanded-album-header.test.tsx @@ -232,17 +232,17 @@ describe('admin actions', () => { delete window.showToast; // Reorganize is local now (showReorganizeModal's port): the button - // mounts the modal, which loads this album's metadata sources. + // mounts the modal, which fetches nothing — the plan comes from the + // library's own rows. const fetchSpy = vi.fn( - async (_i: RequestInfo | URL, _init?: RequestInit) => - new Response(JSON.stringify({ sources: [] })), + async (_i: RequestInfo | URL, _init?: RequestInit) => new Response(JSON.stringify({})), ); vi.stubGlobal('fetch', fetchSpy); fireEvent.click(document.querySelector('.enhanced-reorganize-album-btn') as HTMLElement); expect(document.getElementById('reorganize-modal-title')?.textContent).toBe( 'Reorganize: SAW 85-92', ); - expect(String(fetchSpy.mock.calls[0]?.[0])).toBe('/api/library/album/7/reorganize/sources'); + expect(fetchSpy).not.toHaveBeenCalled(); vi.unstubAllGlobals(); // Delete now opens the LOCAL two-option dialog (deleteLibraryAlbum's port). diff --git a/webui/src/routes/artist-detail/-ui/reorganize-modal.tsx b/webui/src/routes/artist-detail/-ui/reorganize-modal.tsx index e1f41ec96..295f13a35 100644 --- a/webui/src/routes/artist-detail/-ui/reorganize-modal.tsx +++ b/webui/src/routes/artist-detail/-ui/reorganize-modal.tsx @@ -1,19 +1,15 @@ -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import type { EnhancedAlbum } from '../-artist-detail.enhanced'; -import type { ReorganizePreviewTrack, ReorganizeSource } from '../-artist-detail.reorganize'; +import type { ReorganizePreviewTrack } from '../-artist-detail.reorganize'; import { classifyPreviewTrack, - fetchAlbumReorganizeSources, - fetchGlobalReorganizeSources, fetchReorganizePreview, queueReorganizeAllRequest, queueReorganizeRequest, - readReorganizeMode, refreshReorganizeQueue, summarizeReorganizePreview, - writeReorganizeMode, } from '../-artist-detail.reorganize'; /** @@ -21,79 +17,13 @@ import { * _showReorganizeAllModal 6174). Queue model: apply enqueues and closes; * progress arrives through the Reorganize Status panel, never a locked button. * - * The metadata-mode pick (#592 — "tags" reads embedded file tags, zero API - * calls) persists in localStorage and hides the source picker, since tags are - * read straight off the file. + * There is nothing to configure. A reorganize moves the album's files to the + * paths the current template dictates, computed from the library's own rows — + * so there is no metadata source to pick (#592's "read the tags instead" and + * #875's "rename only" are both just what it does now), and the preview needs + * no network call. */ -const MODE_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.'; - -function ModeSection({ mode, onChange }: { mode: string; onChange: (mode: string) => void }) { - return ( -
- -
{MODE_HINT}
- -
- ); -} - -function SourceSection({ - label, - hint, - visible, - sources, - emptyMessage, - source, - onChange, -}: { - label: string; - hint: string; - /** Hidden when mode = 'tags' — the picker is irrelevant there. */ - visible: boolean; - sources: ReorganizeSource[]; - emptyMessage: string | null; - source: string; - onChange: (source: string) => void; -}) { - return ( -
- -
{hint}
- -
- ); -} - function ModalShell({ title, onClose, @@ -139,10 +69,6 @@ function ModalShell({ } export function ReorganizeModal({ album, onClose }: { album: EnhancedAlbum; onClose: () => void }) { - const [mode, setMode] = useState(() => readReorganizeMode()); - const [source, setSource] = useState(''); - const [sources, setSources] = useState(null); - const [action, setAction] = useState('full'); const [preview, setPreview] = useState<{ loading?: boolean; error?: string; @@ -150,27 +76,10 @@ export function ReorganizeModal({ album, onClose }: { album: EnhancedAlbum; onCl } | null>(null); const [applying, setApplying] = useState(false); - useEffect(() => { - let cancelled = false; - fetchAlbumReorganizeSources(album.id) - .then((list) => { - if (!cancelled) setSources(list); - }) - .catch((error: unknown) => { - console.error('Failed to load reorganize sources:', error); - if (!cancelled) setSources([]); - }); - return () => { - cancelled = true; - }; - // The album never changes for a mounted modal. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - const loadPreview = async () => { setPreview({ loading: true }); try { - const result = await fetchReorganizePreview(album.id, source, mode); + const result = await fetchReorganizePreview(album.id); if (result.error) setPreview({ error: result.error }); else setPreview({ tracks: result.tracks }); } catch (error) { @@ -184,11 +93,7 @@ export function ReorganizeModal({ album, onClose }: { album: EnhancedAlbum; onCl const apply = async () => { setApplying(true); try { - const message = await queueReorganizeRequest(album.id, String(album.title || 'album'), { - source, - mode, - renameOnly: action === 'rename', - }); + const message = await queueReorganizeRequest(album.id, String(album.title || 'album')); onClose(); window.showToast?.(message, 'info'); // Wake the status panel so the new item lands immediately rather than @@ -217,37 +122,13 @@ export function ReorganizeModal({ album, onClose }: { album: EnhancedAlbum; onCl } >
- - -
-
- "Full reorganize" re-tags and re-checks every track through the import pipeline — - thorough, but slow and it re-touches every file. "Rename only" just moves files to your - current naming scheme: no re-tagging, no quality/AcoustID checks, and only files whose - name actually changes are touched. Tip: renaming can reset play counts / date-added on - your media server. + Moves this album's files to the paths your current naming scheme dictates, using the + titles the library holds. Tags and audio are left byte-for-byte alone, and only files + whose path actually changes are touched. Tip: renaming can reset play counts / + date-added on your media server.
-
@@ -343,22 +224,6 @@ export function ReorganizeAllModal({ artistName: string; onClose: () => void; }) { - const [mode, setMode] = useState(() => readReorganizeMode()); - const [source, setSource] = useState(''); - const [sources, setSources] = useState([]); - - useEffect(() => { - let cancelled = false; - fetchGlobalReorganizeSources() - .then((list) => { - if (!cancelled) setSources(list); - }) - .catch((error: unknown) => console.error('Failed to load reorganize sources:', error)); - return () => { - cancelled = true; - }; - }, []); - const queueAll = async () => { const total = albums.length; const confirmed = await window.showConfirmDialog?.({ @@ -370,10 +235,7 @@ export function ReorganizeAllModal({ if (!confirmed) return; onClose(); try { - const { message, tone } = await queueReorganizeAllRequest(artistId, artistName, { - source, - mode, - }); + const { message, tone } = await queueReorganizeAllRequest(artistId, artistName); window.showToast?.(message, tone); void refreshReorganizeQueue(); } catch (error) { @@ -397,16 +259,12 @@ export function ReorganizeAllModal({ } >
- - +
+
+ Moves every album's files to the paths your current naming scheme dictates, using the + titles the library holds. Tags and audio are left alone. +
+