diff --git a/CLAUDE.md b/CLAUDE.md index 1acf9d1..584837b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -195,6 +195,28 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal Stdlib only, cached, and every failure path returns `None` — a machine with no Syncthing must still merge, just without naming anyone. +- **`lintunes/fingerprint.py`** — "Identify Track…": Chromaprint's `fpcalc` + CLI (detected with `shutil.which`, the `ffmpeg_available()` pattern — a + runtime tool, never a pip dep) fingerprints the file, and AcoustID's web API + says what it is. Shaped like `art_search.py`: pure parse/rank helpers first + (tested offline against canned JSON), then subprocess/network, then + `TrackIdentifier(QObject)` on a daemon thread emitting a dict carrying either + `candidates` or `error`. **The year rule is the point of the feature**: every + candidate carries the recording's *original* year — the earliest release + across **all** its release groups — even the candidate proposing a later + compilation as the album, because a 60s song must not sort by the year its CD + reissue came out. `_releasegroup_sort_key` separately ranks a plain studio + Album above EP/Single above anything with a Compilation/Live secondary type, + so the default proposal is the real album. `gui/identify_dialog.py` is passive + (the `AlbumArtDialog` contract — nothing written until accepted); the caller + applies `result_fields()` through `LibraryManager.edit_track_fields`, which is + what buys tag writes, undo and artist/album file relocation for free. A row is + pre-checked only where the proposal *differs* from the current value, so an + unchecked field can never quietly blank a tag. The API key is the user's own, + in `preferences.acoustid` (the Last.fm precedent — preferences.json syncs, so + it never belongs in git). Audio under ~3 s has no fingerprint at all + ("Empty fingerprint"), which is a reported failure, not a crash. + - **`lintunes/mpris.py`** — registers `org.mpris.MediaPlayer2.lintunes` over D-Bus so the desktop's media keys / now-playing popup control playback. Spacebar and arrow keys are handled locally via `MainWindow.eventFilter`. diff --git a/TASKS.md b/TASKS.md index ab4a0b0..ee38f57 100644 --- a/TASKS.md +++ b/TASKS.md @@ -9,6 +9,33 @@ When a round closes, move its finished items to `tasks-done.md`. - [ ] archive the done tasks in here to another file, this is crufty.... +## Round 45 (2026-09-02) — Ask the song what it is: done, see tasks-done.md + +Right-click → Identify Track…: Chromaprint fingerprint + AcoustID lookup, a +proposal dialog, oldest-year-wins. Left on the table: + +- [ ] **Batch identify without a dialog per track.** The queue reviews one + track at a time, which is right for a handful and tedious for a hundred. + A "fill only what's empty, above 0.9 confidence, no questions asked" pass + would want the `plan_export()`-then-worker split from `export/exporter.py` + (progress on the status bar, a cancel point) rather than the current + one-at-a-time `_identify_next` chain, plus a summary at the end naming + what it changed. Worth doing only if trav actually has a big pile of + badly-tagged files. +- [ ] **Fetch the cover in the same pass.** An identification hands back + MusicBrainz release IDs, and the Cover Art Archive serves art by release + ID — so a confirmed match could offer artwork without the separate + artist/album text search that `art_search.py` does today. Needs + `meta=releaseids` and a second endpoint; keep the deny-by-default + "nothing is written until accepted" shape. +- [ ] **Genre is still untouched.** AcoustID/MusicBrainz return tags, but + they're folksonomy noise (dozens of overlapping user tags per recording) + and iTunes-style genre is a single word. Deliberately skipped; revisit + only with a mapping trav trusts. +- [ ] **Tracks under ~3 seconds can't be fingerprinted** — Chromaprint returns + "Empty fingerprint" and the status bar says so. Fine for music, would + matter if the library ever holds sound effects. + ## Round 40 (2026-08-22) — welcoming the newcomers: done, see tasks-done.md No startup font modal, a first-run music-folder question, and a portable diff --git a/lintunes/__init__.py b/lintunes/__init__.py index bf9e4ae..4e4d053 100644 --- a/lintunes/__init__.py +++ b/lintunes/__init__.py @@ -1,3 +1,3 @@ """LinTunes — iTunes-style music library manager and player for Linux.""" -__version__ = "0.14.0" +__version__ = "0.15.0" diff --git a/lintunes/fingerprint.py b/lintunes/fingerprint.py new file mode 100644 index 0000000..d7566fc --- /dev/null +++ b/lintunes/fingerprint.py @@ -0,0 +1,248 @@ +"""Track identification via Chromaprint (fpcalc) + the AcoustID web API. + +Pure parsing/ranking helpers are separated from the subprocess and network +calls so they can be tested offline with canned AcoustID JSON; +``TrackIdentifier`` runs the whole fingerprint+lookup on a daemon thread (the +art_search pattern) and reports back over a Qt signal, which is delivered +queued on the GUI thread. + +fpcalc is the Chromaprint *CLI binary* (Fedora: chromaprint-tools, Debian: +libchromaprint-tools) — detected at runtime, never a pip dependency. The +AcoustID application key is user-supplied via Preferences (the Last.fm +precedent); registration is free at https://acoustid.org/new-application. +""" +import json +import shutil +import subprocess +import threading +from dataclasses import asdict, dataclass + +from PyQt6.QtCore import QObject, pyqtSignal + + +ACOUSTID_LOOKUP_URL = "https://api.acoustid.org/v2/lookup" +LOOKUP_META = "recordings releasegroups releases tracks compress" +SCORE_THRESHOLD = 0.5 # AcoustID scores below this are noise +MAX_CANDIDATES = 8 +TIMEOUT_S = 15 +FPCALC_TIMEOUT_S = 60 + + +@dataclass +class IdentifyCandidate: + """One proposed identification. Field names match Track/tagging so + ``fields()`` can go straight into ``LibraryManager.edit_track_fields``.""" + score: float + name: str | None = None + artist: str | None = None + album_artist: str | None = None + album: str | None = None + year: int | None = None + track_number: int | None = None + track_count: int | None = None + disc_number: int | None = None + + def fields(self) -> dict: + """Proposed edits keyed by LinTunes field names, Nones omitted.""" + return {k: v for k, v in asdict(self).items() + if k != "score" and v is not None} + + +# ---- pure parsing/ranking helpers (offline-testable) ---- + +def _join_artists(artists: list | None) -> str | None: + """Concatenate MusicBrainz artist credits, honoring ``joinphrase``.""" + if not artists: + return None + parts = [] + for i, artist in enumerate(artists): + name = artist.get("name", "") + if not name: + continue + if parts: + parts.append(artists[i - 1].get("joinphrase") or "; ") + parts.append(name) + return "".join(parts) or None + + +def _release_year(release: dict) -> int | None: + year = (release.get("date") or {}).get("year") + return year if isinstance(year, int) and year > 0 else None + + +def _oldest_year(releasegroup: dict) -> int | None: + """Earliest dated release in the group — the original pressing, not the + CD reissue.""" + years = [y for r in releasegroup.get("releases", []) + if (y := _release_year(r)) is not None] + return min(years) if years else None + + +def _releasegroup_sort_key(releasegroup: dict) -> tuple: + """Rank a release group for the default proposal: the earliest proper + studio Album first, then EPs/Singles, then Compilations/Live/etc.""" + rg_type = releasegroup.get("type") or "" + secondary = releasegroup.get("secondarytypes") or [] + if not secondary: + rank = {"Album": 0, "EP": 1, "Single": 2}.get(rg_type, 4) + else: + rank = 3 if rg_type == "Album" else 4 + return (rank, _oldest_year(releasegroup) or 9999) + + +def _pick_release(releasegroup: dict) -> dict | None: + """Earliest dated release that carries mediums (track positions); falls + back to the earliest dated one, then the first.""" + releases = releasegroup.get("releases", []) + if not releases: + return None + dated = sorted(releases, key=lambda r: _release_year(r) or 9999) + for release in dated: + if release.get("mediums"): + return release + return dated[0] + + +def _track_position(release: dict | None) -> tuple[int | None, int | None, + int | None]: + """(track_number, track_count, disc_number) from the matching medium. + + With ``meta=tracks`` AcoustID returns only the medium/track entries that + match the looked-up recording, so the first entries are the match. + """ + if not release: + return None, None, None + mediums = release.get("mediums") or [] + if not mediums: + return None, None, None + medium = mediums[0] + tracks = medium.get("tracks") or [] + number = tracks[0].get("position") if tracks else None + count = medium.get("track_count") + disc = medium.get("position") + return (number if isinstance(number, int) else None, + count if isinstance(count, int) else None, + disc if isinstance(disc, int) else None) + + +def parse_lookup(payload: dict, threshold: float = SCORE_THRESHOLD, + limit: int = MAX_CANDIDATES) -> list[IdentifyCandidate]: + """Turn an AcoustID lookup response into ranked candidates. + + One candidate per release group of each matched recording, so the + alternates offered in the dialog genuinely differ (different album/year). + Every candidate of a recording carries the recording's *original* year — + the minimum across all its release groups' releases — even when the + proposed album is a later compilation: years sort by when the song first + came out, not by which pressing this file happens to be from. + """ + candidates = [] + seen = set() + results = sorted(payload.get("results", []), + key=lambda r: r.get("score", 0), reverse=True) + for result in results: + score = result.get("score", 0) + if score < threshold: + continue + for recording in result.get("recordings", []): + title = recording.get("title") + artist = _join_artists(recording.get("artists")) + groups = sorted(recording.get("releasegroups") or [], + key=_releasegroup_sort_key) + original_years = [y for rg in groups + if (y := _oldest_year(rg)) is not None] + original_year = min(original_years) if original_years else None + if not groups: + if title: + candidates.append(IdentifyCandidate( + score=score, name=title, artist=artist)) + continue + for releasegroup in groups: + album = releasegroup.get("title") + key = (title, artist, album) + if key in seen: + continue + seen.add(key) + number, count, disc = _track_position( + _pick_release(releasegroup)) + candidates.append(IdentifyCandidate( + score=score, + name=title, + artist=artist, + album_artist=_join_artists(releasegroup.get("artists")), + album=album, + year=original_year, + track_number=number, + track_count=count, + disc_number=disc, + )) + return candidates[:limit] + + +# ---- subprocess + network ---- + +def fpcalc_available() -> bool: + """Whether the fpcalc *binary* is on PATH (the ffmpeg_available pattern — + Chromaprint's CLI tool, not a Python package).""" + return shutil.which("fpcalc") is not None + + +def fingerprint_file(location: str) -> tuple[int, str]: + """Run fpcalc on a file; returns (duration seconds, fingerprint). + Raises OSError on failure, carrying fpcalc's last stderr line.""" + try: + proc = subprocess.run( + ["fpcalc", "-json", location], + stdin=subprocess.DEVNULL, capture_output=True, + timeout=FPCALC_TIMEOUT_S) + except (OSError, ValueError, subprocess.TimeoutExpired) as e: + raise OSError(f"couldn't run fpcalc: {e}") from e + name = location.rsplit("/", 1)[-1] + if proc.returncode != 0: + detail = proc.stderr.decode("utf-8", "replace").strip().splitlines() + raise OSError(f"fpcalc failed on {name}" + + (f": {detail[-1]}" if detail else "")) + try: + payload = json.loads(proc.stdout.decode("utf-8", "replace")) + return int(payload["duration"]), payload["fingerprint"] + except (ValueError, KeyError, TypeError) as e: + raise OSError(f"couldn't read fpcalc output for {name}") from e + + +def lookup_fingerprint(api_key: str, duration: int, + fingerprint: str) -> list[IdentifyCandidate]: + import requests + response = requests.post( + ACOUSTID_LOOKUP_URL, + data={"client": api_key, "duration": duration, + "fingerprint": fingerprint, "meta": LOOKUP_META, + "format": "json"}, + timeout=TIMEOUT_S, + ) + response.raise_for_status() + payload = response.json() + if payload.get("status") != "ok": + message = (payload.get("error") or {}).get("message", "lookup failed") + raise RuntimeError(message) + return parse_lookup(payload) + + +class TrackIdentifier(QObject): + """One fingerprint+lookup running off the GUI thread. + + ``finished`` carries {"track_id": int, "candidates": [IdentifyCandidate]} + or {"track_id": int, "error": str}. + """ + + finished = pyqtSignal(object) + + def identify(self, track_id: int, location: str, api_key: str): + def work(): + try: + duration, fp = fingerprint_file(location) + candidates = lookup_fingerprint(api_key, duration, fp) + self.finished.emit( + {"track_id": track_id, "candidates": candidates}) + except Exception as e: + self.finished.emit({"track_id": track_id, "error": str(e)}) + threading.Thread(target=work, daemon=True).start() diff --git a/lintunes/gui/identify_dialog.py b/lintunes/gui/identify_dialog.py new file mode 100644 index 0000000..f53afd3 --- /dev/null +++ b/lintunes/gui/identify_dialog.py @@ -0,0 +1,143 @@ +"""Confirmation dialog for a track identification (Identify Track…). + +Shows the proposed tag changes for one track — current value vs an editable +proposed value per field, with a checkbox to opt out of any row — and a +dropdown of alternate candidates (other albums/releases the recording appears +on). Passive like AlbumArtDialog: candidates arrive pre-parsed, nothing is +written until the user accepts, and the caller applies ``result_fields()`` +through LibraryManager afterwards. +""" +from PyQt6.QtWidgets import ( + QCheckBox, QComboBox, QDialog, QDialogButtonBox, QGridLayout, QLabel, + QLineEdit, QPushButton, QSpinBox, QVBoxLayout, +) + +# (field, label, is_number, spinbox max) +_FIELDS = [ + ("name", "Name", False, 0), + ("artist", "Artist", False, 0), + ("album_artist", "Album Artist", False, 0), + ("album", "Album", False, 0), + ("year", "Year", True, 9999), + ("track_number", "Track #", True, 999), + ("track_count", "Track Count", True, 999), + ("disc_number", "Disc #", True, 99), +] + + +class IdentifyDialog(QDialog): + def __init__(self, track, candidates, remaining: int = 0, parent=None): + super().__init__(parent) + self.setWindowTitle("Identify Track") + self._track = track + self._candidates = candidates + # True when the user pressed "Stop Identifying" on a queued batch. + self.cancel_all = False + + layout = QVBoxLayout(self) + best = candidates[0] + header = QLabel(f"Identified “{track.name or '(untitled)'}” — " + f"match confidence {round(best.score * 100)}%") + header.setWordWrap(True) + layout.addWidget(header) + + # All alternates at a glance — a candidate summarizes to one line, so + # a dropdown beats AlbumArtDialog's blind Next-clicking. + self._combo = QComboBox() + for c in candidates: + year = f" ({c.year})" if c.year else "" + self._combo.addItem(" — ".join( + p for p in (c.name, c.artist, c.album) if p) + year) + layout.addWidget(self._combo) + + grid = QGridLayout() + grid.setColumnStretch(1, 1) + grid.setColumnStretch(3, 1) + self._checks: dict[str, QCheckBox] = {} + self._current_labels: dict[str, QLabel] = {} + self._editors: dict[str, QLineEdit | QSpinBox] = {} + for row, (field, label, is_number, maximum) in enumerate(_FIELDS): + check = QCheckBox(label) + current = QLabel() + current.setEnabled(False) # the greyed-hint idiom + arrow = QLabel("→") + if is_number: + editor = QSpinBox() + editor.setRange(0, maximum) + editor.setSpecialValueText(" ") + else: + editor = QLineEdit() + check.toggled.connect(editor.setEnabled) + self._checks[field] = check + self._current_labels[field] = current + self._editors[field] = editor + grid.addWidget(check, row, 0) + grid.addWidget(current, row, 1) + grid.addWidget(arrow, row, 2) + grid.addWidget(editor, row, 3) + layout.addLayout(grid) + + buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok + | QDialogButtonBox.StandardButton.Cancel) + buttons.button(QDialogButtonBox.StandardButton.Ok).setText( + "Apply Tags") + if remaining > 0: + noun = "track" if remaining == 1 else "tracks" + stop_btn = QPushButton(f"Stop Identifying ({remaining} {noun} left)") + buttons.addButton(stop_btn, + QDialogButtonBox.ButtonRole.ActionRole) + stop_btn.clicked.connect(self._stop_all) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + self._combo.currentIndexChanged.connect(self._fill_from_candidate) + self._fill_from_candidate(0) + + # ---- candidate switching ---- + + def _current_value(self, field): + return getattr(self._track, field, None) + + def _fill_from_candidate(self, index: int): + """Re-fill the grid from a candidate (hand edits are discarded on a + switch, like AlbumArtDialog cycling). A row is checked and editable + iff the candidate proposes a value that differs from the current one; + rows with nothing proposed are unchecked and disabled.""" + candidate = self._candidates[index] + for field, _label, is_number, _max in _FIELDS: + current = self._current_value(field) + proposed = getattr(candidate, field) + label = self._current_labels[field] + editor = self._editors[field] + check = self._checks[field] + label.setText(str(current) if current else "—") + if is_number: + editor.setValue(proposed or 0) + else: + editor.setText(proposed or "") + has_proposal = proposed is not None + differs = has_proposal and proposed != (current or None) + check.setEnabled(has_proposal) + check.setChecked(differs) + editor.setEnabled(differs) + + def _stop_all(self): + self.cancel_all = True + self.reject() + + # ---- result ---- + + def result_fields(self) -> dict: + """Checked rows only, keyed by LinTunes field names — ready for + ``LibraryManager.edit_track_fields``. Blank editors are dropped (a + checked row the user then emptied proposes nothing).""" + fields = {} + for field, _label, is_number, _max in _FIELDS: + if not self._checks[field].isChecked(): + continue + editor = self._editors[field] + value = editor.value() if is_number else editor.text().strip() + if value: + fields[field] = value + return fields diff --git a/lintunes/gui/main_window.py b/lintunes/gui/main_window.py index cfe7f06..882e76e 100644 --- a/lintunes/gui/main_window.py +++ b/lintunes/gui/main_window.py @@ -14,12 +14,14 @@ from lintunes.art_search import AlbumArtFetcher from lintunes.cast.controller import CastController from lintunes.eventlog import log_control from lintunes.export import exporter +from lintunes.fingerprint import TrackIdentifier, fpcalc_available from lintunes.inhibit import INHIBIT_LOGOUT, INHIBIT_SUSPEND, SleepInhibitor from lintunes.player import Player from lintunes.importers import file_importer from lintunes.gui.album_art_dialog import AlbumArtDialog from lintunes.gui.cast_dialog import ChromecastDialog from lintunes.gui.export_dialog import ExportKindDialog, WebMixDialog +from lintunes.gui.identify_dialog import IdentifyDialog from lintunes.gui.sidebar import SidebarPanel from lintunes.gui.library_view import LibraryView from lintunes.gui.playlist_view import PlaylistView @@ -159,6 +161,11 @@ class MainWindow(QMainWindow): # Export shares the status-bar progress widgets with device sync — # only one of the two may run at a time (each guards on the other). self._export_worker = None + # Identify Track… works through its selection one at a time, each + # behind its own review dialog. + self._identify_queue = [] + self._identify_total = 0 + self._identifier = None # Separate instance from the playback inhibitor: pausing music # mid-sync must not drop the sync's hold. Logout flag included so # GNOME's shutdown/restart dialog names the transfer as the blocker. @@ -182,6 +189,7 @@ class MainWindow(QMainWindow): view.show_in_playlist_requested.connect(self._show_track_in_playlist) view.table.tracks_changed.connect(self._update_totals) view.table.download_art_requested.connect(self._download_album_art) + view.table.identify_requested.connect(self._identify_tracks) view.table.remove_from_library_requested.connect( lambda ids: self._delete_tracks(ids, delete_files=False)) view.table.delete_from_library_requested.connect( @@ -1077,6 +1085,88 @@ class MainWindow(QMainWindow): self.statusBar().showMessage( f"Artwork embedded in {done} {noun}", 5000) + # ---- track identification (fingerprint lookup) ---- + + def _identify_tracks(self, track_ids: list[int]): + """Fingerprint the selection and look each track up on AcoustID, one + at a time, each behind its own proposal dialog.""" + if not fpcalc_available(): + QMessageBox.warning( + self, "Identify Track", + "Track identification needs the fpcalc tool from Chromaprint.\n\n" + "Install it (Fedora: chromaprint-tools, Debian: " + "libchromaprint-tools) and try again.") + return + api_key = self._prefs.acoustid.get("api_key", "").strip() + if not api_key: + QMessageBox.information( + self, "Identify Track", + "AcoustID needs a free application key.\n\n" + "Register one at acoustid.org/new-application, then paste it " + "into Preferences under Track Identification.") + return + tracks = [] + skipped = 0 + for tid in track_ids: + track = self._manager.library.tracks.get(tid) + if track is None: + continue + if not track.location: + skipped += 1 + continue + tracks.append(track) + if skipped: + self.statusBar().showMessage( + f"Skipped {skipped} track(s) with no file to fingerprint", 6000) + if not tracks: + return + self._identify_queue = tracks + self._identify_total = len(tracks) + self._identify_next(api_key) + + def _identify_next(self, api_key: str): + if not self._identify_queue: + return + track = self._identify_queue.pop(0) + done = self._identify_total - len(self._identify_queue) + counter = (f" ({done} of {self._identify_total})" + if self._identify_total > 1 else "") + self.statusBar().showMessage( + f"Identifying “{track.name}”{counter}…") + # Kept as an attribute so the identifier (and its thread's signal + # source) outlives this method. + self._identifier = TrackIdentifier() + self._identifier.finished.connect( + lambda result, t=track, k=api_key: self._on_identified(t, k, result)) + self._identifier.identify(track.track_id, track.location, api_key) + + def _on_identified(self, track, api_key: str, result: dict): + if "error" in result: + self.statusBar().showMessage( + f"Couldn't identify “{track.name}”: {result['error']}", 6000) + elif not result["candidates"]: + self.statusBar().showMessage( + f"No match found for “{track.name}”", 6000) + else: + self.statusBar().clearMessage() + dialog = IdentifyDialog(track, result["candidates"], + remaining=len(self._identify_queue), + parent=self) + accepted = dialog.exec() + if accepted: + fields = dialog.result_fields() + if fields: + # The edit funnel writes the tags, may relocate the file + # and records one undo step — see LibraryManager. + self._manager.edit_track_fields(track.track_id, fields) + self.statusBar().showMessage( + f"Updated tags for “{track.name}”", 5000) + elif dialog.cancel_all: + self._identify_queue.clear() + self.statusBar().clearMessage() + return + self._identify_next(api_key) + # ---- relocating a moved/renamed file ---- def _existing_ancestor(self, path: str) -> str: diff --git a/lintunes/gui/preferences_dialog.py b/lintunes/gui/preferences_dialog.py index 76e8fc1..b0cce23 100644 --- a/lintunes/gui/preferences_dialog.py +++ b/lintunes/gui/preferences_dialog.py @@ -55,6 +55,7 @@ class PreferencesDialog(QDialog): layout.addWidget(self._build_music_group()) layout.addWidget(self._build_colors_group()) layout.addWidget(self._build_lastfm_group()) + layout.addWidget(self._build_acoustid_group()) layout.addStretch() lastfm.login_finished.connect(self._on_login_finished) @@ -286,3 +287,28 @@ class PreferencesDialog(QDialog): if logged_in and not self._status_label.text(): self._status_label.setText( f"Logged in as {self._prefs.lastfm.get('username', '')}") + + # ---- Track identification (AcoustID) ---- + + def _build_acoustid_group(self) -> QGroupBox: + group = QGroupBox("Track Identification") + form = QFormLayout(group) + + hint = QLabel( + 'Right-click a track → Identify Track… Needs a free application ' + 'key: ' + 'acoustid.org/new-application') + hint.setOpenExternalLinks(True) + hint.setWordWrap(True) + form.addRow(hint) + + self._acoustid_key = QLineEdit( + self._prefs.acoustid.get("api_key", "")) + self._acoustid_key.editingFinished.connect(self._save_acoustid_key) + form.addRow("AcoustID key:", self._acoustid_key) + return group + + def _save_acoustid_key(self): + key = self._acoustid_key.text().strip() + if key != self._prefs.acoustid.get("api_key", ""): + self._prefs.update_acoustid(api_key=key) diff --git a/lintunes/gui/track_table.py b/lintunes/gui/track_table.py index 10c407d..a962c5e 100644 --- a/lintunes/gui/track_table.py +++ b/lintunes/gui/track_table.py @@ -506,6 +506,7 @@ class TrackTableView(QTableView): show_in_playlist_requested = pyqtSignal(int, str) # track_id, playlist pid rating_edited = pyqtSignal(int, int) # track_id, new rating 0-100 download_art_requested = pyqtSignal(list) # selected track ids + identify_requested = pyqtSignal(list) # selected track ids remove_from_library_requested = pyqtSignal(list) # track ids, file kept delete_from_library_requested = pyqtSignal(list) # track ids, file trashed @@ -765,11 +766,14 @@ class TrackTableView(QTableView): locations = self.selected_locations() reveal_action = copy_path_action = download_art_action = None + identify_action = None if locations: menu.addSeparator() reveal_action = menu.addAction("Reveal in File Browser") copy_path_action = menu.addAction("Copy File Path") download_art_action = menu.addAction("Download Album Art…") + # Fingerprinting reads the audio, so this needs a file on disk. + identify_action = menu.addAction("Identify Track…") # "Show in Playlist" — only for a single track, listing the regular # playlists it belongs to. Hovering reveals the submenu; choosing an @@ -813,6 +817,8 @@ class TrackTableView(QTableView): QApplication.clipboard().setText("\n".join(locations)) elif download_art_action is not None and chosen is download_art_action: self.download_art_requested.emit(self.selected_track_ids()) + elif identify_action is not None and chosen is identify_action: + self.identify_requested.emit(self.selected_track_ids()) elif show_in_menu is not None and chosen in show_in_menu.actions(): self.show_in_playlist_requested.emit(selected[0], chosen.data()) elif remove_action is not None and chosen is remove_action: diff --git a/lintunes/preferences.py b/lintunes/preferences.py index 40595b4..c421438 100644 --- a/lintunes/preferences.py +++ b/lintunes/preferences.py @@ -28,6 +28,11 @@ DEFAULTS = { "session_key": "", "scrobble_enabled": False, }, + # Track identification (Identify Track…). The application key is the + # user's own, registered free at acoustid.org/new-application. + "acoustid": { + "api_key": "", + }, } @@ -75,6 +80,15 @@ class Preferences(QObject): def lastfm(self) -> dict: return self._data["lastfm"] + def update_acoustid(self, **fields): + self._data["acoustid"].update(fields) + self.save() + self.changed.emit() + + @property + def acoustid(self) -> dict: + return self._data["acoustid"] + def save(self): self._path.parent.mkdir(parents=True, exist_ok=True) tmp = self._path.with_suffix(".json.tmp") diff --git a/tasks-done.md b/tasks-done.md index 264fb26..54f5698 100644 --- a/tasks-done.md +++ b/tasks-done.md @@ -1,5 +1,52 @@ ## Done +### Round 45 (2026-09-02) — Ask the song what it is (v0.15.0) + +Some files arrive with the title right and everything else missing or wrong, +and there was no way to fix that except typing. Now the audio itself is the +question: right-click → **Identify Track…** fingerprints the file and asks +AcoustID who it is. + +- [x] **Fingerprint, not filename guessing.** New `lintunes/fingerprint.py`: + `fpcalc -json` (Chromaprint's CLI, detected with `shutil.which` like + `ffmpeg_available()` — a runtime tool, not a pip dependency) produces a + duration + fingerprint, which `lookup_fingerprint` POSTs to the AcoustID + web API with `meta=recordings releasegroups releases tracks compress`. + `requests` was already a dep, so the round adds none. +- [x] **The year is the song's, not the pressing's.** trav's actual complaint: + "I don't care when the CD of something from the 60s came out." So + `parse_lookup` proposes the *original* release year — the earliest date + across every release group the recording appears on — and every candidate + carries it, including the one proposing a 1998 greatest-hits as the album. + Years sort by when the song came out. `_releasegroup_sort_key` separately + ranks a plain studio Album above EP/Single above anything wearing a + Compilation/Live secondary type, so the *default* proposal is the real + album too. +- [x] **Nothing is written until you say so.** `gui/identify_dialog.py` is + passive the way `AlbumArtDialog` is — candidates arrive pre-parsed, and + the caller applies the result afterwards. Each row shows the current value + beside an editable proposed one, with a checkbox that starts checked only + where the proposal actually differs from what the file already has (a row + the lookup knows nothing about is disabled, so it can't quietly blank a + tag). A dropdown switches between alternate releases. +- [x] **Applied through the one funnel.** `LibraryManager.edit_track_fields` + does it, so tag writes, the abort-if-the-write-fails rule, artist/album + file relocation and a single undo step all come free rather than being + re-implemented. +- [x] **A selection is a queue, one dialog at a time.** Each track is reviewed + on its own; "Stop Identifying (N left)" abandons the rest, and a failure + (unfingerprintable file, network error, no match) is a status-bar message + that moves on to the next rather than ending the batch. +- [x] **The key is trav's, and stays out of git.** `preferences.acoustid` + + a Preferences row linking acoustid.org/new-application, following the + Last.fm precedent — preferences.json rides the Syncthing share, so + pasting it once covers both machines. +- [x] Fixed an unrelated pre-existing test failure: round 40's + `test_the_pre_010_music_root_key_counts_as_an_override` used the real + tummult mount point as its stand-in for an unreachable path, so it failed + whenever that drive was actually plugged in. It now builds the path under + `tmp_path` and never creates it. + ### Round 44 (2026-08-27) — A removal you make sticks (v0.14.0) Round 43 made the merge report honest, which made its one real limitation diff --git a/tests/test_round40.py b/tests/test_round40.py index 27a6c5d..f964062 100644 --- a/tests/test_round40.py +++ b/tests/test_round40.py @@ -253,7 +253,9 @@ class TestMusicFolderResolution: save_config({"music_root": str(media)}) # The shared value is another machine's mount point, unreachable here. - gone = Library(music_folder="/run/media/trav/tummult/music/iTunes Media") + # Built under tmp_path and never created, so this stays true even when + # the real tummult drive happens to be mounted on the test machine. + gone = Library(music_folder=str(tmp_path / "not-mounted" / "iTunes Media")) state = music_folder.resolve(gone, tmp_path) assert state.path == media diff --git a/tests/test_round45.py b/tests/test_round45.py new file mode 100644 index 0000000..bae1ca2 --- /dev/null +++ b/tests/test_round45.py @@ -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"] == ""