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:
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
Reference in New Issue
Block a user