soulsync/tests/imports/test_import_routes.py
BoulderBadgeDad 20ca4bb981 Import: don't duration-quarantine manual imports against a re-resolved release (#804)
CubeComming #804: importing Coldplay "Yellow" (the 269s Parachutes album track,
correctly tagged) was quarantined — "Duration mismatch: file is 269.2s, expected
266.0s (drift 3.2s > tolerance 3.0s)". The expected 266s came from a re-resolved
*single* edition, not the file's actual album. The duration-agreement integrity
check exists to catch truncated/wrong slskd TRANSFERS — but a manual import is
the user's own already-tagged file being sorted, so checking it against a
re-resolved release just manufactures false quarantines.

Fix: both manual-import paths (singles + album) now mark the context
is_local_import; the integrity check skips the duration-agreement leg for local
imports via expected_duration_for_check() (new pure helper). The size +
mutagen-parse legs still run, so genuinely broken files are still caught — only
the release-vs-file duration comparison is skipped, and only for manual imports.
slskd downloads are completely unaffected.

This does NOT change the deeper matching (file still groups under Singles vs the
Parachutes album — the #767 canonical-version family); it stops the false
quarantine so the file imports.

Tests: 4 on the helper (local skips, download keeps, zero/None/garbage, string
coercion) + updated the routes context assertion. 557 import/integrity tests pass.
2026-06-07 23:02:34 -07:00

601 lines
18 KiB
Python

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"}