diff --git a/core/downloads/lifecycle.py b/core/downloads/lifecycle.py index adec519e..a267f291 100644 --- a/core/downloads/lifecycle.py +++ b/core/downloads/lifecycle.py @@ -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) diff --git a/core/downloads/master.py b/core/downloads/master.py index 7a00ebf5..de136b85 100644 --- a/core/downloads/master.py +++ b/core/downloads/master.py @@ -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// 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( diff --git a/tests/downloads/test_downloads_master.py b/tests/downloads/test_downloads_master.py index c2c5b758..b425e4dc 100644 --- a/tests/downloads/test_downloads_master.py +++ b/tests/downloads/test_downloads_master.py @@ -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 # ---------------------------------------------------------------------------