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:
@@ -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`.
|
||||
|
||||
@@ -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,3 +1,3 @@
|
||||
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
||||
|
||||
__version__ = "0.14.0"
|
||||
__version__ = "0.15.0"
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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