Round 17: task batch + cruft sweep (ratings, art download, MPRIS/exit/BT fixes)

Features:
- Rating hover dots: hovering a rating cell shows 5 clickable slots
  (RatingDelegate); click slot k sets k stars, clicking the current count
  clears. Undoable, library-only (ratings never rewrite music files).
- Search bar moved into the Library header strip, right of the Library
  button, so the tracklist top aligns with the playlist tree.
- Right-click "Download Album Art…": iTunes Search API (no key), off-thread
  fetch, preview/confirm dialog with Next Result, embeds via write_artwork
  + size refresh, invalidates the MPRIS art cache.

Fixes:
- MPRIS media keys: PropertiesChanged sent invalidated_properties as "av"
  instead of "as", so gsd-media-keys dropped it and never MRU-bumped
  lintunes (why the play/pause key kept waking stale players). Now an
  explicit QDBusArgument string array; loopback-verified sa{sv}as.
- Exit segfault: ordered Player.shutdown() (stop, clear source, detach
  buffer/audio outputs) from closeEvent/aboutToQuit; scripted run exits 0.
- BT zero-volume after pause/resume: volume re-applied on resume, device
  swap, and BufferedMedia (needs verify on the affected machine).

Cruft sweep:
- Tag writes filtered to EDITABLE_FIELDS; failures logged + surfaced in
  the status bar (was a swallowed print).
- O(n²) import fixed (location index + cached max track id); O(1)
  refresh/reveal via TrackTableModel row index.
- lastfm: scrobble-queue thread lock, login prefs write marshalled to the
  GUI thread, JSON/raise_for_status order fixed.
- Shared read_json/write_json; TableSettingsMixin dedupes view settings.

TASKS.md rewritten as a resumable board; tests in tests/test_round17.py
(251 total pass).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 11:36:11 -04:00
parent 9575f0400f
commit 8dc33fa65e
18 changed files with 1203 additions and 197 deletions

393
tests/test_round17.py Normal file
View File

@ -0,0 +1,393 @@
"""Round 17: cruft cleanup + rating clicks, search-bar move, player shutdown,
volume re-apply, MPRIS marshalling fix, album-art download plumbing."""
import threading
from unittest.mock import MagicMock, patch
from PyQt6.QtCore import QCoreApplication, Qt, QPoint, QPointF, QUrl
from PyQt6.QtGui import QFontMetrics, QMouseEvent
from lintunes import player as player_module
from lintunes import tagging
from lintunes.lastfm import LastFm
from lintunes.library_manager import LibraryManager
from lintunes.models import Track
from lintunes.models.library import Library
from lintunes.player import Player
def _manager(tmp_path, tracks=()):
library = Library()
for track in tracks:
library.tracks[track.track_id] = track
return LibraryManager(library, tmp_path)
# ---- 1a: tag-write filter (rating/size edits never rewrite music files) ----
class TestTagWriteFilter:
def test_rating_edit_succeeds_without_file(self, qapp, tmp_path):
track = Track(track_id=1, name="Song",
location=str(tmp_path / "missing.mp3"))
manager = _manager(tmp_path, [track])
manager.edit_track_fields(1, {"rating": 80})
assert track.rating == 80
manager.undo_stack.undo()
assert track.rating == 0
def test_rating_edit_leaves_file_untouched(self, qapp, tmp_path, mp3_file):
track = Track(track_id=1, name="Song", location=str(mp3_file))
manager = _manager(tmp_path, [track])
before = mp3_file.stat().st_mtime_ns
manager.edit_track_fields(1, {"rating": 60})
assert track.rating == 60
assert mp3_file.stat().st_mtime_ns == before
def test_mixed_edit_still_writes_tags(self, qapp, tmp_path, mp3_file):
track = Track(track_id=1, name="Old", location=str(mp3_file))
manager = _manager(tmp_path, [track])
manager.edit_track_fields(1, {"name": "New", "rating": 40})
assert track.name == "New" and track.rating == 40
assert tagging.read_tags(mp3_file)["name"] == "New"
def test_failed_tag_write_emits_signal(self, qapp, tmp_path, monkeypatch):
track = Track(track_id=1, name="Song", location=str(tmp_path / "x.mp3"))
manager = _manager(tmp_path, [track])
failures = []
manager.tag_write_failed.connect(lambda name, err: failures.append(name))
def boom(path, fields):
raise OSError("disk full")
monkeypatch.setattr("lintunes.tagging.write_tags", boom)
manager.edit_track_fields(1, {"name": "New"})
assert failures == ["Song"]
assert track.name == "Song" # edit not applied when the write failed
# ---- 1c: import dedup index + cached max track id ----
class TestImportIndex:
def test_duplicate_import_returns_existing(self, qapp, tmp_path, mp3_file):
from lintunes.importers.file_importer import import_paths
manager = _manager(tmp_path)
music = tmp_path / "music"
first = import_paths([mp3_file], music, manager)
again = import_paths([mp3_file], music, manager)
assert len(manager.library.tracks) == 1
assert first[0].track_id == again[0].track_id
def test_new_track_id_tracks_additions(self, qapp, tmp_path):
manager = _manager(tmp_path, [Track(track_id=7, name="A")])
assert manager.new_track_id() == 8
manager.add_track(Track(name="B"))
assert manager.new_track_id() == 9
def test_new_track_id_recomputed_on_reload(self, qapp, tmp_path):
from lintunes.storage import json_storage
manager = _manager(tmp_path, [Track(track_id=1, name="A")])
manager.flush()
# Another machine synced in a track with a higher id.
disk = Library()
disk.tracks[1] = Track(track_id=1, name="A")
disk.tracks[42] = Track(track_id=42, name="B")
json_storage.save_tracks(disk, tmp_path)
manager.reload_from_disk()
assert manager.new_track_id() == 43
# ---- 1d: model id→row index ----
class TestModelRowIndex:
def test_refresh_and_row_for_id(self, qapp):
from lintunes.gui.track_table import TrackTableModel
model = TrackTableModel()
a, b = Track(track_id=1, name="A"), Track(track_id=2, name="B")
model.set_tracks([a, b, a]) # duplicate row, as in a playlist
assert model.row_for_id(1) == 0
assert model.row_for_id(2) == 1
assert model.row_for_id(99) is None
changed = []
model.dataChanged.connect(lambda tl, br: changed.append(tl.row()))
model.refresh_track(1)
assert changed == [0, 2] # both occurrences repainted
# ---- 1e: scrobble queue lock ----
class TestScrobbleQueue:
def test_concurrent_enqueue_keeps_all(self, qapp, tmp_path):
prefs = MagicMock()
prefs.lastfm = {}
lastfm = LastFm(prefs, tmp_path)
threads = [threading.Thread(
target=lastfm._enqueue, args=({"artist": "A", "track": str(i),
"timestamp": i},))
for i in range(20)]
for t in threads:
t.start()
for t in threads:
t.join()
assert len(lastfm._load_queue()) == 20
# ---- 1f: login prefs write marshalled to the GUI thread ----
class TestLoginMarshal:
def test_login_updates_prefs_on_gui_thread(self, qapp, tmp_path, monkeypatch):
prefs = MagicMock()
prefs.lastfm = {}
lastfm = LastFm(prefs, tmp_path)
monkeypatch.setattr(
LastFm, "_call",
staticmethod(lambda *a, **k: {"session": {"name": "trav",
"key": "sk123"}}))
results = []
lastfm.login_finished.connect(lambda ok, msg: results.append((ok, msg)))
lastfm.login("key", "secret", "trav", "pw")
for _ in range(100):
QCoreApplication.processEvents()
if results:
break
threading.Event().wait(0.02)
assert results and results[0][0] is True
prefs.update_lastfm.assert_called_once_with(
api_key="key", api_secret="secret", username="trav",
session_key="sk123")
# ---- Task C: MPRIS PropertiesChanged marshalled as "as" ----
class TestMprisSignal:
def test_signal_marshals_as_sa_sv_as(self, qapp):
"""Send the signal over the real session bus and check the signature
it goes out with. A plain [] marshals invalidated_properties as "av"
and GDBus consumers (gsd-media-keys) validate + drop the signal; the
spec requires "as"."""
import pytest
from PyQt6.QtDBus import QDBusConnection
from lintunes.mpris import OBJECT_PATH, build_properties_changed
from PyQt6.QtCore import QObject, pyqtSlot
from PyQt6.QtDBus import QDBusMessage
class Receiver(QObject):
def __init__(self):
super().__init__()
self.messages = []
@pyqtSlot(QDBusMessage)
def on_signal(self, msg):
self.messages.append(msg)
bus = QDBusConnection.sessionBus()
if not bus.isConnected():
pytest.skip("no D-Bus session bus in this environment")
receiver = Receiver()
assert bus.connect(
"", OBJECT_PATH, "org.freedesktop.DBus.Properties",
"PropertiesChanged", receiver.on_signal)
assert bus.send(build_properties_changed(
{"PlaybackStatus": "Playing"}))
for _ in range(100):
QCoreApplication.processEvents()
if receiver.messages:
break
threading.Event().wait(0.02)
assert receiver.messages, "signal was not delivered back over the bus"
assert receiver.messages[0].signature() == "sa{sv}as"
def test_invalidate_artwork_removes_cache_file(self, tmp_path, monkeypatch):
from lintunes import mpris
monkeypatch.setattr(mpris, "_art_cache_dir", lambda: tmp_path)
cached = tmp_path / "ABCD1234.jpg"
cached.write_bytes(b"old art")
mpris.invalidate_artwork(Track(track_id=1, persistent_id="ABCD1234"))
assert not cached.exists()
# Missing file / no persistent id are silent no-ops.
mpris.invalidate_artwork(Track(track_id=1, persistent_id="ABCD1234"))
mpris.invalidate_artwork(None)
# ---- Tasks E + F: player shutdown order and volume re-apply ----
def _mock_player(qapp, tmp_path):
manager = _manager(tmp_path)
with patch.multiple(player_module,
QMediaPlayer=MagicMock(),
QAudioOutput=MagicMock(),
QAudioBufferOutput=MagicMock(),
QMediaDevices=MagicMock()):
player = Player(manager)
return player
class TestPlayerShutdown:
def test_shutdown_detaches_in_order_and_is_idempotent(self, qapp, tmp_path):
player = _mock_player(qapp, tmp_path)
player.shutdown()
player._media.stop.assert_called_once()
player._media.setSource.assert_called_once_with(QUrl())
player._media.setAudioBufferOutput.assert_called_with(None)
player._media.setAudioOutput.assert_called_with(None)
player.shutdown() # second call must be a no-op
player._media.stop.assert_called_once()
class TestVolumeReapply:
def test_set_volume_applies_converted_gain(self, qapp, tmp_path):
from PyQt6.QtMultimedia import QAudio
player = _mock_player(qapp, tmp_path)
player.set_volume(0.5)
expected = QAudio.convertVolume(
0.5, QAudio.VolumeScale.LogarithmicVolumeScale,
QAudio.VolumeScale.LinearVolumeScale)
player._audio.setVolume.assert_called_with(expected)
def test_resume_reapplies_volume(self, qapp, tmp_path, monkeypatch):
player = _mock_player(qapp, tmp_path)
player._current_track = Track(track_id=1, name="A")
applied = []
monkeypatch.setattr(player, "_apply_volume",
lambda: applied.append(True))
monkeypatch.setattr(player, "is_playing", lambda: False)
player.toggle_play() # paused with a cued track → resume
assert applied == [True]
player._media.play.assert_called_once()
# ---- Task B: search bar lives in the header strip ----
class TestSearchBarPlacement:
def test_search_parent_is_top_strip(self, qapp, tmp_path):
from lintunes.gui.library_view import LibraryView
manager = _manager(tmp_path, [Track(track_id=1, name="A")])
view = LibraryView(manager)
assert view._search.parentWidget() is view._top_strip
view.set_header_height(34)
assert view._top_strip.height() >= 0 # metrics path still works
def test_search_still_filters(self, qapp, tmp_path):
from lintunes.gui.library_view import LibraryView
manager = _manager(tmp_path, [
Track(track_id=1, name="Apple Song", artist="X"),
Track(track_id=2, name="Banana Song", artist="Y")])
view = LibraryView(manager)
view._search.setText("banana")
view._apply_search()
assert [t.name for t in view.table.model_.tracks] == ["Banana Song"]
# ---- Task A: rating hover dots + click to rate ----
class TestRatingHelpers:
def test_rating_from_click(self):
from lintunes.gui.track_table import rating_from_click
assert rating_from_click(0, 3) == 60 # set 3 stars
assert rating_from_click(60, 5) == 100 # raise to 5
assert rating_from_click(60, 3) == 0 # click current count clears
assert rating_from_click(100, 1) == 20 # lower to 1
def test_rating_slot_at_bounds(self):
from lintunes.gui.track_table import rating_slot_at
assert rating_slot_at(0, 14) == 1
assert rating_slot_at(-5, 14) == 1 # left edge clamps
assert rating_slot_at(3 + 14 * 2, 14) == 3
assert rating_slot_at(9999, 14) == 5 # right edge clamps
assert rating_slot_at(10, 0) == 1 # degenerate width
class TestRatingClicks:
def _view(self, qapp, tracks):
from lintunes.gui.track_table import TrackTableView
from lintunes.models.playlist import PlaylistSettings
view = TrackTableView(playlist_mode=False)
view.apply_settings(PlaylistSettings(
visible_columns=["name", "rating"], sort_column="name"))
view.set_tracks(tracks)
view.resize(500, 300)
return view
def test_click_on_rating_cell_emits(self, qapp):
from lintunes.gui.track_table import (
RATING_LEFT_PAD, rating_slot_width)
track = Track(track_id=1, name="A", rating=40)
view = self._view(qapp, [track])
emitted = []
view.rating_edited.connect(lambda tid, r: emitted.append((tid, r)))
col = view.model_.fields.index("rating")
rect = view.visualRect(view.proxy.index(0, col))
slot_w = rating_slot_width(QFontMetrics(view.font()))
# Click the 4th slot → 80.
x = rect.left() + RATING_LEFT_PAD + int(slot_w * 3.5)
pos = QPointF(x, rect.center().y())
event = QMouseEvent(QMouseEvent.Type.MouseButtonPress, pos,
Qt.MouseButton.LeftButton,
Qt.MouseButton.LeftButton,
Qt.KeyboardModifier.NoModifier)
view.mousePressEvent(event)
assert emitted == [(1, 80)]
def test_library_view_wiring_edits_and_undoes(self, qapp, tmp_path):
from lintunes.gui.library_view import LibraryView
track = Track(track_id=1, name="A")
manager = _manager(tmp_path, [track])
view = LibraryView(manager)
view.table.rating_edited.emit(1, 80)
assert track.rating == 80
manager.undo_stack.undo()
assert track.rating == 0
def test_hover_state_tracks_cell(self, qapp):
track = Track(track_id=1, name="A", rating=40)
view = self._view(qapp, [track])
col = view.model_.fields.index("rating")
index = view.proxy.index(0, col)
view._set_rating_hover(index)
assert view.is_rating_hovered(index)
assert not view.is_rating_hovered(view.proxy.index(0, 0))
view._set_rating_hover(None)
assert not view.is_rating_hovered(index)
# ---- Task D: album art search plumbing (offline) ----
class TestArtSearch:
def test_upgrade_artwork_url(self):
from lintunes.art_search import upgrade_artwork_url
url = "https://is1.mzstatic.com/image/thumb/x/100x100bb.jpg"
assert "600x600bb.jpg" in upgrade_artwork_url(url)
assert "100x100" not in upgrade_artwork_url(url)
def test_parse_results(self):
from lintunes.art_search import parse_results
payload = {"results": [
{"artistName": "Artist", "collectionName": "Album",
"artworkUrl100": "https://x/100x100bb.jpg"},
{"artistName": "No Art"}, # skipped: no artworkUrl100
]}
candidates = parse_results(payload)
assert len(candidates) == 1
assert candidates[0].artist == "Artist"
assert candidates[0].album == "Album"
assert "600x600" in candidates[0].art_url
def test_embed_updates_file_and_size(self, qapp, tmp_path, mp3_file,
jpeg_bytes):
# The Task D accept path: write_artwork + size refresh, no undo entry.
track = Track(track_id=1, name="A", location=str(mp3_file), size=1)
manager = _manager(tmp_path, [track])
tagging.write_artwork(track.location, jpeg_bytes, "image/jpeg")
manager.update_track_fields(1, {"size": mp3_file.stat().st_size})
assert tagging.read_embedded_artwork(track.location) == jpeg_bytes
assert track.size == mp3_file.stat().st_size
assert not manager.undo_stack.can_undo()
# ---- context menu exposes the new action's signal ----
class TestDownloadArtSignal:
def test_signal_exists_and_carries_ids(self, qapp):
from lintunes.gui.track_table import TrackTableView
view = TrackTableView()
got = []
view.download_art_requested.connect(got.append)
view.download_art_requested.emit([1, 2])
assert got == [[1, 2]]