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.
601 lines
18 KiB
Python
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"}
|