diff --git a/core/imports/pipeline.py b/core/imports/pipeline.py index a94445b8..5d202a2a 100644 --- a/core/imports/pipeline.py +++ b/core/imports/pipeline.py @@ -6,6 +6,8 @@ import json import os import threading import time +from types import SimpleNamespace +from typing import Any from config.settings import config_manager from core.imports.file_ops import ( @@ -68,12 +70,35 @@ from utils.logging_config import get_logger logger = get_logger("imports.pipeline") pp_logger = get_logger("post_processing") - -def post_process_matched_download(context_key, context, file_path, runtime): +__all__ = [ + "build_import_pipeline_runtime", + "post_process_matched_download", + "post_process_matched_download_with_verification", +] + + +def build_import_pipeline_runtime( + *, + automation_engine: Any | None = None, + on_download_completed: Any | None = None, + web_scan_manager: Any | None = None, + repair_worker: Any | None = None, +) -> SimpleNamespace: + """Build the runtime object consumed by core.imports.pipeline.""" + return SimpleNamespace( + automation_engine=automation_engine, + on_download_completed=on_download_completed, + web_scan_manager=web_scan_manager, + repair_worker=repair_worker, + ) + + +def post_process_matched_download(context_key, context, file_path, runtime, metadata_runtime=None): on_download_completed = getattr(runtime, "on_download_completed", None) automation_engine = getattr(runtime, "automation_engine", None) web_scan_manager = getattr(runtime, "web_scan_manager", None) repair_worker = getattr(runtime, "repair_worker", None) + metadata_runtime = metadata_runtime or runtime def _notify_download_completed(batch_id, task_id, success=True): if on_download_completed: @@ -364,7 +389,7 @@ def post_process_matched_download(context_key, context, file_path, runtime): f"[Metadata Input] Playlist mode - artist: '{artist_context.get('name', 'MISSING')}' " f"(id: {artist_context.get('id', 'MISSING')})" ) - enhance_file_metadata(file_path, context, artist_context, None, runtime=runtime) + enhance_file_metadata(file_path, context, artist_context, None, runtime=metadata_runtime) except Exception as meta_err: import traceback pp_logger.info(f"[inner] Metadata enhancement FAILED for {context_key}: {meta_err}\n{traceback.format_exc()}") @@ -529,7 +554,7 @@ def post_process_matched_download(context_key, context, file_path, runtime): ) else: logger.info("[Metadata Input] album_info: None (single track)") - enhance_file_metadata(file_path, context, artist_context, album_info, runtime=runtime) + enhance_file_metadata(file_path, context, artist_context, album_info, runtime=metadata_runtime) except Exception as meta_err: import traceback pp_logger.info(f"[inner] Metadata enhancement FAILED for {context_key}: {meta_err}\n{traceback.format_exc()}") @@ -762,7 +787,7 @@ def post_process_matched_download(context_key, context, file_path, runtime): post_process_locks.pop(context_key, None) -def post_process_matched_download_with_verification(context_key, context, file_path, task_id, batch_id, runtime): +def post_process_matched_download_with_verification(context_key, context, file_path, task_id, batch_id, runtime, metadata_runtime=None): on_download_completed = getattr(runtime, "on_download_completed", None) def _notify_download_completed(batch_id, task_id, success=True): @@ -773,7 +798,7 @@ def post_process_matched_download_with_verification(context_key, context, file_p try: original_task_id = context.pop('task_id', None) original_batch_id = context.pop('batch_id', None) - post_process_matched_download(context_key, context, file_path, runtime) + post_process_matched_download(context_key, context, file_path, runtime, metadata_runtime=metadata_runtime) if original_task_id: context['task_id'] = original_task_id if original_batch_id: diff --git a/core/metadata/enrichment.py b/core/metadata/enrichment.py index 962a6bcf..82f462d6 100644 --- a/core/metadata/enrichment.py +++ b/core/metadata/enrichment.py @@ -3,6 +3,8 @@ from __future__ import annotations import os +from types import SimpleNamespace +from typing import Any from core.metadata.artwork import embed_album_art_metadata from core.metadata.common import ( @@ -19,6 +21,7 @@ from utils.logging_config import get_logger as _create_logger __all__ = [ + "build_metadata_enrichment_runtime", "enhance_file_metadata", "extract_source_metadata", "embed_source_ids", @@ -28,6 +31,32 @@ __all__ = [ logger = _create_logger("metadata.enrichment") +def build_metadata_enrichment_runtime( + *, + mb_worker: Any | None = None, + deezer_worker: Any | None = None, + audiodb_worker: Any | None = None, + tidal_client: Any | None = None, + qobuz_enrichment_worker: Any | None = None, + lastfm_worker: Any | None = None, + genius_worker: Any | None = None, + spotify_enrichment_worker: Any | None = None, + itunes_enrichment_worker: Any | None = None, +) -> SimpleNamespace: + """Build the runtime object consumed by core.metadata.enrichment/source.""" + return SimpleNamespace( + mb_worker=mb_worker, + deezer_worker=deezer_worker, + audiodb_worker=audiodb_worker, + tidal_client=tidal_client, + qobuz_enrichment_worker=qobuz_enrichment_worker, + lastfm_worker=lastfm_worker, + genius_worker=genius_worker, + spotify_enrichment_worker=spotify_enrichment_worker, + itunes_enrichment_worker=itunes_enrichment_worker, + ) + + def enhance_file_metadata(file_path: str, context: dict, artist: dict, album_info: dict, runtime=None) -> bool: cfg = get_config_manager() if cfg.get("metadata_enhancement.enabled", True) is False: diff --git a/tests/imports/test_import_pipeline.py b/tests/imports/test_import_pipeline.py index 20b71692..231a0437 100644 --- a/tests/imports/test_import_pipeline.py +++ b/tests/imports/test_import_pipeline.py @@ -126,3 +126,84 @@ def test_verification_wrapper_handles_simple_download(tmp_path, monkeypatch): runtime_state.processed_download_ids.update(original_processed_ids) runtime_state.post_process_locks.clear() runtime_state.post_process_locks.update(original_post_locks) + + +def test_post_process_matched_download_forwards_separate_metadata_runtime(tmp_path, monkeypatch): + source_path = tmp_path / "source.flac" + source_path.write_bytes(b"audio") + target_path = tmp_path / "Album Folder" / "track.flac" + + runtime = types.SimpleNamespace( + automation_engine=None, + on_download_completed=None, + web_scan_manager=None, + repair_worker=None, + ) + metadata_runtime = types.SimpleNamespace(marker="metadata-runtime") + seen = {} + + monkeypatch.setattr(import_pipeline, "config_manager", types.SimpleNamespace( + get=lambda key, default=None: { + "post_processing.replaygain_enabled": False, + "lossy_copy.enabled": False, + "lossy_copy.delete_original": False, + "import.replace_lower_quality": False, + "soulseek.download_path": str(tmp_path / "downloads"), + }.get(key, default) + )) + monkeypatch.setattr(import_pipeline, "normalize_import_context", lambda context: context) + monkeypatch.setattr(import_pipeline, "get_import_track_info", lambda context: {"_playlist_folder_mode": True, "_playlist_name": "Playlist"}) + monkeypatch.setattr(import_pipeline, "get_import_original_search", lambda context: {"title": "Track", "album": "Album"}) + monkeypatch.setattr(import_pipeline, "get_import_context_artist", lambda context: {"name": "Artist"}) + monkeypatch.setattr(import_pipeline, "get_import_has_clean_metadata", lambda context: True) + monkeypatch.setattr( + import_pipeline, + "build_import_album_info", + lambda context, force_album=False: { + "is_album": True, + "album_name": "Album", + "track_number": 1, + "disc_number": 1, + "clean_track_name": "Track", + "source": "spotify", + }, + ) + monkeypatch.setattr(import_pipeline, "resolve_album_group", lambda artist_context, album_info, original_album: album_info["album_name"]) + monkeypatch.setattr(import_pipeline, "get_import_clean_title", lambda *args, **kwargs: "Track") + monkeypatch.setattr(import_pipeline, "get_audio_quality_string", lambda file_path: "") + monkeypatch.setattr(import_pipeline, "check_flac_bit_depth", lambda *args, **kwargs: None) + monkeypatch.setattr(import_pipeline, "build_final_path_for_track", lambda *args, **kwargs: (str(target_path), None)) + + def _capture_enhance(file_path, context, artist, album_info, runtime=None): + seen["runtime"] = runtime + return True + + monkeypatch.setattr(import_pipeline, "enhance_file_metadata", _capture_enhance) + monkeypatch.setattr(import_pipeline, "safe_move_file", lambda *args, **kwargs: None) + monkeypatch.setattr(import_pipeline, "download_cover_art", lambda *args, **kwargs: None) + monkeypatch.setattr(import_pipeline, "generate_lrc_file", lambda *args, **kwargs: None) + monkeypatch.setattr(import_pipeline, "downsample_hires_flac", lambda *args, **kwargs: None) + monkeypatch.setattr(import_pipeline, "create_lossy_copy", lambda *args, **kwargs: None) + monkeypatch.setattr(import_pipeline, "cleanup_empty_directories", lambda *args, **kwargs: None) + monkeypatch.setattr(import_pipeline, "emit_track_downloaded", lambda *args, **kwargs: None) + monkeypatch.setattr(import_pipeline, "record_library_history_download", lambda *args, **kwargs: None) + monkeypatch.setattr(import_pipeline, "record_download_provenance", lambda *args, **kwargs: None) + monkeypatch.setattr(import_pipeline, "record_soulsync_library_entry", lambda *args, **kwargs: None) + monkeypatch.setattr(import_pipeline, "check_and_remove_from_wishlist", lambda *args, **kwargs: None) + monkeypatch.setattr(import_pipeline, "record_retag_download", lambda *args, **kwargs: None) + + context = { + "track_info": {"_playlist_folder_mode": True, "_playlist_name": "Playlist"}, + "original_search_result": {"title": "Track", "album": "Album"}, + "is_album_download": False, + } + + import_pipeline.post_process_matched_download( + "ctx-1", + context, + str(source_path), + runtime, + metadata_runtime=metadata_runtime, + ) + + assert seen["runtime"] is metadata_runtime diff --git a/tests/metadata/test_runtime_bundle.py b/tests/metadata/test_runtime_bundle.py new file mode 100644 index 00000000..415944af --- /dev/null +++ b/tests/metadata/test_runtime_bundle.py @@ -0,0 +1,56 @@ +import types + +from core.imports.pipeline import build_import_pipeline_runtime +from core.metadata.enrichment import build_metadata_enrichment_runtime + + +def test_build_import_pipeline_runtime_exposes_expected_contract(): + import_fields = { + "automation_engine": object(), + "on_download_completed": object(), + "web_scan_manager": object(), + "repair_worker": object(), + } + runtime = build_import_pipeline_runtime(**import_fields) + + assert isinstance(runtime, types.SimpleNamespace) + for name, value in import_fields.items(): + assert hasattr(runtime, name) + assert getattr(runtime, name) is value + + for name in ( + "mb_worker", + "deezer_worker", + "audiodb_worker", + "tidal_client", + "qobuz_enrichment_worker", + "lastfm_worker", + "genius_worker", + "spotify_enrichment_worker", + "itunes_enrichment_worker", + ): + assert not hasattr(runtime, name) + + +def test_build_metadata_enrichment_runtime_exposes_expected_contract(): + metadata_fields = { + "mb_worker": object(), + "deezer_worker": object(), + "audiodb_worker": object(), + "tidal_client": object(), + "qobuz_enrichment_worker": object(), + "lastfm_worker": object(), + "genius_worker": object(), + "spotify_enrichment_worker": object(), + "itunes_enrichment_worker": object(), + } + + runtime = build_metadata_enrichment_runtime(**metadata_fields) + + assert isinstance(runtime, types.SimpleNamespace) + for name, value in metadata_fields.items(): + assert hasattr(runtime, name) + assert getattr(runtime, name) is value + + for name in ("automation_engine", "on_download_completed", "web_scan_manager", "repair_worker"): + assert not hasattr(runtime, name) diff --git a/web_server.py b/web_server.py index a002458e..d564c911 100644 --- a/web_server.py +++ b/web_server.py @@ -132,7 +132,9 @@ from core.imports.staging import ( start_import_suggestions_cache, ) from core.imports.paths import build_final_path_for_track as _build_final_path_for_track +from core.imports.pipeline import build_import_pipeline_runtime as _build_import_pipeline_runtime from core.metadata.common import get_file_lock +from core.metadata.enrichment import build_metadata_enrichment_runtime as _build_metadata_enrichment_runtime from core.metadata.source import ( mb_release_cache, mb_release_cache_lock, @@ -19678,13 +19680,13 @@ import urllib.request def _wipe_source_tags(file_path: str) -> bool: return metadata_enrichment.wipe_source_tags(file_path) -def _enhance_file_metadata(file_path: str, context: dict, artist: dict, album_info: dict, runtime=None) -> bool: +def _enhance_file_metadata(file_path: str, context: dict, artist: dict, album_info: dict, metadata_runtime=None) -> bool: return metadata_enrichment.enhance_file_metadata( file_path, context, artist, album_info, - runtime=runtime or _build_import_pipeline_runtime(), + runtime=metadata_runtime or _build_metadata_enrichment_runtime(), ) @@ -19778,24 +19780,7 @@ def _post_process_matched_download_with_verification(context_key, context, file_ task_id, batch_id, _build_import_pipeline_runtime(), - ) - -def _build_import_pipeline_runtime(): - """Collect live controller dependencies for the shared import pipeline.""" - return types.SimpleNamespace( - automation_engine=automation_engine, - on_download_completed=_on_download_completed, - web_scan_manager=web_scan_manager, - repair_worker=repair_worker, - mb_worker=mb_worker, - deezer_worker=deezer_worker, - audiodb_worker=audiodb_worker, - tidal_client=tidal_client, - qobuz_enrichment_worker=qobuz_enrichment_worker, - lastfm_worker=lastfm_worker, - genius_worker=genius_worker, - spotify_enrichment_worker=spotify_enrichment_worker, - itunes_enrichment_worker=itunes_enrichment_worker, + _build_metadata_enrichment_runtime(), ) @@ -19903,45 +19888,12 @@ def _post_process_matched_download(context_key, context, file_path): just move files to /Transfer without metadata enhancement. """ from core.imports.pipeline import post_process_matched_download - return post_process_matched_download(context_key, context, file_path, _build_import_pipeline_runtime()) - -def _build_import_pipeline_runtime(): - """Collect the live controller dependencies needed by core.imports.pipeline.""" - return types.SimpleNamespace( - automation_engine=automation_engine, - on_download_completed=_on_download_completed, - web_scan_manager=web_scan_manager, - repair_worker=repair_worker, - mb_worker=mb_worker, - deezer_worker=deezer_worker, - audiodb_worker=audiodb_worker, - tidal_client=tidal_client, - qobuz_enrichment_worker=qobuz_enrichment_worker, - lastfm_worker=lastfm_worker, - genius_worker=genius_worker, - spotify_enrichment_worker=spotify_enrichment_worker, - itunes_enrichment_worker=itunes_enrichment_worker, - ) - -def _wipe_source_tags(file_path: str) -> bool: - return metadata_enrichment.wipe_source_tags(file_path) - - -def _enhance_file_metadata(file_path: str, context: dict, artist: dict, album_info: dict, runtime=None) -> bool: - return metadata_enrichment.enhance_file_metadata( - file_path, - context, - artist, - album_info, - runtime=runtime or _build_import_pipeline_runtime(), - ) - - -def _download_cover_art(album_info: dict, target_dir: str, context: dict = None): - return metadata_enrichment.download_cover_art( - album_info, - target_dir, + return post_process_matched_download( + context_key, context, + file_path, + _build_import_pipeline_runtime(), + metadata_runtime=_build_metadata_enrichment_runtime(), ) # Track stale transfer keys (completed in slskd but no context — e.g., from before app restart)