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>
340 lines
12 KiB
Python
340 lines
12 KiB
Python
"""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:]]
|