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:
2026-09-02 15:40:52 -04:00
co-authored by Claude Opus 5
parent 8c097faacc
commit f0d4d1eff2
12 changed files with 946 additions and 2 deletions
+22
View File
@@ -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`.
+27
View File
@@ -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
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.14.0"
__version__ = "0.15.0"
+248
View File
@@ -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()
+143
View File
@@ -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
+90
View File
@@ -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:
+26
View File
@@ -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: <a href="https://acoustid.org/new-application">'
'acoustid.org/new-application</a>')
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)
+6
View File
@@ -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:
+14
View File
@@ -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")
+47
View File
@@ -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
+3 -1
View File
@@ -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
+319
View File
@@ -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"] == ""