Files
lintunes/lintunes/gui/identify_dialog.py
T
trav 312748d05e 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.

Claude-Session: https://claude.ai/code/session_01NsiFHdyVg1UhBSxDJfTNRm
2026-09-02 15:40:52 -04:00

144 lines
5.8 KiB
Python

"""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