v0.38.0: several imported songs are identified as one batch

No identify dialog pops up mid-download any more: songs are looked up
quietly as they land and reviewed together once the last one is in, as
rows in the app's columns. Songs that are one album get the album-wide
fields once, unified, plus a cover from the album-art search. Ticked
rows apply as one undo step; unticked ones get the ordinary dialog
after. Popups wait for LinTunes to be the app in front.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 01:42:13 -07:00
co-authored by Claude Opus 5.5
parent 581be8d732
commit f899794068
9 changed files with 1150 additions and 8 deletions
+19
View File
@@ -378,6 +378,25 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
track number into the mp3 **before** emitting `downloaded`, so the
import files it under the right artist and Identify ranks by that artist.
If the embed page changes shape, `parse_embed` raises `SpotifyError`.
**Several songs are identified as one batch** (Round 77,
`identify_batch.py` + `gui/identify_batch_dialog.py`): an import of more
than one song never pops a dialog mid-download. `IdentifyCollector` looks
each up quietly as it lands, and only after `finished`/`failed` *and* the
last lookup does one review window open, with all the songs as rows in the
app's columns. `find_common_album` makes it "one album" when every song
naming an album can be on the same one (its own tags vote too, which is
what carries a Spotify album through AcoustID's compilations). Then the
album-wide fields are unified to the most common value, shown once above
the table, and the cover comes from the same `AlbumArtFetcher` search.
Ticked rows go through `LibraryManager.edit_many_track_fields` as **one**
undo step. Unticked rows get the ordinary `IdentifyDialog` afterwards,
from the lookups already made. **No popup may land on top of another
app**: `MainWindow._when_app_active` shows it at once only while LinTunes
is the active application, and otherwise asks for attention
(`QApplication.alert`) and waits for `applicationStateChanged`. A window
test that downloads several songs must stub `_show_batch_review` and
cancel `_url_batch` on teardown, or a late lookup opens a modal dialog
in the next test.
- **`lintunes/mpris.py`** — registers `org.mpris.MediaPlayer2.lintunes` over D-Bus
so the desktop's media keys / now-playing popup control playback. Spacebar and
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.37.0"
__version__ = "0.38.0"
+384
View File
@@ -0,0 +1,384 @@
"""Review a whole import's proposed tags at once (Round 77).
The rows are the songs, the columns the app's own (Name, Artist, Album …),
and every cell is editable. A value that would change is **bold**, and its
tooltip says what it was. Ticked rows are applied together, as one undo
step. Unticked rows come back afterwards one at a time in the ordinary
Identify dialog, and **Review Each Song** sends all of them that way.
When ``identify_batch.analyze`` decided the songs are one album, the
album-wide fields sit above the table, once. Editing them edits every row.
Beside them is the album's cover from the same search Download Album Art
uses, with Next Artwork to cycle through the results.
Passive like IdentifyDialog: nothing is written until the caller reads
``edits()``, ``unchecked_rows()`` and ``selected_image`` after an accept.
"""
import re
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QFont, QPixmap
from PyQt6.QtWidgets import (
QAbstractItemView, QCheckBox, QDialog, QDialogButtonBox, QFormLayout,
QHBoxLayout, QHeaderView, QLabel, QLineEdit, QPushButton, QSpinBox,
QTableWidget, QTableWidgetItem, QVBoxLayout,
)
from lintunes.art_search import AlbumArtFetcher
ART_PX = 180
# (header, fields shown in the column)
COLUMNS = [
("Name", ("name",)),
("Artist", ("artist",)),
("Album Artist", ("album_artist",)),
("Album", ("album",)),
("Year", ("year",)),
("Track", ("track_number", "track_count")),
("Disc", ("disc_number",)),
]
CHECK_COL = 0
FIRST_FIELD_COL = 1
SOURCE_COL = FIRST_FIELD_COL + len(COLUMNS)
_TRACK = re.compile(r"^\s*(\d+)?\s*(?:(?:of|/)\s*(\d+))?\s*$")
def format_cell(fields: tuple, values: dict) -> str:
if fields == ("track_number", "track_count"):
number, count = values.get("track_number"), values.get("track_count")
if number and count:
return f"{number} of {count}"
if number:
return str(number)
return f"of {count}" if count else ""
value = values.get(fields[0])
return "" if value in (None, 0) else str(value)
def parse_cell(fields: tuple, text: str) -> dict:
"""A cell's text back into fields (None where it's blank or unreadable,
which means "leave it as it is")."""
text = (text or "").strip()
if fields == ("track_number", "track_count"):
m = _TRACK.match(text)
if not m:
return {"track_number": None, "track_count": None}
return {"track_number": int(m.group(1)) if m.group(1) else None,
"track_count": int(m.group(2)) if m.group(2) else None}
name = fields[0]
if name in ("year", "disc_number"):
return {name: int(text) if text.isdigit() and int(text) else None}
return {name: text or None}
class IdentifyBatchDialog(QDialog):
APPLY, REVIEW_ALL = 1, 2
def __init__(self, analysis, art_query=None, parent=None):
super().__init__(parent)
self.setWindowTitle("Identify Songs")
self._analysis = analysis
self._rows = list(analysis.rows)
if analysis.is_album:
self._rows.sort(key=lambda r: (r.proposal.get("disc_number") or 0,
r.proposal.get("track_number")
or 999))
self.outcome = 0
self.selected_image: bytes | None = None
self.selected_mime = "image/jpeg"
self._art_candidates = []
self._art_index = 0
self._art_image = None
self._art_mime = "image/jpeg"
self._fetcher = None
self._filling = False
layout = QVBoxLayout(self)
n = len(self._rows)
header = QLabel(
f"These {n} songs look like one album." if analysis.is_album
else f"Proposed tags for {n} songs.")
bold = header.font()
bold.setBold(True)
header.setFont(bold)
layout.addWidget(header)
self._album_edit = self._album_artist_edit = self._year_edit = None
self._embed = None
if analysis.is_album:
layout.addLayout(self._album_panel(art_query))
self._table = QTableWidget(n, SOURCE_COL + 1)
self._table.setHorizontalHeaderLabels(
[""] + [h for h, _f in COLUMNS] + ["Found by"])
self._table.verticalHeader().hide()
self._table.setSelectionBehavior(
QAbstractItemView.SelectionBehavior.SelectRows)
self._table.setEditTriggers(
QAbstractItemView.EditTrigger.DoubleClicked
| QAbstractItemView.EditTrigger.EditKeyPressed
| QAbstractItemView.EditTrigger.AnyKeyPressed)
head = self._table.horizontalHeader()
head.setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
head.setSectionResizeMode(CHECK_COL,
QHeaderView.ResizeMode.ResizeToContents)
head.setStretchLastSection(True)
self._fill_table()
self._table.itemChanged.connect(self._on_item_changed)
for col, width in zip(range(FIRST_FIELD_COL, SOURCE_COL),
(200, 150, 120, 170, 55, 70, 45)):
self._table.setColumnWidth(col, width)
layout.addWidget(self._table, 1)
hint = QLabel("Bold is what changes (hover to see what it was). "
"Double-click to edit. Unticked songs are shown one "
"at a time afterwards.")
hint.setEnabled(False)
hint.setWordWrap(True)
layout.addWidget(hint)
buttons = QDialogButtonBox()
self._apply_btn = QPushButton()
self._apply_btn.setDefault(True)
buttons.addButton(self._apply_btn,
QDialogButtonBox.ButtonRole.AcceptRole)
review = QPushButton("Review Each Song…")
review.setToolTip("Skip this list and confirm every song in its own "
"window")
buttons.addButton(review, QDialogButtonBox.ButtonRole.ActionRole)
cancel = buttons.addButton(QDialogButtonBox.StandardButton.Cancel)
cancel.setToolTip("Stop identifying: the songs keep the tags they "
"have")
self._apply_btn.clicked.connect(self._on_apply)
review.clicked.connect(self._on_review_all)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
self._update_apply()
self._table.setFocus() # not the Album box: nothing to type yet
self.resize(1000, min(260 + 30 * n + (ART_PX if analysis.is_album
else 0), 820))
# ---- the album panel ----
def _album_panel(self, art_query):
a = self._analysis
row = QHBoxLayout()
self._art = QLabel("Searching…" if art_query else "No artwork search")
self._art.setFixedSize(ART_PX, ART_PX)
self._art.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._art.setStyleSheet("border: 1px solid palette(mid);")
self._art.setWordWrap(True)
row.addWidget(self._art)
side = QVBoxLayout()
form = QFormLayout()
self._album_edit = QLineEdit(a.album or "")
self._album_artist_edit = QLineEdit(a.album_artist or "")
self._album_artist_edit.setPlaceholderText("(none)")
self._year_edit = QSpinBox()
self._year_edit.setRange(0, 9999)
self._year_edit.setSpecialValueText(" ")
self._year_edit.setValue(a.year or 0)
form.addRow("Album", self._album_edit)
form.addRow("Album Artist", self._album_artist_edit)
form.addRow("Year", self._year_edit)
self._album_edit.textEdited.connect(
lambda t: self._set_column(("album",), t))
self._album_artist_edit.textEdited.connect(
lambda t: self._set_column(("album_artist",), t))
self._year_edit.valueChanged.connect(
lambda v: self._set_column(("year",), str(v) if v else ""))
side.addLayout(form)
self._art_caption = QLabel()
self._art_caption.setWordWrap(True)
self._art_caption.setEnabled(False)
side.addWidget(self._art_caption)
art_row = QHBoxLayout()
self._embed = QCheckBox(f"Embed this artwork in all "
f"{len(self._rows)} songs")
self._embed.setEnabled(False)
self._next_art = QPushButton("Next Artwork")
self._next_art.setEnabled(False)
self._next_art.clicked.connect(self._on_next_art)
art_row.addWidget(self._embed)
art_row.addWidget(self._next_art)
art_row.addStretch(1)
side.addLayout(art_row)
side.addStretch(1)
row.addLayout(side, 1)
if art_query:
self._fetcher = AlbumArtFetcher()
self._fetcher.search_finished.connect(self._on_art_search)
self._fetcher.image_finished.connect(self._on_art_image)
self._fetcher.search(*art_query)
return row
def _on_art_search(self, result: dict):
self._art_candidates = result.get("candidates") or []
if not self._art_candidates:
self._art.setText("No artwork found" if "error" not in result
else "Artwork search failed")
self._art_caption.setText(result.get("error", ""))
return
self._next_art.setEnabled(len(self._art_candidates) > 1)
self._load_art()
def _load_art(self):
candidate = self._art_candidates[self._art_index]
n = len(self._art_candidates)
self._art_caption.setText(
f"{candidate.artist} — {candidate.album}, via {candidate.source}"
+ (f" ({self._art_index + 1}/{n})" if n > 1 else ""))
self._art_image = None
self._embed.setEnabled(False)
self._art.setPixmap(QPixmap())
self._art.setText("Loading…")
self._fetcher.fetch(candidate)
def _on_next_art(self):
self._art_index = (self._art_index + 1) % len(self._art_candidates)
self._load_art()
def _on_art_image(self, result: dict):
if not self._art_candidates or result.get("candidate") is not \
self._art_candidates[self._art_index]:
if "candidate" in result:
return # a stale download after Next Artwork
if "error" in result:
self._art.setText("Couldn't load the image")
return
pixmap = QPixmap()
if not pixmap.loadFromData(result["image"]):
self._art.setText("Couldn't decode the image")
return
self._art_image = result["image"]
self._art_mime = result["mime"]
self._art.setPixmap(pixmap.scaled(
self._art.size(), Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation))
first = not self._embed.isEnabled() and self._art_index == 0
self._embed.setEnabled(True)
if first:
self._embed.setChecked(True)
# ---- the table ----
def _fill_table(self):
self._filling = True
for r, row in enumerate(self._rows):
check = QTableWidgetItem()
check.setFlags(Qt.ItemFlag.ItemIsUserCheckable
| Qt.ItemFlag.ItemIsEnabled
| Qt.ItemFlag.ItemIsSelectable)
# Ticked whether or not it changes: unticking is what asks for
# a song's own window, and a song with nothing to change
# shouldn't get one unasked.
check.setCheckState(Qt.CheckState.Checked)
self._table.setItem(r, CHECK_COL, check)
shown = {f: (row.proposal.get(f) if row.proposal.get(f)
not in (None, "", 0) else row.current(f))
for f in ("name", "artist", "album_artist", "album",
"year", "track_number", "track_count",
"disc_number")}
for c, (_h, fields) in enumerate(COLUMNS, FIRST_FIELD_COL):
self._table.setItem(r, c,
QTableWidgetItem(format_cell(fields,
shown)))
self._mark(r, c)
source = QTableWidgetItem(row.source)
source.setFlags(Qt.ItemFlag.ItemIsEnabled
| Qt.ItemFlag.ItemIsSelectable)
self._table.setItem(r, SOURCE_COL, source)
self._filling = False
def _mark(self, r: int, c: int):
"""Bold when the cell would change the song; the tooltip says what
it was."""
fields = COLUMNS[c - FIRST_FIELD_COL][1]
item = self._table.item(r, c)
row = self._rows[r]
current = {f: row.current(f) for f in fields}
was = format_cell(fields, current)
proposed = parse_cell(fields, item.text())
differs = any(v is not None and v != (current[f] or None)
for f, v in proposed.items())
font = QFont(item.font())
font.setBold(differs)
item.setFont(font)
item.setToolTip(f"Was: {was or '(blank)'}" if differs else "")
def _set_column(self, fields: tuple, text: str):
col = FIRST_FIELD_COL + [f for _h, f in COLUMNS].index(fields)
self._table.blockSignals(True)
for r in range(len(self._rows)):
self._table.item(r, col).setText(text)
self._mark(r, col)
self._table.blockSignals(False)
def _on_item_changed(self, item):
if self._filling:
return
if item.column() == CHECK_COL:
self._update_apply()
elif FIRST_FIELD_COL <= item.column() < SOURCE_COL:
self._table.blockSignals(True)
self._mark(item.row(), item.column())
self._table.blockSignals(False)
def _checked(self, r: int) -> bool:
return (self._table.item(r, CHECK_COL).checkState()
== Qt.CheckState.Checked)
def _update_apply(self):
n = sum(1 for r in range(len(self._rows)) if self._checked(r))
self._apply_btn.setText(f"Apply to {n} Song{'' if n == 1 else 's'}"
if n != len(self._rows) else "Apply to All")
self._apply_btn.setEnabled(True) # none ticked = review them all
# ---- what the caller reads ----
def row_fields(self, r: int) -> dict:
"""Row ``r``'s changes as the cells now read: blank or unchanged
cells propose nothing."""
row = self._rows[r]
out = {}
for c, (_h, fields) in enumerate(COLUMNS, FIRST_FIELD_COL):
for name, value in parse_cell(
fields, self._table.item(r, c).text()).items():
if value is not None and value != (row.current(name)
or None):
out[name] = value
return out
def edits(self) -> dict:
"""{track id: fields} for the ticked songs that change."""
out = {}
for r, row in enumerate(self._rows):
if self._checked(r):
fields = self.row_fields(r)
if fields:
out[row.track.track_id] = fields
return out
def unchecked_rows(self) -> list:
return [row for r, row in enumerate(self._rows)
if not self._checked(r)]
def all_rows(self) -> list:
return list(self._rows)
def _on_apply(self):
self.outcome = self.APPLY
if self._embed is not None and self._embed.isChecked() \
and self._art_image:
self.selected_image = self._art_image
self.selected_mime = self._art_mime
self.accept()
def _on_review_all(self):
self.outcome = self.REVIEW_ALL
self.accept()
+136 -4
View File
@@ -33,7 +33,11 @@ from lintunes.gui.device_sync_dialog import (
DeviceSyncSettingsDialog, resolve_sync_playlists,
)
from lintunes.gui.export_dialog import ExportKindDialog, WebMixDialog
from lintunes.gui.identify_batch_dialog import IdentifyBatchDialog
from lintunes.gui.identify_dialog import IdentifyDialog
from lintunes.identify_batch import (
IdentifyCollector, album_art_query, analyze,
)
from lintunes.gui.sidebar import SIDEBAR_INSET, SidebarPanel
from lintunes.gui.library_view import LibraryView
from lintunes.gui.playlist_view import PlaylistView
@@ -250,6 +254,13 @@ class MainWindow(QMainWindow):
self._url_identify = True # False after "Stop Identifying"
self._url_note = ""
self._url_progress = None # UrlImportProgress, for several songs
# Several songs are looked up quietly while they download, and
# reviewed together once the last one has landed.
self._url_batch = None # identify_batch.IdentifyCollector
# Popups waiting for LinTunes to be the app in front (they must
# never land on top of another app).
self._popups_waiting: list = []
self._popups_hooked = False
# 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.
@@ -1341,6 +1352,15 @@ class MainWindow(QMainWindow):
if problem else "")
spotify = dialog.spotify_tracks()
if self._url_batch is not None:
self._url_batch.cancel()
self._url_batch = None
if max(len(spotify), len(chosen)) > 1:
batch = IdentifyCollector(
self._prefs.acoustid.get("api_key", "").strip(), self)
batch.progress.connect(self._on_url_batch_progress)
batch.ready.connect(self._on_url_batch_ready)
self._url_batch = batch
if spotify:
# Spotify won't hand over audio: each song is found on YouTube
# and arrives already wearing Spotify's tags.
@@ -1421,12 +1441,113 @@ class MainWindow(QMainWindow):
if self._url_progress is not None:
self._url_progress.landed(bool(imported))
if imported and self._url_identify:
self._enqueue_identify(imported)
if self._url_batch is not None:
self._url_batch.add(imported) # reviewed when all are in
else:
self._enqueue_identify(imported)
def _finish_url_import(self):
if self._url_worker is not None:
self._url_worker.cleanup()
self._hide_sync_widgets()
if self._url_batch is not None:
self._url_batch.close() # no more songs are coming
def _on_url_batch_progress(self, done: int, total: int):
worker = self._url_worker
if worker is None or not worker.busy():
self.statusBar().showMessage(
f"Identifying the imported songs: {done} of {total}…")
def _on_url_batch_ready(self, entries: list):
"""Every song is downloaded and looked up: one window for them all
(one song, if that's all that landed, gets the ordinary one)."""
if self.sender() is not self._url_batch:
return
self._url_batch = None
library = self._manager.library
entries = [(t, c) for t, c in entries if t.track_id in library.tracks]
if not entries or not self._url_identify:
return
self.statusBar().clearMessage()
if len(entries) == 1:
analysis = analyze(entries)
self._when_app_active(
lambda: self._review_individually(analysis.rows))
return
analysis = analyze(entries)
self._when_app_active(lambda: self._show_batch_review(analysis))
def _show_batch_review(self, analysis):
dialog = IdentifyBatchDialog(analysis, album_art_query(analysis),
self)
if not dialog.exec():
self.statusBar().showMessage(
"Identify cancelled — the songs keep the tags they have",
6000)
return
if dialog.outcome == dialog.REVIEW_ALL:
self._review_individually(dialog.all_rows())
return
edits = dialog.edits()
if edits:
# The edit funnel writes the tags, may relocate the files, and
# records the whole list as one undo step.
self._manager.edit_many_track_fields(edits)
if dialog.selected_image:
library = self._manager.library
tracks = [library.tracks[r.track.track_id]
for r in dialog.all_rows()
if r.track.track_id in library.tracks]
self._embed_album_art(tracks, dialog.selected_image,
dialog.selected_mime)
if edits:
self.statusBar().showMessage(
f"Updated tags for {len(edits)} song(s)", 6000)
self._review_individually(dialog.unchecked_rows())
def _review_individually(self, rows: list):
"""The ordinary Identify window, one song at a time, from lookups
already made."""
rows = [r for r in rows if r.candidates
and r.track.track_id in self._manager.library.tracks]
for i, row in enumerate(rows):
track = self._manager.library.tracks[row.track.track_id]
dialog = IdentifyDialog(track, row.candidates,
remaining=len(rows) - i - 1, parent=self)
if dialog.exec():
fields = dialog.result_fields()
if fields:
self._manager.edit_track_fields(track.track_id, fields)
elif dialog.cancel_all:
return
def _when_app_active(self, show):
"""Run ``show`` (which opens a window over this one) now if LinTunes
is the app in front, else once it is. A dialog parented to the main
window only ever stacks over LinTunes, but one opened while another
app has focus would still jump in front of it. Meanwhile the window
asks for attention instead."""
app = QApplication.instance()
if app.applicationState() == Qt.ApplicationState.ApplicationActive:
show()
return
self._popups_waiting.append(show)
QApplication.alert(self)
if not self._popups_hooked:
self._popups_hooked = True
app.applicationStateChanged.connect(self._on_app_state_changed)
def _on_app_state_changed(self, state):
if (state == Qt.ApplicationState.ApplicationActive
and self._popups_waiting):
waiting, self._popups_waiting = self._popups_waiting, []
def run():
for show in waiting:
show()
# After the activation event has finished being delivered.
QTimer.singleShot(0, run)
def _on_url_finished(self, summary: dict):
self._finish_url_import()
@@ -1453,8 +1574,9 @@ class MainWindow(QMainWindow):
self._finish_url_import()
if self._url_progress is not None:
self._url_progress.finish(False)
QMessageBox.warning(self, "Import from URL",
f"Couldn't import from that link:\n\n{message}")
self._when_app_active(lambda: QMessageBox.warning(
self, "Import from URL",
f"Couldn't import from that link:\n\n{message}"))
def _confirm_cancel_url_import(self):
box = QMessageBox(self)
@@ -1778,7 +1900,14 @@ class MainWindow(QMainWindow):
f"and the filename says nothing", 6000)
else:
self.statusBar().clearMessage()
dialog = IdentifyDialog(track, result["candidates"],
self._when_app_active(
lambda: self._show_identify(track, result["candidates"]))
return
self._identify_next()
def _show_identify(self, track, candidates):
if track.track_id in self._manager.library.tracks:
dialog = IdentifyDialog(track, candidates,
remaining=len(self._identify_queue),
parent=self)
accepted = dialog.exec()
@@ -1796,6 +1925,9 @@ class MainWindow(QMainWindow):
# Also covers songs a URL import has yet to download: they
# still import, they just don't get a dialog each.
self._url_identify = False
if self._url_batch is not None:
self._url_batch.cancel()
self._url_batch = None
self.statusBar().clearMessage()
return
self._identify_next()
+250
View File
@@ -0,0 +1,250 @@
"""Identify several songs at once (Round 77): the analysis behind the batch
review window an Import from URL of more than one song ends with.
One dialog per song was fine for a song and a chore for an album: twelve
"Apply Tags" clicks for twelve songs that obviously belong together. So a
multi-song import is fingerprinted quietly while it downloads, and only
once *everything* has landed is it shown, all at once, as rows.
``find_common_album`` is the "these are all one album" call. Every song
votes for each album it could be on: any of its lookup candidates, plus
its own tags (a Spotify album import already carries the right album, even
when AcoustID only knows the song from a compilation). An album named by
every song that names one, covering at least ``ALBUM_SHARE`` of them, is
the album. Each song then takes its best candidate *on that album*, and the
album-wide fields (album, album artist, year, track count) are unified to
the most common value, so one stray pressing can't leave a single song
filed under a different year. A playlist gets none of that: each song
simply shows its best candidate.
Pure and Qt-widget-free apart from ``IdentifyCollector``, which runs the
lookups one at a time while a download is still going.
"""
from collections import Counter
from dataclasses import dataclass, field
from PyQt6.QtCore import QObject, pyqtSignal
from lintunes.art_search import _simplify_album
from lintunes.fingerprint import IdentifyCandidate, TrackIdentifier
# What a row shows and may change, in column order.
FIELDS = ("name", "artist", "album_artist", "album", "year", "track_number",
"track_count", "disc_number")
ALBUM_FIELDS = ("album", "album_artist", "year", "track_count")
# Share of the songs an album must cover to be "one album".
ALBUM_SHARE = 0.75
@dataclass
class BatchRow:
track: object # the library Track
proposal: dict # field → proposed value (None: keep)
source: str # "AcoustID 97%", "Filename", …
candidates: list = field(default_factory=list)
def current(self, name: str):
return getattr(self.track, name, None)
def changes(self) -> dict:
"""The proposed values that differ from the track's own. A blank
proposal never blanks a tag."""
out = {}
for name in FIELDS:
value = self.proposal.get(name)
if value in (None, "", 0):
continue
if value != (self.current(name) or None):
out[name] = value
return out
@dataclass
class BatchAnalysis:
rows: list
album: str | None = None # set when the songs are one album
album_artist: str | None = None
year: int | None = None
@property
def is_album(self) -> bool:
return self.album is not None
def album_key(album: str | None) -> str:
""""Last Splash (Remastered)" and "last splash" are one album."""
return _simplify_album(album or "").casefold().strip()
def tags_candidate(track) -> IdentifyCandidate:
"""What the song's own tags say, as a candidate."""
candidate = IdentifyCandidate(score=0.0, source="tags")
for name in FIELDS:
value = getattr(track, name, None)
if value not in (None, "", 0):
setattr(candidate, name, value)
return candidate
def describe(candidate: IdentifyCandidate | None) -> str:
if candidate is None:
return "Nothing found"
if candidate.source == "acoustid":
return f"AcoustID {round(candidate.score * 100)}%"
return {"filename": "Filename", "tags": "Its own tags"}.get(
candidate.source, candidate.source)
def _options(track, candidates) -> list:
"""Every candidate for a song, best first, its own tags last."""
return list(candidates) + [tags_candidate(track)]
def find_common_album(entries) -> str | None:
"""The album key every song that names an album agrees on, or None.
``entries`` is [(track, candidates)]. Among albums every voting song
can be on, the one its songs' *best* candidates name most wins (so a
compilation that happens to hold every song too loses to the album
the top matches say)."""
if len(entries) < 2:
return None
voters = []
top_votes = Counter()
for track, candidates in entries:
keys = {album_key(c.album) for c in _options(track, candidates)
if album_key(c.album)}
if keys:
voters.append(keys)
for c in _options(track, candidates):
if album_key(c.album):
top_votes[album_key(c.album)] += 1
break
if len(voters) < max(2, ALBUM_SHARE * len(entries)):
return None
common = set.intersection(*voters)
if not common:
return None
return max(sorted(common), key=lambda k: top_votes[k])
def _most_common(values):
"""The most frequent non-empty value; ties go to the smallest (the
earliest year, the lowest count)."""
counts = Counter(v for v in values if v not in (None, "", 0))
if not counts:
return None
best = max(counts.values())
return min(v for v, n in counts.items() if n == best)
def analyze(entries) -> BatchAnalysis:
"""[(track, candidates)] → rows to review, unified into one album when
the songs are one."""
key = find_common_album(entries)
chosen = []
for track, candidates in entries:
pick = candidates[0] if candidates else None
if key is not None:
pick = next((c for c in _options(track, candidates)
if album_key(c.album) == key), pick)
chosen.append((track, candidates, pick))
if key is None:
rows = [BatchRow(track, pick.fields() if pick else {},
describe(pick), list(candidates))
for track, candidates, pick in chosen]
return BatchAnalysis(rows)
picks = [pick for _t, _c, pick in chosen if pick is not None
and album_key(pick.album) == key]
album = _most_common(p.album for p in picks)
album_artist = _most_common(p.album_artist for p in picks)
year = _most_common(p.year for p in picks)
track_count = _most_common(p.track_count for p in picks)
rows = []
for track, candidates, pick in chosen:
proposal = pick.fields() if pick is not None else {}
proposal.update({"album": album, "album_artist": album_artist,
"year": year, "track_count": track_count})
rows.append(BatchRow(track, proposal, describe(pick),
list(candidates)))
return BatchAnalysis(rows, album=album, album_artist=album_artist,
year=year)
def album_art_query(analysis: BatchAnalysis) -> tuple[str, str] | None:
"""(artist, album) to search cover art by, for an album batch."""
if not analysis.is_album:
return None
artist = analysis.album_artist or _most_common(
r.proposal.get("artist") or r.current("artist")
for r in analysis.rows)
return (artist, analysis.album) if artist else None
class IdentifyCollector(QObject):
"""Looks songs up one at a time, quietly, as they're handed in, and
says ``ready`` with [(track, candidates)] in the order they came once
``close()`` has said no more are coming and the last lookup is back.
``progress`` is (looked up, handed in)."""
progress = pyqtSignal(int, int)
ready = pyqtSignal(list)
def __init__(self, api_key: str, parent=None):
super().__init__(parent)
self._api_key = api_key
self._tracks: list = []
self._results: dict = {} # track id → candidates
self._queue: list = []
self._identifier = None
self._closed = False
self._cancelled = False
self._sent = False
def add(self, tracks):
if self._cancelled:
return
self._tracks.extend(tracks)
self._queue.extend(tracks)
if self._identifier is None:
self._next()
def close(self):
self._closed = True
self._maybe_ready()
def cancel(self):
self._cancelled = True
self._queue.clear()
def total(self) -> int:
return len(self._tracks)
def _next(self):
if not self._queue or self._cancelled:
self._identifier = None
self._maybe_ready()
return
track = self._queue.pop(0)
# Kept so the identifier and its thread's signal source outlive
# this call.
self._identifier = TrackIdentifier()
self._identifier.finished.connect(
lambda result, t=track: self._on_result(t, result))
self._identifier.identify(track, self._api_key)
def _on_result(self, track, result: dict):
self._results[track.track_id] = result.get("candidates") or []
self.progress.emit(len(self._results), len(self._tracks))
self._next()
def _maybe_ready(self):
if (self._closed and self._identifier is None and not self._sent
and not self._cancelled):
self._sent = True
self.ready.emit([(t, self._results.get(t.track_id, []))
for t in self._tracks])
+7 -2
View File
@@ -605,9 +605,14 @@ class LibraryManager(QObject):
track already has costs nothing and a track that ends up unchanged is
skipped entirely (no file write, no undo entry).
"""
self.edit_many_track_fields({tid: fields for tid in track_ids})
def edit_many_track_fields(self, edits: dict[int, dict]):
"""``edit_tracks_fields`` with different fields per track ({track id:
fields}) — a batch identify's rows — still ONE undoable command."""
changes = [] # (track_id, new_fields, old_fields)
with timed("edit_tracks_fields (%d tracks)", len(track_ids)):
for track_id in track_ids:
with timed("edit_many_track_fields (%d tracks)", len(edits)):
for track_id, fields in edits.items():
track = self.library.tracks.get(track_id)
if not track:
continue
+3
View File
@@ -337,7 +337,10 @@ def window(qapp, tmp_path, fake, monkeypatch):
manager = LibraryManager(library, tmp_path / "data")
win = MainWindow(manager, Preferences(tmp_path / "data"))
monkeypatch.setattr(win, "_enqueue_identify", lambda tracks: None)
monkeypatch.setattr(win, "_show_batch_review", lambda analysis: None)
yield win
if win._url_batch is not None: # lookups still out: never review them
win._url_batch.cancel()
win.close()
+11 -1
View File
@@ -321,7 +321,12 @@ def window(qapp, tmp_path, fake, monkeypatch):
identified = []
monkeypatch.setattr(win, "_enqueue_identify", identified.extend)
win.identified = identified
reviews = []
monkeypatch.setattr(win, "_show_batch_review", reviews.append)
win.reviews = reviews
yield win
if win._url_batch is not None: # lookups still out: never review them
win._url_batch.cancel()
win.close()
@@ -360,4 +365,9 @@ def test_spotify_songs_import_tagged_and_go_to_identify(window, qapp, fake,
assert sorted((t.artist, t.album, t.name) for t in lib) == [
("The Breeders", "Last Splash", "Cannonball"),
("The Breeders", "Last Splash", "New Year")]
assert len(window.identified) == 2
# Several songs: no dialog each, one review once both are in.
assert window.identified == []
_pump(qapp, lambda: window.reviews)
[analysis] = window.reviews
assert analysis.is_album and analysis.album == "Last Splash"
assert len(analysis.rows) == 2
+339
View File
@@ -0,0 +1,339 @@
"""Round 77: an import of several songs is identified as one batch.
- Nothing pops up while songs are still downloading; they're looked up
quietly and reviewed together once the last one has landed.
- Songs that are one album are reviewed as one: album-wide fields once,
unified, plus a cover from the album-art search.
- A ticked row is applied with the rest (one undo step); an unticked one
gets the ordinary Identify window afterwards.
- No window ever lands on top of another app: it waits for LinTunes to
be in front.
"""
import pytest
from PyQt6.QtCore import Qt
from lintunes import identify_batch
from lintunes.fingerprint import IdentifyCandidate
from lintunes.gui import identify_batch_dialog as dialog_module
from lintunes.gui.identify_batch_dialog import (
IdentifyBatchDialog, format_cell, parse_cell,
)
from lintunes.identify_batch import (
BatchRow, IdentifyCollector, analyze, find_common_album,
)
from lintunes.library_manager import LibraryManager
from lintunes.models import Library
from lintunes.models.track import Track
from lintunes.preferences import Preferences
def _c(name, album, year=None, number=None, count=None, score=0.9,
artist="The Breeders", album_artist=None, source="acoustid"):
return IdentifyCandidate(score=score, source=source, name=name,
artist=artist, album=album, year=year,
track_number=number, track_count=count,
album_artist=album_artist)
def _t(tid, name="", album="", artist="", **kw):
return Track(track_id=tid, name=name, album=album, artist=artist, **kw)
# --------------------------------------------------------------------------
# A. one album or not
def test_songs_on_one_album_are_one_album():
entries = [
(_t(1, "x"), [_c("New Year", "Last Splash", 1993, 1, 15),
_c("New Year", "90s Hits", 2004, 7, 20)]),
(_t(2, "y"), [_c("Cannonball", "Last Splash", 1993, 2, 15)]),
(_t(3, "z"), [_c("Invisible Man", "Last Splash (Remastered)", 2013,
3, 15)]),
]
a = analyze(entries)
assert a.is_album and a.album == "Last Splash"
# The album's own year, not the remaster's, for all three.
assert [r.proposal["year"] for r in a.rows] == [1993] * 3
assert [r.proposal["album"] for r in a.rows] == ["Last Splash"] * 3
assert [r.proposal["track_number"] for r in a.rows] == [1, 2, 3]
assert a.rows[0].source == "AcoustID 90%"
def test_the_album_the_top_matches_name_beats_a_compilation():
entries = [
(_t(1), [_c("A", "Real Album"), _c("A", "Best Of")]),
(_t(2), [_c("B", "Real Album"), _c("B", "Best Of")]),
]
assert find_common_album(entries) == "real album"
def test_a_song_tagged_with_the_album_votes_for_it():
"""A Spotify album import carries the album in its tags even where
AcoustID only knows the song from a compilation."""
entries = [
(_t(1, album="Last Splash"), [_c("A", "Greatest Hits")]),
(_t(2, album="Last Splash"), [_c("B", "Last Splash")]),
]
a = analyze(entries)
assert a.album == "Last Splash"
# The first song keeps its own tags' album, not the compilation's.
assert a.rows[0].source == "Its own tags"
def test_a_playlist_is_not_an_album():
entries = [
(_t(1), [_c("A", "One", 1990, 1, 10)]),
(_t(2), [_c("B", "Two", 2000, 4, 12)]),
]
a = analyze(entries)
assert not a.is_album
assert [r.proposal["album"] for r in a.rows] == ["One", "Two"]
assert identify_batch.album_art_query(a) is None
def test_songs_nobody_knows_dont_block_the_album():
entries = [(_t(i), [_c(f"S{i}", "LP")]) for i in range(1, 5)]
entries.append((_t(9), []))
a = analyze(entries)
assert a.is_album and a.rows[-1].proposal["album"] == "LP"
assert a.rows[-1].source == "Nothing found"
def test_one_song_is_never_an_album():
assert find_common_album([(_t(1), [_c("A", "LP")])]) is None
def test_changes_never_blank_a_tag():
row = BatchRow(_t(1, "Song", "LP", "Me", year=1999),
{"name": "Song", "album": None, "year": 2001,
"artist": ""}, "x")
assert row.changes() == {"year": 2001}
# --------------------------------------------------------------------------
# B. the window
@pytest.fixture
def no_network(monkeypatch):
searched = []
class _Fetcher:
def __init__(self):
from PyQt6.QtCore import QObject, pyqtSignal
class _S(QObject):
search_finished = pyqtSignal(object)
image_finished = pyqtSignal(object)
self._s = _S()
self.search_finished = self._s.search_finished
self.image_finished = self._s.image_finished
def search(self, artist, album):
searched.append((artist, album))
def fetch(self, candidate):
pass
monkeypatch.setattr(dialog_module, "AlbumArtFetcher", _Fetcher)
return searched
def _album_analysis():
return analyze([
(_t(1, "new year", artist="The Breeders"),
[_c("New Year", "Last Splash", 1993, 1, 2)]),
(_t(2, "Cannonball", artist="The Breeders", album="Last Splash",
year=1993, track_number=2, track_count=2),
[_c("Cannonball", "Last Splash", 1993, 2, 2)]),
])
def test_cells_round_trip():
assert format_cell(("track_number", "track_count"),
{"track_number": 3, "track_count": 12}) == "3 of 12"
assert parse_cell(("track_number", "track_count"), "3/12") == {
"track_number": 3, "track_count": 12}
assert parse_cell(("track_number", "track_count"), "huh") == {
"track_number": None, "track_count": None}
assert parse_cell(("year",), "") == {"year": None}
def test_the_album_window_applies_ticked_rows(qapp, no_network):
analysis = _album_analysis()
dialog = IdentifyBatchDialog(
analysis, identify_batch.album_art_query(analysis))
assert no_network == [("The Breeders", "Last Splash")]
# Only the first song changes; the second already says all of it.
assert dialog.edits() == {1: {"name": "New Year", "album": "Last Splash",
"year": 1993, "track_number": 1,
"track_count": 2}}
name_cell = dialog._table.item(0, 1)
assert name_cell.font().bold() and "new year" in name_cell.toolTip()
assert not dialog._table.item(1, 1).font().bold()
# The album-wide field is edited once, for every row.
dialog._album_edit.setText("Last Splash (4AD)")
dialog._album_edit.textEdited.emit("Last Splash (4AD)")
edits = dialog.edits()
assert edits[1]["album"] == edits[2]["album"] == "Last Splash (4AD)"
dialog._table.item(1, 0).setCheckState(Qt.CheckState.Unchecked)
assert list(dialog.edits()) == [1]
assert [r.track.track_id for r in dialog.unchecked_rows()] == [2]
dialog._on_apply()
assert dialog.outcome == dialog.APPLY
assert dialog.selected_image is None # no art loaded
def test_art_arrives_ticked_and_is_handed_over(qapp, no_network):
from lintunes.art_search import ArtCandidate
from PyQt6.QtCore import QBuffer, QByteArray, QIODevice
from PyQt6.QtGui import QImage
image = QImage(4, 4, QImage.Format.Format_RGB32)
image.fill(0xff0000)
data = QByteArray()
buf = QBuffer(data)
buf.open(QIODevice.OpenModeFlag.WriteOnly)
image.save(buf, "PNG")
analysis = _album_analysis()
dialog = IdentifyBatchDialog(analysis, ("The Breeders", "Last Splash"))
art = ArtCandidate("The Breeders", "Last Splash", "http://x/a.jpg")
dialog._on_art_search({"candidates": [art]})
dialog._on_art_image({"candidate": art, "image": bytes(data),
"mime": "image/png"})
assert dialog._embed.isChecked()
dialog._on_apply()
assert dialog.selected_image == bytes(data)
assert dialog.selected_mime == "image/png"
def test_a_playlist_window_has_no_album_panel(qapp, no_network):
analysis = analyze([(_t(1), [_c("A", "One")]),
(_t(2), [_c("B", "Two")])])
dialog = IdentifyBatchDialog(analysis, None)
assert dialog._album_edit is None and dialog._embed is None
assert no_network == []
dialog._on_review_all()
assert dialog.outcome == dialog.REVIEW_ALL
# --------------------------------------------------------------------------
# C. the manager: different fields per song, one undo step
def test_edit_many_track_fields_is_one_undo(tmp_path, mp3_file):
import shutil
library = Library()
for tid in (1, 2):
path = tmp_path / f"{tid}.mp3"
shutil.copy(mp3_file, path)
library.tracks[tid] = _t(tid, f"old{tid}", location=str(path))
manager = LibraryManager(library, tmp_path / "data")
manager.edit_many_track_fields({1: {"name": "One"},
2: {"name": "Two", "year": 1993}})
assert (library.tracks[1].name, library.tracks[2].year) == ("One", 1993)
manager.undo_stack.undo()
assert (library.tracks[1].name, library.tracks[2].name,
library.tracks[2].year) == ("old1", "old2", 0)
# --------------------------------------------------------------------------
# D. the collector
def test_the_collector_waits_for_close_and_keeps_order(qapp, monkeypatch):
pending = []
class _Identifier:
def __init__(self):
from PyQt6.QtCore import QObject, pyqtSignal
class _S(QObject):
finished = pyqtSignal(object)
self._s = _S()
self.finished = self._s.finished
def identify(self, track, key):
pending.append((self, track))
monkeypatch.setattr(identify_batch, "TrackIdentifier", _Identifier)
collector = IdentifyCollector("")
ready = []
collector.ready.connect(ready.append)
collector.add([_t(1)])
collector.add([_t(2)])
assert len(pending) == 1 # one lookup at a time
ident, track = pending.pop()
ident.finished.emit({"candidates": [_c("A", "LP")]})
ident, track = pending.pop()
ident.finished.emit({"error": "nope"})
assert ready == [] # more songs could still come
collector.close()
[entries] = ready
assert [(t.track_id, len(c)) for t, c in entries] == [(1, 1), (2, 0)]
# --------------------------------------------------------------------------
# E. the window waits for LinTunes to be in front
@pytest.fixture
def window(qapp, tmp_path):
from lintunes.gui.main_window import MainWindow
manager = LibraryManager(Library(), tmp_path / "data")
win = MainWindow(manager, Preferences(tmp_path / "data"))
yield win
win.close()
def test_popups_wait_until_lintunes_is_in_front(window, qapp, monkeypatch):
from lintunes.gui import main_window as mw
state = [Qt.ApplicationState.ApplicationInactive]
monkeypatch.setattr(qapp, "applicationState", lambda: state[0])
alerts = []
monkeypatch.setattr(mw.QApplication, "alert",
lambda w, ms=0: alerts.append(w))
shown = []
window._when_app_active(lambda: shown.append(1))
assert shown == [] and alerts == [window]
window._on_app_state_changed(Qt.ApplicationState.ApplicationActive)
qapp.processEvents()
assert shown == [1]
state[0] = Qt.ApplicationState.ApplicationActive
window._when_app_active(lambda: shown.append(2))
assert shown == [1, 2]
def test_a_batch_review_applies_then_reviews_the_unticked(window, qapp,
monkeypatch):
from lintunes.gui import main_window as mw
library = window._manager.library
for tid in (1, 2, 3):
library.tracks[tid] = _t(tid, f"t{tid}")
rows = [BatchRow(library.tracks[t], {}, "x", [_c(f"S{t}", "LP")])
for t in (1, 2, 3)]
applied, individually = [], []
class _Batch:
APPLY, REVIEW_ALL = 1, 2
outcome = 1
selected_image = None
def __init__(self, analysis, query, parent):
pass
def exec(self):
return True
def edits(self):
return {1: {"name": "S1"}}
def unchecked_rows(self):
return rows[2:]
def all_rows(self):
return rows
monkeypatch.setattr(mw, "IdentifyBatchDialog", _Batch)
monkeypatch.setattr(window._manager, "edit_many_track_fields",
applied.append)
monkeypatch.setattr(window, "_review_individually", individually.append)
window._show_batch_review(identify_batch.BatchAnalysis(rows))
assert applied == [{1: {"name": "S1"}}]
assert individually == [rows[2:]]