soulsync/tests/downloads/test_downloads_lifecycle.py
Broque Thomas 5771c5ba77 Album-bundle staging: clean Soulseek copies + sweep orphans at startup
Two related leaks in ``storage/album_bundle_staging/<batch_id>/``:

1. **Soulseek bundle cleanup was excluded.** The per-batch cleanup
   at the end of a bundle download gated on:
       (album_bundle_source or '').lower() in ('torrent', 'usenet')
   The comment justified it as "slskd keeps its own completed
   folders" — but the Soulseek bundle path ALSO copies completed
   files into the private staging dir (``soulseek_client.py:1599``,
   ``copy_audio_files_atomically(completed, Path(staging_dir))``)
   for the per-track workers to claim. Those copies persisted
   forever; long-running installs accumulated stale GB. Extended
   the cleanup gate's allow-list to include ``soulseek`` so the
   per-batch dir is removed on bundle completion — same code path
   that already worked for torrent / usenet.

2. **No sweep for orphan dirs.** Any leftover ``<batch_id>``
   subdir from a previous-session crash, an errored batch, or a
   pre-fix Soulseek bundle stayed on disk forever. Added
   ``sweep_orphan_album_bundle_staging(staging_root, active_batch_ids)``
   that runs ONCE at server startup, before any batch can register
   a staging dir. Removes every ``<batch_id>``-shaped subdir
   whose id isn't in the active set. Safe by construction:
     - Only touches subdirs of the configured staging root.
     - Name-shape check (``entry.name == _safe_batch_dirname(entry.name)``)
       rejects hand-placed dirs like ``.git`` or stray docs.
     - ``shutil.rmtree`` errors log + continue — sweep must not
       crash app startup over a permission glitch.
     - active_batch_ids normalised through ``_safe_batch_dirname``
       so colon-bearing batch_ids match their on-disk form.
   Wired into the web_server startup right after the stuck-flags
   diagnostic so it fires before anything else touches batches.

Tests
- ``test_downloads_lifecycle.py`` gained one regression test
  pinning that Soulseek bundles now have their staging dir
  cleaned (sibling to the existing torrent test).
- ``test_album_bundle_staging_sweep.py`` (NEW, 11 tests)
  covers: orphan removal with no actives, active dirs preserved,
  special-char batch_id normalisation, no-op on missing /empty
  /empty-string staging root, non-dir entries skipped, unsafe-
  name dirs preserved (.git etc.), partial rmtree failure doesn't
  abort the rest, listdir failure returns 0 cleanly, default
  None active set, defensive against empty / None entries in
  the active set.

488 downloads tests pass.

For users with an existing "clean up old files" automation pointed
at this dir: stop pointing it there if you want — the auto-cleanup
+ startup sweep cover it now. Or leave it as belt-and-suspenders
with a relaxed (1h+) mtime threshold so it can't race a mid-batch
download.
2026-05-27 22:18:42 -07:00

580 lines
21 KiB
Python

"""Tests for core/downloads/lifecycle.py — batch lifecycle (start, complete, check)."""
from __future__ import annotations
import threading
import pytest
from core.downloads import lifecycle as lc
from core.runtime_state import (
download_batches,
download_tasks,
)
@pytest.fixture(autouse=True)
def reset_state():
download_tasks.clear()
download_batches.clear()
yield
download_tasks.clear()
download_batches.clear()
# ---------------------------------------------------------------------------
# Fakes
# ---------------------------------------------------------------------------
class _Recorder:
def __init__(self):
self.calls = []
def __call__(self, name):
def _inner(*args, **kwargs):
self.calls.append((name, args, kwargs))
return None
return _inner
class _FakeAutoEngine:
def __init__(self):
self.events = []
def emit(self, event_type, data):
self.events.append((event_type, data))
class _FakeMonitor:
def __init__(self):
self.stopped = []
def stop_monitoring(self, batch_id):
self.stopped.append(batch_id)
class _FakeRepair:
def __init__(self):
self.batches = []
def process_batch(self, batch_id):
self.batches.append(batch_id)
class _FakeConfig:
def __init__(self, values=None):
self._v = values or {}
def get(self, key, default=None):
return self._v.get(key, default)
def _build_deps(
*,
automation=None,
monitor=None,
repair=None,
config=None,
is_shutting_down=lambda: False,
submit_dl_worker=None,
submit_failed=None,
submit_failed_auto=None,
process_failed=None,
process_failed_auto=None,
yt_states=None,
tidal_states=None,
deezer_states=None,
spotify_states=None,
):
rec = _Recorder()
return lc.LifecycleDeps(
config_manager=config or _FakeConfig(),
automation_engine=automation,
download_monitor=monitor or _FakeMonitor(),
repair_worker=repair,
mb_worker=None,
is_shutting_down=is_shutting_down,
get_batch_lock=lambda bid: threading.Lock(),
submit_download_track_worker=submit_dl_worker or rec('submit_dl'),
submit_failed_to_wishlist=submit_failed or rec('submit_failed'),
submit_failed_to_wishlist_with_auto_completion=submit_failed_auto or rec('submit_failed_auto'),
process_failed_to_wishlist=process_failed or rec('process_failed'),
process_failed_to_wishlist_with_auto_completion=process_failed_auto or rec('process_failed_auto'),
ensure_wishlist_track_format=lambda track: track,
get_track_artist_name=lambda track: 'Artist',
check_and_remove_from_wishlist=rec('check_wishlist'),
regenerate_batch_m3u=rec('regen_m3u'),
youtube_playlist_states=yt_states or {},
tidal_discovery_states=tidal_states or {},
deezer_discovery_states=deezer_states or {},
spotify_public_discovery_states=spotify_states or {},
), rec
# ---------------------------------------------------------------------------
# start_next_batch_of_downloads
# ---------------------------------------------------------------------------
def test_start_next_returns_silently_for_missing_batch():
deps, rec = _build_deps()
lc.start_next_batch_of_downloads('absent', deps)
assert rec.calls == []
def test_start_next_skipped_when_shutting_down():
download_batches['b1'] = {'queue': ['t1'], 'queue_index': 0, 'active_count': 0, 'max_concurrent': 1}
deps, rec = _build_deps(is_shutting_down=lambda: True)
lc.start_next_batch_of_downloads('b1', deps)
assert rec.calls == [] # no submit
def test_start_next_submits_up_to_max_concurrent():
download_tasks['t1'] = {'status': 'queued'}
download_tasks['t2'] = {'status': 'queued'}
download_tasks['t3'] = {'status': 'queued'}
download_batches['b1'] = {
'queue': ['t1', 't2', 't3'], 'queue_index': 0,
'active_count': 0, 'max_concurrent': 2,
}
deps, rec = _build_deps()
lc.start_next_batch_of_downloads('b1', deps)
submits = [c for c in rec.calls if c[0] == 'submit_dl']
assert len(submits) == 2
assert download_batches['b1']['active_count'] == 2
assert download_batches['b1']['queue_index'] == 2
def test_start_next_skips_cancelled_tasks_without_consuming_slots():
download_tasks['t1'] = {'status': 'cancelled'}
download_tasks['t2'] = {'status': 'queued'}
download_batches['b1'] = {
'queue': ['t1', 't2'], 'queue_index': 0,
'active_count': 0, 'max_concurrent': 1,
}
deps, rec = _build_deps()
lc.start_next_batch_of_downloads('b1', deps)
submits = [c for c in rec.calls if c[0] == 'submit_dl']
assert len(submits) == 1
# t2 should be the one submitted (t1 skipped)
assert submits[0][1] == ('t2', 'b1')
def test_start_next_sets_searching_status_before_submit():
download_tasks['t1'] = {'status': 'queued'}
download_batches['b1'] = {
'queue': ['t1'], 'queue_index': 0,
'active_count': 0, 'max_concurrent': 1,
}
deps, _ = _build_deps()
lc.start_next_batch_of_downloads('b1', deps)
assert download_tasks['t1']['status'] == 'searching'
assert download_tasks['t1']['status_change_time'] is not None
def test_start_next_submit_failure_marks_task_failed_no_ghost_worker():
download_tasks['t1'] = {'status': 'queued'}
download_batches['b1'] = {
'queue': ['t1'], 'queue_index': 0,
'active_count': 0, 'max_concurrent': 1,
}
def _broken_submit(task_id, batch_id):
raise RuntimeError("executor dead")
deps, _ = _build_deps(submit_dl_worker=_broken_submit)
lc.start_next_batch_of_downloads('b1', deps)
# No counters incremented
assert download_batches['b1']['active_count'] == 0
assert download_batches['b1']['queue_index'] == 0
# Task marked failed
assert download_tasks['t1']['status'] == 'failed'
def test_start_next_orphan_task_in_queue_skipped():
download_batches['b1'] = {
'queue': ['absent', 't2'], 'queue_index': 0,
'active_count': 0, 'max_concurrent': 2,
}
download_tasks['t2'] = {'status': 'queued'}
deps, rec = _build_deps()
lc.start_next_batch_of_downloads('b1', deps)
submits = [c for c in rec.calls if c[0] == 'submit_dl']
# Only t2 submitted
assert len(submits) == 1
assert submits[0][1] == ('t2', 'b1')
# ---------------------------------------------------------------------------
# on_download_completed
# ---------------------------------------------------------------------------
def test_on_complete_missing_batch_returns_silently():
deps, rec = _build_deps()
lc.on_download_completed('absent', 't1', True, deps)
assert rec.calls == []
def test_on_complete_decrements_active_count():
download_tasks['t1'] = {'status': 'completed', 'track_info': {'name': 'X'}}
download_batches['b1'] = {
'queue': ['t1'], 'queue_index': 1, 'active_count': 1,
'max_concurrent': 1, 'permanently_failed_tracks': [],
'cancelled_tracks': set(),
}
deps, _ = _build_deps()
lc.on_download_completed('b1', 't1', True, deps)
assert download_batches['b1']['active_count'] == 0
def test_on_complete_duplicate_call_skips_decrement_but_checks_completion():
download_tasks['t1'] = {'status': 'completed', 'track_info': {'name': 'X'}}
download_batches['b1'] = {
'queue': ['t1'], 'queue_index': 1, 'active_count': 1,
'max_concurrent': 1, 'permanently_failed_tracks': [],
'cancelled_tracks': set(),
}
deps, _ = _build_deps()
# First call: decrements
lc.on_download_completed('b1', 't1', True, deps)
assert download_batches['b1']['active_count'] == 0
# Mark as complete to prevent batch completion path
download_batches['b1']['phase'] = 'complete'
# Second call: should NOT decrement again (would go negative)
lc.on_download_completed('b1', 't1', True, deps)
assert download_batches['b1']['active_count'] == 0 # still 0, not -1
def test_on_complete_failed_task_appended_to_permanently_failed_tracks():
download_tasks['t1'] = {
'status': 'failed', 'track_info': {'name': 'Money'},
'track_index': 0, 'retry_count': 0,
}
download_batches['b1'] = {
'queue': ['t1'], 'queue_index': 1, 'active_count': 1,
'max_concurrent': 1, 'permanently_failed_tracks': [],
'cancelled_tracks': set(),
}
deps, _ = _build_deps()
lc.on_download_completed('b1', 't1', False, deps)
assert len(download_batches['b1']['permanently_failed_tracks']) == 1
assert download_batches['b1']['permanently_failed_tracks'][0]['track_name'] == 'Money'
assert download_batches['b1']['permanently_failed_tracks'][0]['track_data'] == {'name': 'Money'}
assert download_batches['b1']['permanently_failed_tracks'][0]['spotify_track'] == {'name': 'Money'}
def test_on_complete_cancelled_task_added_to_cancelled_tracks():
download_tasks['t1'] = {
'status': 'cancelled', 'track_info': {'name': 'X'},
'track_index': 5,
}
download_batches['b1'] = {
'queue': ['t1'], 'queue_index': 1, 'active_count': 1,
'max_concurrent': 1, 'permanently_failed_tracks': [],
'cancelled_tracks': set(),
}
deps, _ = _build_deps()
lc.on_download_completed('b1', 't1', False, deps)
assert 5 in download_batches['b1']['cancelled_tracks']
def test_on_complete_emits_download_failed_for_not_found():
download_tasks['t1'] = {
'status': 'not_found', 'track_info': {'name': 'X'},
'track_index': 0, 'retry_count': 0,
}
download_batches['b1'] = {
'queue': ['t1'], 'queue_index': 1, 'active_count': 1,
'max_concurrent': 1, 'permanently_failed_tracks': [],
'cancelled_tracks': set(),
}
auto = _FakeAutoEngine()
deps, _ = _build_deps(automation=auto)
lc.on_download_completed('b1', 't1', False, deps)
events = [e for e in auto.events if e[0] == 'download_failed']
assert len(events) == 1
def test_on_complete_success_calls_check_and_remove_wishlist():
download_tasks['t1'] = {'status': 'completed', 'track_info': {'name': 'X', 'artists': ['A']}}
download_batches['b1'] = {
'queue': ['t1'], 'queue_index': 1, 'active_count': 1,
'max_concurrent': 1, 'permanently_failed_tracks': [],
'cancelled_tracks': set(),
}
deps, rec = _build_deps()
lc.on_download_completed('b1', 't1', True, deps)
assert any(c[0] == 'check_wishlist' for c in rec.calls)
# ---------------------------------------------------------------------------
# Batch completion (via on_download_completed)
# ---------------------------------------------------------------------------
def test_batch_completion_emits_batch_complete_when_all_done():
download_tasks['t1'] = {'status': 'completed', 'track_info': {'name': 'X'}}
download_tasks['t2'] = {'status': 'completed', 'track_info': {'name': 'Y'}}
download_batches['b1'] = {
'queue': ['t1', 't2'], 'queue_index': 2, 'active_count': 1,
'max_concurrent': 2, 'permanently_failed_tracks': [],
'cancelled_tracks': set(),
'playlist_name': 'PL',
}
auto = _FakeAutoEngine()
monitor = _FakeMonitor()
deps, _ = _build_deps(automation=auto, monitor=monitor)
# Final task completes
lc.on_download_completed('b1', 't2', True, deps)
assert download_batches['b1']['phase'] == 'complete'
assert ('batch_complete', {'playlist_name': 'PL', 'total_tracks': '2', 'completed_tracks': '2', 'failed_tracks': '0'}) in auto.events
assert 'b1' in monitor.stopped
def test_batch_completion_cleans_private_album_bundle_staging(tmp_path):
staging_dir = tmp_path / 'b1'
staging_dir.mkdir()
(staging_dir / 'leftover.flac').write_bytes(b'audio')
download_tasks['t1'] = {'status': 'completed', 'track_info': {'name': 'X'}}
download_batches['b1'] = {
'queue': ['t1'], 'queue_index': 1, 'active_count': 1,
'max_concurrent': 1, 'permanently_failed_tracks': [],
'cancelled_tracks': set(),
'album_bundle_private_staging': True,
'album_bundle_source': 'torrent',
'album_bundle_staging_path': str(staging_dir),
}
deps, _ = _build_deps()
lc.on_download_completed('b1', 't1', True, deps)
assert not staging_dir.exists()
def test_batch_completion_cleans_soulseek_bundle_staging(tmp_path):
"""Regression: Soulseek bundles also copy files into the private
staging dir (``soulseek_client.py:1599``). Pre-fix the cleanup
gate excluded ``soulseek`` because of an outdated comment about
slskd "keeping its own completed folders" — so slskd bundle
copies leaked under storage/album_bundle_staging forever.
Now soulseek is in the cleanup set alongside torrent / usenet."""
staging_dir = tmp_path / 'b_slskd'
staging_dir.mkdir()
(staging_dir / 'leftover.flac').write_bytes(b'audio')
download_tasks['t1'] = {'status': 'completed', 'track_info': {'name': 'X'}}
download_batches['b_slskd'] = {
'queue': ['t1'], 'queue_index': 1, 'active_count': 1,
'max_concurrent': 1, 'permanently_failed_tracks': [],
'cancelled_tracks': set(),
'album_bundle_private_staging': True,
'album_bundle_source': 'soulseek',
'album_bundle_staging_path': str(staging_dir),
}
deps, _ = _build_deps()
lc.on_download_completed('b_slskd', 't1', True, deps)
assert not staging_dir.exists()
def test_batch_completion_keeps_unexpected_staging_path(tmp_path):
staging_dir = tmp_path / 'shared-staging'
staging_dir.mkdir()
(staging_dir / 'leftover.flac').write_bytes(b'audio')
download_tasks['t1'] = {'status': 'completed', 'track_info': {'name': 'X'}}
download_batches['b1'] = {
'queue': ['t1'], 'queue_index': 1, 'active_count': 1,
'max_concurrent': 1, 'permanently_failed_tracks': [],
'cancelled_tracks': set(),
'album_bundle_private_staging': True,
'album_bundle_source': 'torrent',
'album_bundle_staging_path': str(staging_dir),
}
deps, _ = _build_deps()
lc.on_download_completed('b1', 't1', True, deps)
assert staging_dir.exists()
def test_batch_completion_skips_emit_when_zero_successful():
"""Don't emit batch_complete if nothing actually downloaded."""
download_tasks['t1'] = {'status': 'failed', 'track_info': {'name': 'X'}, 'track_index': 0}
download_batches['b1'] = {
'queue': ['t1'], 'queue_index': 1, 'active_count': 1,
'max_concurrent': 1,
'permanently_failed_tracks': [{'track_name': 'X'}], # 1 failed
'cancelled_tracks': set(),
'playlist_name': 'PL',
}
auto = _FakeAutoEngine()
deps, _ = _build_deps(automation=auto)
lc.on_download_completed('b1', 't1', False, deps)
# Pre-existing failed already counted, so successful = 1 (count) - 1 (already failed) = 0
# but on_download_completed appends another failure for t1, so failed count = 2 > finished = 1
# the emit is only triggered if successful_downloads > 0
events = [e for e in auto.events if e[0] == 'batch_complete']
# successful = finished_count(1) - failed_count(2) = -1, which is not > 0 → no emit
assert events == []
def test_batch_completion_routes_auto_batch_to_auto_completion_handler():
download_tasks['t1'] = {'status': 'completed', 'track_info': {'name': 'X'}}
download_batches['b1'] = {
'queue': ['t1'], 'queue_index': 1, 'active_count': 1,
'max_concurrent': 1,
'permanently_failed_tracks': [], 'cancelled_tracks': set(),
'auto_initiated': True,
'playlist_name': 'PL',
}
deps, rec = _build_deps()
lc.on_download_completed('b1', 't1', True, deps)
assert any(c[0] == 'submit_failed_auto' and c[1] == ('b1',) for c in rec.calls)
def test_batch_completion_routes_manual_batch_to_regular_handler():
download_tasks['t1'] = {'status': 'completed', 'track_info': {'name': 'X'}}
download_batches['b1'] = {
'queue': ['t1'], 'queue_index': 1, 'active_count': 1,
'max_concurrent': 1,
'permanently_failed_tracks': [], 'cancelled_tracks': set(),
# no auto_initiated → manual
}
deps, rec = _build_deps()
lc.on_download_completed('b1', 't1', True, deps)
assert any(c[0] == 'submit_failed' and c[1] == ('b1',) for c in rec.calls)
def test_batch_completion_does_not_complete_when_tasks_still_searching():
download_tasks['t1'] = {'status': 'searching', 'track_info': {'name': 'X'},
'status_change_time': lc.time.time()} # fresh
download_tasks['t2'] = {'status': 'completed', 'track_info': {'name': 'Y'}}
download_batches['b1'] = {
'queue': ['t1', 't2'], 'queue_index': 2, 'active_count': 1,
'max_concurrent': 2, 'permanently_failed_tracks': [],
'cancelled_tracks': set(),
}
auto = _FakeAutoEngine()
deps, _ = _build_deps(automation=auto)
lc.on_download_completed('b1', 't2', True, deps)
# Batch NOT marked complete (t1 still searching)
assert download_batches['b1'].get('phase') != 'complete'
def test_stuck_searching_task_forced_to_not_found():
"""Task searching > 10min gets forced to not_found."""
download_tasks['t1'] = {
'status': 'searching', 'track_info': {'name': 'X'},
'status_change_time': 0, # very ancient
}
download_batches['b1'] = {
'queue': ['t1'], 'queue_index': 1, 'active_count': 1,
'max_concurrent': 1, 'permanently_failed_tracks': [],
'cancelled_tracks': set(),
}
deps, _ = _build_deps()
# Trigger completion check
lc.on_download_completed('b1', 't1', False, deps)
# t1 forced to not_found
assert download_tasks['t1']['status'] == 'not_found'
def test_stuck_post_processing_task_forced_to_completed():
"""Task post_processing > 5min gets forced to completed."""
download_tasks['t1'] = {
'status': 'post_processing', 'track_info': {'name': 'X'},
'status_change_time': 0,
}
download_batches['b1'] = {
'queue': ['t1'], 'queue_index': 1, 'active_count': 1,
'max_concurrent': 1, 'permanently_failed_tracks': [],
'cancelled_tracks': set(),
}
deps, _ = _build_deps()
lc.on_download_completed('b1', 't1', True, deps)
assert download_tasks['t1']['status'] == 'completed'
def test_youtube_playlist_phase_updated_on_completion():
download_tasks['t1'] = {'status': 'completed', 'track_info': {'name': 'X'}}
download_batches['b1'] = {
'queue': ['t1'], 'queue_index': 1, 'active_count': 1,
'max_concurrent': 1, 'permanently_failed_tracks': [],
'cancelled_tracks': set(),
'playlist_id': 'youtube_abc123',
}
yt = {'abc123': {'phase': 'downloading'}}
deps, _ = _build_deps(yt_states=yt)
lc.on_download_completed('b1', 't1', True, deps)
assert yt['abc123']['phase'] == 'download_complete'
# ---------------------------------------------------------------------------
# check_batch_completion_v2
# ---------------------------------------------------------------------------
def test_check_v2_returns_none_for_missing_batch():
deps, _ = _build_deps()
result = lc.check_batch_completion_v2('absent', deps)
assert result is None
def test_check_v2_returns_false_when_not_complete():
download_tasks['t1'] = {'status': 'downloading', 'track_info': {}}
download_batches['b1'] = {
'queue': ['t1'], 'queue_index': 1, 'active_count': 1,
'max_concurrent': 1, 'permanently_failed_tracks': [],
}
deps, _ = _build_deps()
result = lc.check_batch_completion_v2('b1', deps)
assert result is False
def test_check_v2_returns_true_when_complete():
download_tasks['t1'] = {'status': 'completed', 'track_info': {'name': 'X'}}
download_batches['b1'] = {
'queue': ['t1'], 'queue_index': 1, 'active_count': 0,
'max_concurrent': 1, 'permanently_failed_tracks': [],
}
deps, _ = _build_deps()
result = lc.check_batch_completion_v2('b1', deps)
assert result is True
assert download_batches['b1']['phase'] == 'complete'
def test_check_v2_already_complete_returns_true_without_reprocessing():
download_tasks['t1'] = {'status': 'completed', 'track_info': {'name': 'X'}}
download_batches['b1'] = {
'queue': ['t1'], 'queue_index': 1, 'active_count': 0,
'max_concurrent': 1, 'permanently_failed_tracks': [],
'phase': 'complete', # already marked
}
deps, rec = _build_deps()
result = lc.check_batch_completion_v2('b1', deps)
assert result is True
# Wishlist NOT submitted again
assert not any(c[0] in ('process_failed', 'process_failed_auto') for c in rec.calls)
def test_check_v2_routes_auto_batch_to_auto_handler():
"""v2 calls wishlist processing DIRECTLY (sync), not via executor submit.
Different from on_download_completed which uses async submit."""
download_tasks['t1'] = {'status': 'completed', 'track_info': {'name': 'X'}}
download_batches['b1'] = {
'queue': ['t1'], 'queue_index': 1, 'active_count': 0,
'max_concurrent': 1, 'permanently_failed_tracks': [],
'auto_initiated': True,
}
deps, rec = _build_deps()
lc.check_batch_completion_v2('b1', deps)
assert any(c[0] == 'process_failed_auto' for c in rec.calls)
def test_check_v2_exception_returns_false():
download_batches['b1'] = {'queue': ['t1'], 'queue_index': 1, 'active_count': 0}
# Force an exception by putting invalid type in batch
download_batches['b1']['queue'] = None # will raise TypeError on len()
deps, _ = _build_deps()
result = lc.check_batch_completion_v2('b1', deps)
assert result is False