"""Round 45 — Identify Track…: Chromaprint fingerprint + AcoustID lookup. The interesting rule here is the year. trav's complaint was that a 1960s song kept getting stamped with the year its CD reissue came out, which sorts the library wrong. So the parser proposes the song's *original* release year: the earliest release across every release group the recording appears on, carried by every candidate — even the one that proposes a later compilation as the album. The rest of the round is plumbing: pure parsing over canned AcoustID JSON, a passive proposal dialog, and one context-menu signal. """ import json import subprocess import pytest from lintunes.fingerprint import ( IdentifyCandidate, fingerprint_file, fpcalc_available, lookup_fingerprint, parse_lookup, ) # ---- canned AcoustID payloads ---- def _release(year, mediums=None): release = {"date": {"year": year} if year else {}} if mediums: release["mediums"] = mediums return release MEDIUM = [{"position": 1, "track_count": 12, "tracks": [{"position": 4}]}] # One recording, one album, pressed in 1969 and reissued on CD in 1999. REISSUE_PAYLOAD = { "status": "ok", "results": [{ "score": 0.95, "recordings": [{ "title": "Sunshine", "artists": [{"name": "The Band"}], "releasegroups": [{ "title": "First Album", "type": "Album", "artists": [{"name": "The Band"}], "releases": [_release(1999), _release(1969, MEDIUM)], }], }], }], } # The same recording on its 1967 album and a 1998 greatest-hits compilation. COMPILATION_PAYLOAD = { "status": "ok", "results": [{ "score": 0.9, "recordings": [{ "title": "Sunshine", "artists": [{"name": "The Band"}], "releasegroups": [ {"title": "Greatest Hits", "type": "Album", "secondarytypes": ["Compilation"], "releases": [_release(1998)]}, {"title": "First Album", "type": "Album", "releases": [_release(1967, MEDIUM)]}, ], }], }], } class TestParsing: def test_maps_every_field(self): [candidate] = parse_lookup(REISSUE_PAYLOAD) assert candidate.name == "Sunshine" assert candidate.artist == "The Band" assert candidate.album_artist == "The Band" assert candidate.album == "First Album" assert candidate.track_number == 4 assert candidate.track_count == 12 assert candidate.disc_number == 1 assert candidate.score == 0.95 def test_year_is_the_oldest_release_not_the_cd_reissue(self): [candidate] = parse_lookup(REISSUE_PAYLOAD) assert candidate.year == 1969 def test_every_candidate_carries_the_songs_original_year(self): """Even the greatest-hits candidate says 1967 — the year sorts by when the song came out, not by which pressing this file is from.""" candidates = parse_lookup(COMPILATION_PAYLOAD) assert {c.year for c in candidates} == {1967} def test_studio_album_ranks_above_the_compilation(self): candidates = parse_lookup(COMPILATION_PAYLOAD) assert [c.album for c in candidates] == ["First Album", "Greatest Hits"] def test_score_threshold_drops_weak_matches(self): payload = {"status": "ok", "results": [ {"score": 0.3, "recordings": [{"title": "Noise"}]}, {"score": 0.9, "recordings": [{"title": "Real"}]}, ]} assert [c.name for c in parse_lookup(payload)] == ["Real"] def test_candidate_limit_is_respected(self): groups = [{"title": f"Album {i}", "type": "Album", "releases": [_release(1970 + i)]} for i in range(12)] payload = {"status": "ok", "results": [{ "score": 0.9, "recordings": [{"title": "Song", "releasegroups": groups}], }]} assert len(parse_lookup(payload, limit=8)) == 8 def test_empty_payload_gives_no_candidates(self): assert parse_lookup({"status": "ok", "results": []}) == [] def test_missing_data_leaves_fields_unset(self): payload = {"status": "ok", "results": [{ "score": 0.9, "recordings": [{"title": "Song", "releasegroups": [ {"title": "Album", "type": "Album", "releases": [_release(None)]}, ]}], }]} [candidate] = parse_lookup(payload) assert candidate.year is None assert candidate.track_number is None assert "year" not in candidate.fields() assert candidate.fields()["album"] == "Album" def test_joined_artist_credits(self): payload = {"status": "ok", "results": [{ "score": 0.9, "recordings": [{"title": "Duet", "artists": [ {"name": "A", "joinphrase": " & "}, {"name": "B"}]}], }]} assert parse_lookup(payload)[0].artist == "A & B" def test_fields_are_editable_tag_names(self): from lintunes import tagging candidate = IdentifyCandidate(score=1.0, name="N", artist="A", year=1969) assert set(candidate.fields()) <= set(tagging.EDITABLE_FIELDS) class TestLookup: def test_api_error_becomes_a_runtime_error(self, monkeypatch): class FakeResponse: def raise_for_status(self): pass def json(self): return {"status": "error", "error": {"message": "invalid API key"}} import requests monkeypatch.setattr(requests, "post", lambda *a, **k: FakeResponse()) with pytest.raises(RuntimeError, match="invalid API key"): lookup_fingerprint("key", 180, "fp") def test_successful_lookup_parses(self, monkeypatch): class FakeResponse: def raise_for_status(self): pass def json(self): return REISSUE_PAYLOAD import requests monkeypatch.setattr(requests, "post", lambda *a, **k: FakeResponse()) assert lookup_fingerprint("key", 180, "fp")[0].year == 1969 @pytest.fixture def long_mp3(tmp_path): """Chromaprint refuses anything under ~3 seconds ("Empty fingerprint"), so this one can't reuse the shared 1-second mp3_file fixture.""" import shutil as _shutil if _shutil.which("ffmpeg") is None: pytest.skip("ffmpeg not available to generate audio fixtures") path = tmp_path / "long.mp3" result = subprocess.run( ["ffmpeg", "-y", "-f", "lavfi", "-i", "sine=frequency=440:duration=5", "-codec:a", "libmp3lame", "-b:a", "64k", str(path)], capture_output=True) if result.returncode != 0: pytest.skip("ffmpeg could not generate the fixture") return path class TestFpcalc: def test_fingerprints_a_real_file(self, long_mp3): if not fpcalc_available(): pytest.skip("fpcalc (chromaprint-tools) not installed") duration, fingerprint = fingerprint_file(str(long_mp3)) assert duration > 0 assert isinstance(fingerprint, str) and fingerprint def test_too_short_to_fingerprint_raises_oserror(self, mp3_file): """A one-second file is a real failure mode, not a crash: the error reaches the status bar and the queue moves on.""" if not fpcalc_available(): pytest.skip("fpcalc (chromaprint-tools) not installed") with pytest.raises(OSError, match="fpcalc failed"): fingerprint_file(str(mp3_file)) def test_non_audio_file_raises_oserror(self, tmp_path): if not fpcalc_available(): pytest.skip("fpcalc (chromaprint-tools) not installed") bogus = tmp_path / "notes.txt" bogus.write_text("not audio") with pytest.raises(OSError): fingerprint_file(str(bogus)) def test_missing_binary_raises_oserror(self, monkeypatch): def boom(*args, **kwargs): raise FileNotFoundError("fpcalc") monkeypatch.setattr(subprocess, "run", boom) with pytest.raises(OSError): fingerprint_file("/tmp/whatever.mp3") class _FakeTrack: def __init__(self, **fields): self.track_id = 1 self.name = fields.get("name", "") self.artist = fields.get("artist", "") self.album_artist = fields.get("album_artist", "") self.album = fields.get("album", "") self.year = fields.get("year", 0) self.track_number = fields.get("track_number", 0) self.track_count = fields.get("track_count", 0) self.disc_number = fields.get("disc_number", 0) @pytest.fixture def dialog_cls(qapp): from lintunes.gui.identify_dialog import IdentifyDialog return IdentifyDialog class TestIdentifyDialog: def test_only_differing_fields_start_checked(self, dialog_cls): track = _FakeTrack(name="Sunshine", artist="Unknown") candidate = IdentifyCandidate(score=0.95, name="Sunshine", artist="The Band", year=1969) dialog = dialog_cls(track, [candidate]) # Same as the file already has → nothing to change. assert not dialog._checks["name"].isChecked() # Differs → offered. assert dialog._checks["artist"].isChecked() assert dialog._checks["year"].isChecked() # Nothing proposed → unavailable. assert not dialog._checks["album"].isChecked() assert not dialog._checks["album"].isEnabled() def test_result_fields_returns_checked_rows_only(self, dialog_cls): track = _FakeTrack(name="Sunshine") candidate = IdentifyCandidate(score=0.95, name="Sunshine", artist="The Band", album="First Album", year=1969) dialog = dialog_cls(track, [candidate]) dialog._checks["album"].setChecked(False) assert dialog.result_fields() == {"artist": "The Band", "year": 1969} def test_hand_edited_value_wins(self, dialog_cls): track = _FakeTrack() candidate = IdentifyCandidate(score=0.9, artist="The Band", year=1969) dialog = dialog_cls(track, [candidate]) dialog._editors["artist"].setText("The Band (live)") dialog._editors["year"].setValue(1968) assert dialog.result_fields() == {"artist": "The Band (live)", "year": 1968} def test_switching_candidate_refills_the_grid(self, dialog_cls): track = _FakeTrack() first = IdentifyCandidate(score=0.9, album="First Album", year=1967) second = IdentifyCandidate(score=0.9, album="Greatest Hits", year=1967) dialog = dialog_cls(track, [first, second]) assert dialog._editors["album"].text() == "First Album" dialog._combo.setCurrentIndex(1) assert dialog._editors["album"].text() == "Greatest Hits" assert dialog.result_fields()["album"] == "Greatest Hits" def test_stop_button_sets_cancel_all(self, dialog_cls): dialog = dialog_cls(_FakeTrack(), [IdentifyCandidate(score=0.9, artist="A")], remaining=3) assert dialog.cancel_all is False dialog._stop_all() assert dialog.cancel_all is True def test_no_stop_button_for_a_single_track(self, dialog_cls): from PyQt6.QtWidgets import QPushButton dialog = dialog_cls(_FakeTrack(), [IdentifyCandidate(score=0.9, artist="A")]) labels = [b.text() for b in dialog.findChildren(QPushButton)] assert not any("Stop Identifying" in label for label in labels) class TestContextMenuSignal: def test_table_emits_identify_requested(self, qapp): from lintunes.gui.track_table import TrackTableView table = TrackTableView(playlist_mode=False) assert hasattr(table, "identify_requested") seen = [] table.identify_requested.connect(seen.append) table.identify_requested.emit([1, 2]) assert seen == [[1, 2]] class TestPreferences: def test_acoustid_key_persists(self, tmp_path): from lintunes.preferences import Preferences prefs = Preferences(tmp_path) assert prefs.acoustid["api_key"] == "" prefs.update_acoustid(api_key="ABC123") assert Preferences(tmp_path).acoustid["api_key"] == "ABC123" def test_older_preferences_file_merges_cleanly(self, tmp_path): from lintunes.preferences import Preferences (tmp_path / "preferences.json").write_text( json.dumps({"ui_scale": "large"})) prefs = Preferences(tmp_path) assert prefs.get("ui_scale") == "large" assert prefs.acoustid["api_key"] == ""