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

View File

@ -1,10 +1,11 @@
import json
import re
import shutil
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from lintunes.storage.json_storage import read_json, write_json
# Syncthing conflict file pattern: filename.sync-conflict-20240101-123456-ABCDEFG.json
CONFLICT_PATTERN = re.compile(r"^(.+)\.sync-conflict-(\d{8}-\d{6})-\w+(\.\w+)$")
@ -107,8 +108,8 @@ def _find_conflict_files(data_dir: Path) -> list[tuple[Path, Path]]:
def _merge_library(original_path: Path, conflict_path: Path) -> ConflictSummary:
original = _load_json(original_path)
conflict = _load_json(conflict_path)
original = read_json(original_path)
conflict = read_json(conflict_path)
added = 0
changed = 0
@ -125,7 +126,7 @@ def _merge_library(original_path: Path, conflict_path: Path) -> ConflictSummary:
name = original[tid].get("name") or f"track {tid}"
examples.append(f"{name}”: {notes[0]}")
_save_json(original_path, original)
write_json(original_path, original)
lines = []
if changed:
@ -187,8 +188,8 @@ def _merge_track_fields(orig: dict, conflict: dict) -> list[str]:
def _merge_playlist(original_path: Path, conflict_path: Path) -> ConflictSummary:
original = _load_json(original_path)
conflict = _load_json(conflict_path)
original = read_json(original_path)
conflict = read_json(conflict_path)
name = original.get("name") or conflict.get("name") or original_path.stem
# Smart playlists: membership is derived, so keep the newer criteria and let
@ -216,7 +217,7 @@ def _merge_playlist(original_path: Path, conflict_path: Path) -> ConflictSummary
original["track_ids"] = orig_ids + extra
order_src = "this machine"
_save_json(original_path, original)
write_json(original_path, original)
added = len(set(original["track_ids"]) - orig_set)
lines = [f"Order kept from {order_src} (most recently edited)."]
@ -252,15 +253,3 @@ def _stars(rating) -> str:
def _copy(src: Path, dest: Path):
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dest)
def _load_json(path: Path) -> dict:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def _save_json(path: Path, data: dict):
tmp = path.with_suffix(path.suffix + ".tmp")
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
tmp.replace(path)

View File

@ -32,7 +32,7 @@ def save_tracks(library: Library, data_dir: Path):
# across machines that sync the folder to different mount points.
data["location"] = to_relative(data["location"], data_dir)
tracks_dict[str(tid)] = data
_write_json(data_dir / "library.json", tracks_dict)
write_json(data_dir / "library.json", tracks_dict)
def save_metadata(library: Library, data_dir: Path):
@ -44,13 +44,13 @@ def save_metadata(library: Library, data_dir: Path):
"playlist_count": len(library.playlists),
"library_settings": library.library_settings.to_dict(),
}
_write_json(data_dir / "library_metadata.json", metadata)
write_json(data_dir / "library_metadata.json", metadata)
def save_playlist(playlist: Playlist, data_dir: Path):
playlists_dir = data_dir / "playlists"
playlists_dir.mkdir(parents=True, exist_ok=True)
_write_json(playlists_dir / f"{playlist.persistent_id}.json", playlist.to_dict())
write_json(playlists_dir / f"{playlist.persistent_id}.json", playlist.to_dict())
def delete_playlist_file(persistent_id: str, data_dir: Path):
@ -67,7 +67,7 @@ def load_library(data_dir: Path) -> Library:
tracks = {}
library_path = data_dir / "library.json"
if library_path.exists():
tracks_dict = _read_json(library_path)
tracks_dict = read_json(library_path)
for tid_str, track_data in tracks_dict.items():
track = Track.from_dict(track_data)
# Stored relative to the data dir; resolve back to an absolute path.
@ -80,7 +80,7 @@ def load_library(data_dir: Path) -> Library:
library_settings = PlaylistSettings()
metadata_path = data_dir / "library_metadata.json"
if metadata_path.exists():
metadata = _read_json(metadata_path)
metadata = read_json(metadata_path)
music_folder = metadata.get("music_folder", "")
import_date = metadata.get("import_date")
if "library_settings" in metadata:
@ -91,7 +91,7 @@ def load_library(data_dir: Path) -> Library:
playlists_dir = data_dir / "playlists"
if playlists_dir.exists():
for playlist_file in playlists_dir.glob("*.json"):
playlist_data = _read_json(playlist_file)
playlist_data = read_json(playlist_file)
playlist = Playlist.from_dict(playlist_data)
playlists[playlist.persistent_id] = playlist
@ -104,13 +104,13 @@ def load_library(data_dir: Path) -> Library:
)
def _write_json(path: Path, data: dict):
def write_json(path: Path, data: dict):
tmp = path.with_suffix(path.suffix + ".tmp")
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
tmp.replace(path)
def _read_json(path: Path) -> dict:
def read_json(path: Path) -> dict:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)