v0.15.0: ask the song what it is
Right-click → Identify Track… fingerprints the file with Chromaprint's fpcalc and asks AcoustID who it is, then proposes the tags it found. The year is the point. A 60s song kept getting stamped with the year its CD reissue came out, which sorts the library wrong, so every candidate carries the recording's *original* year — the earliest release across all of its release groups — including the candidate that proposes a later greatest-hits as the album. Ranking separately prefers a plain studio Album over EP/Single over anything wearing a Compilation or Live secondary type, so the default proposal is the real album as well. Nothing is written until accepted: the dialog shows each current value beside an editable proposed one, and a row starts checked only where the proposal actually differs from what the file already has, so an untouched field can never quietly blank a tag. Applying goes through edit_track_fields, which is what buys the tag write, the abort-if-it-fails rule, artist/album file relocation and one undo step without re-deriving any of them. A selection is a queue reviewed one track at a time; a failure is a status-bar message that moves on rather than ending the batch. fpcalc is detected at runtime like ffmpeg and the AcoustID key lives in preferences (so it syncs, and stays out of git) — the round adds no new dependency. Also fixes a pre-existing round 40 test that used the real tummult mount point as its stand-in for an unreachable path, so it failed whenever that drive was plugged in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NsiFHdyVg1UhBSxDJfTNRm
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
"""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"] == ""
|
||||
Reference in New Issue
Block a user