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).
This commit is contained in:
2026-07-02 11:36:11 -04:00
parent 3f94fb9854
commit 5f917eb68c
18 changed files with 1203 additions and 197 deletions
+18 -4
View File
@@ -1,9 +1,12 @@
import logging
import secrets
from datetime import datetime, timezone
from pathlib import Path
from PyQt6.QtCore import QObject, QTimer, pyqtSignal
log = logging.getLogger(__name__)
from lintunes import smart, tagging
from lintunes.models import Library, Playlist, PlaylistType
from lintunes.storage import json_storage
@@ -32,6 +35,7 @@ class LibraryManager(QObject):
track_fields_edited = pyqtSignal(int, list) # track_id, changed field names
library_reloaded = pyqtSignal() # library re-read from disk (sync)
conflict_resolved = pyqtSignal(list) # list[ConflictSummary] just merged
tag_write_failed = pyqtSignal(str, str) # track name, error text
def __init__(self, library: Library, data_dir: Path, parent=None):
super().__init__(parent)
@@ -65,10 +69,12 @@ class LibraryManager(QObject):
self._own_sigs: dict[str, tuple | None] = {}
self.snapshot_sync_state()
self._max_track_id = max(self.library.tracks.keys(), default=0)
# ---- id generation ----
def new_track_id(self) -> int:
return max(self.library.tracks.keys(), default=0) + 1
return self._max_track_id + 1
def new_persistent_id(self) -> str:
while True:
@@ -312,6 +318,7 @@ class LibraryManager(QObject):
if not track.persistent_id:
track.persistent_id = secrets.token_hex(8).upper()
self.library.tracks[track.track_id] = track
self._max_track_id = max(self._max_track_id, track.track_id)
self._dirty_tracks = True
self._dirty_metadata = True
self._schedule_save()
@@ -403,13 +410,18 @@ class LibraryManager(QObject):
for tid, new, _old in changes]))
def _write_track_tags(self, track, field_map: dict) -> bool:
if not track.location:
# Library-only fields (rating, size, start/stop times) have no tag
# representation; don't rewrite the audio file for them.
writable = {k: v for k, v in field_map.items()
if k in tagging.EDITABLE_FIELDS}
if not writable or not track.location:
return True
try:
tagging.write_tags(track.location, field_map)
tagging.write_tags(track.location, writable)
return True
except Exception as e:
print(f"Could not write tags to {track.location}: {e}")
log.warning("Could not write tags to %s: %s", track.location, e)
self.tag_write_failed.emit(track.name or track.location, str(e))
return False
def _apply_track_fields(self, track_id: int, field_map: dict):
@@ -653,6 +665,8 @@ class LibraryManager(QObject):
self.library.import_date = disk.import_date
self.library.library_settings = disk.library_settings
self._max_track_id = max(self.library.tracks.keys(), default=0)
self._recomputing = True
try:
self.recompute_all_smart()