import os from concurrent.futures import Future import core.imports.routes as import_routes from core.imports.routes import ( ImportRouteRuntime, album_match, album_process, process_single_import_file, search_albums, search_tracks, singles_process, staging_files, staging_groups, staging_hints, staging_suggestions, ) class _FakeLogger: def __init__(self): self.debug_messages = [] self.error_messages = [] self.info_messages = [] self.warning_messages = [] def debug(self, msg, *args): self.debug_messages.append(msg % args if args else msg) def error(self, msg, *args): self.error_messages.append(msg % args if args else msg) def info(self, msg, *args): self.info_messages.append(msg % args if args else msg) def warning(self, msg, *args): self.warning_messages.append(msg % args if args else msg) class _FakeHydrabaseWorker: def __init__(self): self.enqueued = [] def enqueue(self, query, kind): self.enqueued.append((query, kind)) class _FakeAutomationEngine: def __init__(self, fail=False): self.events = [] self.fail = fail def emit(self, name, payload): if self.fail: raise RuntimeError("emit boom") self.events.append((name, payload)) class _FakeExecutor: def __init__(self, outcomes=None): self.outcomes = list(outcomes or []) self.calls = [] def submit(self, fn, *args): self.calls.append((fn, args)) future = Future() if self.outcomes: outcome = self.outcomes.pop(0) if isinstance(outcome, BaseException): future.set_exception(outcome) else: future.set_result(outcome) else: future.set_result(fn(*args)) return future def _touch(path): path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(b"") def _metadata_for(files): def _read_metadata(file_path, rel_path): return files[rel_path] return _read_metadata def test_staging_files_returns_audio_files_with_metadata(tmp_path): _touch(tmp_path / "Artist" / "02 - Song.flac") _touch(tmp_path / "cover.jpg") rel_song = os.path.join("Artist", "02 - Song.flac") metadata = { rel_song: { "title": "Song", "artist": "Track Artist", "albumartist": "Album Artist", "album": "Album", "track_number": 2, "disc_number": 1, } } runtime = ImportRouteRuntime( get_staging_path=lambda: str(tmp_path), read_staging_file_metadata=_metadata_for(metadata), logger=_FakeLogger(), ) payload, status = staging_files(runtime) assert status == 200 assert payload["success"] is True assert payload["staging_path"] == str(tmp_path) assert len(payload["files"]) == 1 assert payload["files"] == [ { "filename": "02 - Song.flac", "rel_path": rel_song, "full_path": str(tmp_path / "Artist" / "02 - Song.flac"), "title": "Song", "artist": "Album Artist", "album": "Album", "track_number": 2, "disc_number": 1, "extension": ".flac", } ] def test_staging_groups_only_returns_multi_file_album_groups(tmp_path): _touch(tmp_path / "a.mp3") _touch(tmp_path / "b.mp3") _touch(tmp_path / "single.mp3") metadata = { "a.mp3": { "title": "A", "artist": "Artist", "albumartist": "", "album": "Album", "track_number": 2, "disc_number": 1, }, "b.mp3": { "title": "B", "artist": "Artist", "albumartist": "", "album": "Album", "track_number": 1, "disc_number": 1, }, "single.mp3": { "title": "Single", "artist": "Other", "albumartist": "", "album": "Other Album", "track_number": 1, "disc_number": 1, }, } runtime = ImportRouteRuntime( get_staging_path=lambda: str(tmp_path), read_staging_file_metadata=_metadata_for(metadata), logger=_FakeLogger(), ) payload, status = staging_groups(runtime) assert status == 200 assert payload["success"] is True assert len(payload["groups"]) == 1 group = payload["groups"][0] assert group["album"] == "Album" assert group["artist"] == "Artist" assert group["file_count"] == 2 assert [f["filename"] for f in group["files"]] == ["b.mp3", "a.mp3"] def test_staging_hints_prefers_tag_queries_then_folder_queries(tmp_path): _touch(tmp_path / "Folder_Album" / "01.mp3") _touch(tmp_path / "Folder_Album" / "02.mp3") _touch(tmp_path / "Loose" / "track.flac") def _read_tags(file_path): if file_path.endswith("01.mp3") or file_path.endswith("02.mp3"): return {"album": ["Tagged Album"], "artist": ["Tagged Artist"]} return {} runtime = ImportRouteRuntime( get_staging_path=lambda: str(tmp_path), read_tags=_read_tags, logger=_FakeLogger(), ) payload, status = staging_hints(runtime) assert status == 200 assert payload == { "success": True, "hints": ["Tagged Album Tagged Artist", "Folder Album", "Loose"], } def test_staging_suggestions_returns_cache_payload(monkeypatch): monkeypatch.setattr( import_routes, "get_import_suggestions_cache", lambda: {"suggestions": [{"album": "Album"}], "built": True}, ) monkeypatch.setattr(import_routes, "_get_primary_source", lambda: "deezer") payload, status = staging_suggestions() assert status == 200 assert payload == { "success": True, "suggestions": [{"album": "Album"}], "ready": True, "primary_source": "deezer", } def test_staging_groups_returns_empty_for_missing_staging_path(tmp_path): runtime = ImportRouteRuntime( get_staging_path=lambda: str(tmp_path / "missing"), logger=_FakeLogger(), ) payload, status = staging_groups(runtime) assert status == 200 assert payload == {"success": True, "groups": []} def test_staging_hints_returns_empty_for_missing_staging_path(tmp_path): runtime = ImportRouteRuntime( get_staging_path=lambda: str(tmp_path / "missing"), logger=_FakeLogger(), ) payload, status = staging_hints(runtime) assert status == 200 assert payload == {"success": True, "hints": []} def test_staging_files_returns_error_when_path_resolution_fails(): logger = _FakeLogger() runtime = ImportRouteRuntime( get_staging_path=lambda: (_ for _ in ()).throw(RuntimeError("path boom")), logger=logger, ) payload, status = staging_files(runtime) assert status == 500 assert payload["success"] is False assert payload["error"] == "path boom" assert logger.error_messages == ["Error scanning staging files: path boom"] def test_staging_groups_returns_error_when_metadata_read_fails(tmp_path): _touch(tmp_path / "a.mp3") logger = _FakeLogger() runtime = ImportRouteRuntime( get_staging_path=lambda: str(tmp_path), read_staging_file_metadata=lambda _file_path, _rel_path: (_ for _ in ()).throw(RuntimeError("tag boom")), logger=logger, ) payload, status = staging_groups(runtime) assert status == 500 assert payload["success"] is False assert payload["error"] == "tag boom" assert logger.error_messages == ["Error building staging groups: tag boom"] def test_staging_hints_returns_error_when_path_resolution_fails(): logger = _FakeLogger() runtime = ImportRouteRuntime( get_staging_path=lambda: (_ for _ in ()).throw(RuntimeError("hint boom")), logger=logger, ) payload, status = staging_hints(runtime) assert status == 500 assert payload["success"] is False assert payload["error"] == "hint boom" assert logger.error_messages == ["Error getting staging hints: hint boom"] def test_search_albums_enqueues_hydrabase_and_caps_limit(): worker = _FakeHydrabaseWorker() calls = [] runtime = ImportRouteRuntime( get_primary_source=lambda: "hydrabase", hydrabase_worker=worker, dev_mode_enabled=True, search_import_albums=lambda query, limit: calls.append((query, limit)) or [{"id": "album-1"}], logger=_FakeLogger(), ) payload, status = search_albums(runtime, " Album ", 99) assert status == 200 assert payload == { "success": True, "albums": [{"id": "album-1"}], "primary_source": "hydrabase", } assert worker.enqueued == [("Album", "albums")] assert calls == [("Album", 50)] def test_search_albums_requires_query(): payload, status = search_albums(ImportRouteRuntime(logger=_FakeLogger()), "", 12) assert status == 400 assert payload == {"success": False, "error": "Missing query parameter"} def test_search_albums_exposes_primary_source_when_chain_falls_back(): # Pins github issue #681: when the primary source returns nothing and the # silent fallback chain (intentional, see core/auto_import_worker.py:1316) # serves results from a different source, the response must carry both # `primary_source` (what the user configured) and per-album `source` # (what actually served the result) so the UI can warn the user. runtime = ImportRouteRuntime( get_primary_source=lambda: "musicbrainz", search_import_albums=lambda query, limit: [ {"id": "deezer-1", "name": "Album", "source": "deezer"}, ], logger=_FakeLogger(), ) payload, status = search_albums(runtime, "Weapons of Mass Destruction", 12) assert status == 200 assert payload["success"] is True assert payload["primary_source"] == "musicbrainz" assert payload["albums"][0]["source"] == "deezer" def test_search_tracks_enqueues_hydrabase_and_caps_limit(): worker = _FakeHydrabaseWorker() calls = [] runtime = ImportRouteRuntime( get_primary_source=lambda: "hydrabase", hydrabase_worker=worker, dev_mode_enabled=True, search_import_tracks=lambda query, limit: calls.append((query, limit)) or [{"id": "track-1"}], logger=_FakeLogger(), ) payload, status = search_tracks(runtime, " Track ", 99) assert status == 200 assert payload == { "success": True, "tracks": [{"id": "track-1"}], "primary_source": "hydrabase", } assert worker.enqueued == [("Track", "tracks")] assert calls == [("Track", 30)] def test_album_match_warns_without_source_and_passes_file_filter(): logger = _FakeLogger() calls = [] runtime = ImportRouteRuntime( build_album_import_match_payload=lambda *args, **kwargs: calls.append((args, kwargs)) or {"success": True, "matches": []}, logger=logger, ) payload, status = album_match( runtime, { "album_id": "album-1", "album_name": "Album", "album_artist": "Artist", "file_paths": ["a.flac", "b.flac"], }, ) assert status == 200 assert payload == {"success": True, "matches": []} assert calls == [ ( ("album-1",), { "album_name": "Album", "album_artist": "Artist", "file_paths": {"a.flac", "b.flac"}, "source": None, }, ) ] assert len(logger.warning_messages) == 1 assert "Missing 'source'" in logger.warning_messages[0] def test_album_match_requires_album_id(): payload, status = album_match(ImportRouteRuntime(logger=_FakeLogger()), {}) assert status == 400 assert payload == {"success": False, "error": "Missing album_id"} def test_album_process_posts_valid_files_and_records_side_effects(tmp_path): good_file = tmp_path / "good.flac" _touch(good_file) processed_contexts = [] activity = [] refresh_calls = [] automation = _FakeAutomationEngine() runtime = ImportRouteRuntime( resolve_album_artist_context=lambda album, source="": {"name": "Artist"}, build_album_import_context=lambda album, track, **kwargs: {"album": album, "track": track, **kwargs}, post_process_matched_download=lambda key, context, path: processed_contexts.append((key, context, path)), add_activity_item=lambda *args: activity.append(args), refresh_import_suggestions_cache=lambda: refresh_calls.append("refresh"), automation_engine=automation, logger=_FakeLogger(), ) payload, status = album_process( runtime, { "album": {"id": "album-1", "name": "Album", "artist": "Artist", "source": "deezer"}, "matches": [ { "staging_file": {"full_path": str(good_file), "filename": "good.flac"}, "track": {"name": "Good Track", "track_number": 1, "disc_number": 2}, }, { "staging_file": {"full_path": str(tmp_path / "missing.flac"), "filename": "missing.flac"}, "track": {"name": "Missing Track", "track_number": 2}, }, ], }, ) assert status == 200 assert payload == { "success": True, "processed": 1, "total": 2, "errors": ["File not found: missing.flac"], } assert len(processed_contexts) == 1 key, context, path = processed_contexts[0] assert key.startswith("import_album_album-1_1_") assert context["artist_context"] == {"name": "Artist"} assert context["total_discs"] == 2 assert context["source"] == "deezer" assert path == str(good_file) assert activity == [("", "Album Imported", "Album by Artist (1/2 tracks)", "Now")] assert refresh_calls == ["refresh"] assert automation.events == [ ( "import_completed", {"track_count": "1", "album_name": "Album", "artist": "Artist"}, ), ( "batch_complete", { "playlist_name": "Import: Album", "total_tracks": "2", "completed_tracks": "1", "failed_tracks": "1", }, ), ] def test_album_process_requires_album_and_matches(): payload, status = album_process(ImportRouteRuntime(logger=_FakeLogger()), {"album": {}, "matches": []}) assert status == 400 assert payload == {"success": False, "error": "Missing album or matches data"} def test_process_single_import_file_resolves_and_posts_context(tmp_path): audio_file = tmp_path / "Artist - Song.flac" _touch(audio_file) post_calls = [] runtime = ImportRouteRuntime( parse_filename_metadata=lambda filename: {"title": "Song", "artist": "Artist"}, get_single_track_import_context=lambda title, artist, **kwargs: { "source": "deezer", "context": {"track": {"name": title}, "artist": {"name": artist}}, }, normalize_import_context=lambda context: context, get_import_context_artist=lambda context: context["artist"], get_import_track_info=lambda context: context["track"], post_process_matched_download=lambda key, context, path: post_calls.append((key, context, path)), logger=_FakeLogger(), ) outcome = process_single_import_file(runtime, {"full_path": str(audio_file), "filename": audio_file.name}) assert outcome == ("ok", "Song") assert len(post_calls) == 1 assert post_calls[0][0].startswith("import_single_") assert post_calls[0][1] == {"track": {"name": "Song"}, "artist": {"name": "Artist"}, "is_local_import": True} assert post_calls[0][2] == str(audio_file) def test_process_single_import_file_rejects_malformed_manual_match(tmp_path): audio_file = tmp_path / "Song.flac" _touch(audio_file) runtime = ImportRouteRuntime(post_process_matched_download=lambda *_args: None, logger=_FakeLogger()) outcome = process_single_import_file( runtime, {"full_path": str(audio_file), "filename": audio_file.name, "manual_match": {"id": "track-1"}}, ) assert outcome == ("error", "Malformed manual match for file: Song.flac") def test_singles_process_aggregates_worker_results_and_side_effects(): activity = [] refresh_calls = [] automation = _FakeAutomationEngine() executor = _FakeExecutor(outcomes=[("ok", "Song"), ("error", "Bad Song")]) runtime = ImportRouteRuntime( import_singles_executor=executor, add_activity_item=lambda *args: activity.append(args), refresh_import_suggestions_cache=lambda: refresh_calls.append("refresh"), automation_engine=automation, logger=_FakeLogger(), ) payload, status = singles_process( runtime, [{"filename": "a.flac"}, {"filename": "b.flac"}], ) assert status == 200 assert payload == { "success": True, "processed": 1, "total": 2, "errors": ["Bad Song"], } assert len(executor.calls) == 2 assert activity == [("", "Singles Imported", "1/2 tracks processed", "Now")] assert refresh_calls == ["refresh"] assert automation.events == [ ( "import_completed", {"track_count": "1", "album_name": "", "artist": "Various"}, ), ( "batch_complete", { "playlist_name": "Import: Singles", "total_tracks": "2", "completed_tracks": "1", "failed_tracks": "1", }, ), ] def test_singles_process_uses_injected_single_file_worker(): calls = [] executor = _FakeExecutor() def fake_process_file(runtime, file_info): calls.append((runtime, file_info)) return ("ok", file_info["filename"]) runtime = ImportRouteRuntime( import_singles_executor=executor, process_single_import_file=fake_process_file, logger=_FakeLogger(), ) payload, status = singles_process(runtime, [{"filename": "patched.flac"}]) assert status == 200 assert payload == { "success": True, "processed": 1, "total": 1, "errors": [], } assert calls == [(runtime, {"filename": "patched.flac"})] assert executor.calls[0][0] is fake_process_file def test_singles_process_requires_files(): payload, status = singles_process(ImportRouteRuntime(logger=_FakeLogger()), []) assert status == 400 assert payload == {"success": False, "error": "No files provided"}