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