Playlists: wire materialize triggers + retire per-track routing flag

- Routing (step 5): organize-by-playlist tracks no longer set the per-track
  _playlist_folder_mode flag, so they import NORMALLY into Artist/Album — exactly
  what a normal download does. _playlist_name provenance is kept (origin.py).
- Triggers (step 4): build the playlist folder from the batch's own payload at
  both end-of-flow points — the all-owned path in master.py (no downloads, so the
  lifecycle never runs) and the batch-complete hook in lifecycle.py (after
  downloads). Both gated on playlist_folder_mode, both non-fatal.

Works for the all-owned case (the smack test that did nothing before) and for
mixed owned/downloaded, with no source-ID or mirrored-playlist dependency. The
materialized folder uses the default ./Playlists root + symlink mode until the
Settings UI is added.

Updated the master test to assert the new contract (provenance kept, routing
flag gone). 979 tests pass.
This commit is contained in:
BoulderBadgeDad 2026-06-12 13:37:59 -07:00
parent bef73d855d
commit aa5d747327
3 changed files with 54 additions and 7 deletions

View file

@ -549,6 +549,24 @@ def on_download_completed(batch_id: str, task_id: str, success: bool, deps: Life
except Exception as m3u_err:
logger.error(f"[M3U] Error regenerating M3U on batch complete: {m3u_err}")
# PLAYLIST MATERIALIZE: if this was an organize-by-playlist batch,
# (re)build the playlist's folder of links/copies now that every
# track is imported. Built from the batch's own payload (owned +
# downloaded real paths) — non-fatal, derived view. (materialize.py)
if batch.get('playlist_folder_mode'):
try:
from core.playlists.materialize_service import materialize_playlist_from_batch
_mat = materialize_playlist_from_batch(batch, download_tasks, deps.config_manager)
if _mat:
logger.info(
f"[Playlist Folder] Rebuilt '{_mat.playlist_dir}': "
f"{_mat.linked} linked, {_mat.copied} copied, "
f"{_mat.unchanged} unchanged, {_mat.removed_stale} stale removed"
+ (" (symlinks unsupported here → copied)" if _mat.fellback else "")
)
except Exception as _mat_err:
logger.error(f"[Playlist Folder] Materialize failed (non-fatal): {_mat_err}")
# REPAIR: Scan all album folders from this batch for track number issues
if deps.repair_worker:
deps.repair_worker.process_batch(batch_id)

View file

@ -785,6 +785,25 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
logger.warning("[Auto-Wishlist] No missing tracks found - calling auto-completion handler to toggle cycle and reschedule")
deps.missing_download_executor.submit(deps.process_failed_tracks_to_wishlist_exact_with_auto_completion, batch_id)
# Organize-by-playlist with NOTHING to download (every track already
# owned): the batch never enters the download/lifecycle path, so build
# the playlist folder here from the owned files the analysis matched.
# Gated + non-fatal; runs once after analysis, not in the per-track loop.
if effective_playlist_folder_mode:
try:
from core.playlists.materialize_service import materialize_playlist_from_batch
_batch = download_batches.get(batch_id)
if _batch is not None:
_mat = materialize_playlist_from_batch(_batch, download_tasks, deps.config_manager)
if _mat:
logger.info(
f"[Playlist Folder] Rebuilt '{_mat.playlist_dir}' (all owned): "
f"{_mat.linked} linked, {_mat.copied} copied, {_mat.removed_stale} stale removed"
+ (" (symlinks unsupported here → copied)" if _mat.fellback else "")
)
except Exception as _mat_err:
logger.error(f"[Playlist Folder] All-owned materialize failed (non-fatal): {_mat_err}")
return
logger.warning(f" transitioning batch {batch_id} to download phase with {len(missing_tracks)} tracks.")
@ -1163,7 +1182,13 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
task_pl_folder_mode = True
task_pl_name = wl_pl_name or wl_mirrored.get('name') or batch_playlist_name
if task_pl_folder_mode:
track_info['_playlist_folder_mode'] = True
# Organize-by-playlist now imports each track NORMALLY into the
# Artist/Album library (i.e. exactly what a normal download does)
# — the playlist folder is built as links/copies AFTER the batch
# from the real library files. So we deliberately DON'T set
# `_playlist_folder_mode` (which routed the real file into a flat
# Music/<playlist>/ dump). We keep `_playlist_name` + source_info
# — they're download provenance (core/downloads/origin.py).
track_info['_playlist_name'] = task_pl_name
if batch_source_playlist_ref:
track_info['source_info'] = {
@ -1172,8 +1197,8 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
'source': batch_source,
}
logger.info(
f"[Task Creation] Added playlist folder mode for: "
f"{track_info.get('name')}{task_pl_name}"
f"[Task Creation] Organize-by-playlist (normal import + "
f"materialize after batch): {track_info.get('name')}{task_pl_name}"
)
else:
logger.debug(

View file

@ -1048,8 +1048,12 @@ def test_wishlist_album_grouping_uses_shared_rich_album_context(monkeypatch):
assert images == {'http://img'}
def test_playlist_folder_mode_propagates(monkeypatch):
"""Playlist folder mode flag carried through to track_info."""
def test_organize_by_playlist_keeps_provenance_not_routing(monkeypatch):
"""Organize-by-playlist no longer routes the file per-track. Each track imports
NORMALLY into Artist/Album (what a normal download does); the playlist folder
is built as links AFTER the batch. So the old routing flag
`_playlist_folder_mode` is NOT set, but `_playlist_name` provenance IS kept
(core/downloads/origin.py)."""
db = _FakeDB()
monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db)
@ -1062,8 +1066,8 @@ def test_playlist_folder_mode_propagates(monkeypatch):
task_id = download_batches['B15']['queue'][0]
info = download_tasks[task_id]['track_info']
assert info['_playlist_folder_mode'] is True
assert info['_playlist_name'] == 'My Mix'
assert '_playlist_folder_mode' not in info # routing retired → normal import
assert info['_playlist_name'] == 'My Mix' # provenance preserved
# ---------------------------------------------------------------------------