soulsync/tests/test_library_reorganize_orchestrator.py
BoulderBadgeDad 3b49ac8280 Fix #767: Library Organizer dry run no longer creates folders
The reorganize preview (dry run) was physically creating destination album
folders, littering the library with empty dirs and making "changes" before the
user ever hit Apply.

Cause: preview_album_reorganize calls build_final_path_for_track purely to
COMPUTE the destination path string — but that shared helper has 9 os.makedirs
side effects (it's also the live download/import path builder, where creating
the dir is correct). So computing the preview path created "Lenka (Expanded
Edition)/" on disk.

Fix: build_final_path_for_track gains create_dirs=True; all 9 makedirs now route
through a gated helper. The reorganize PREVIEW passes create_dirs=False, so a
dry run computes the exact destination path with zero filesystem side effects.
Everything else keeps the default True:
- the download/import post-process flow (still writes files into the dir),
- retag,
- the reorganize APPLY path — verified it goes through post_process_fn (the real
  pipeline → build_final_path_for_track with create_dirs=True), so live moves
  still create their destination dirs. The gate only silences the dry run.

Tests: tests/imports/test_import_paths.py — create_dirs=False computes the
correct path (matching the reported "01 - The Show.flac") but writes NOTHING to
disk (not even the Transfer root); create_dirs=True still creates folders; both
yield an identical path. Updated two reorganize-orchestrator test doubles to
accept the new kwarg. 148 reorganize/paths/retag/pipeline tests pass.

Does NOT fix the second half of #767 (Expanded Edition picked over the standard
album). That is NOT a reorganizer bug: the library album row was linked to the
deluxe release at enrichment time (its stored spotify_album_id/itunes_album_id/
deezer_id points at "Lenka (Expanded Edition)"), and the reorganizer faithfully
reorganizes to whatever the album is linked to. The real fix is in album
enrichment's edition preference — tracked separately.
2026-06-02 10:32:06 -07:00

2034 lines
82 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 "config.settings" not in sys.modules:
config_pkg = types.ModuleType("config")
settings_mod = types.ModuleType("config.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["config.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': 'Dont 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_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 <transfer>/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
# <transfer>/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()