Files
lintunes/lintunes/preferences.py
T
trav c59aa26263 v0.17.0: the desktop already knows what the songs are
The first half of andTunes, a music player for the Rabbit R1. Auxio re-reads
Android's MediaStore on every launch, which is the "your songs will show up
here" hang — so the answer isn't a faster scanner, it's not scanning at all.
LinTunes already knows the artist, album and year of every file it just
copied, so it writes them into Music/andTunes/library.json and the app reads
one file instead of indexing a filesystem.

New lintunes/andtunes/: layout.py (a de-duplicated Media/<Artist>/<Album>/
tree, so a song in three playlists is stored once), manifest.py (pure, Qt-free),
art.py (one 480 px cover per album via QImage, cached and invalidated by the
audio file's mtime — which is exactly what embedding new art moves), and
sync.py, a third sibling of plan_export/ExportWorker.

Three rules the code depends on: planning never renders art (a mutagen open
per album, and planning runs on the GUI thread); the index is written last and
after a cancel lists only tracks whose files actually landed; and every delete
goes through layout.assert_inside, which is why the old Music/<Playlist>/
folders are structurally unreachable rather than merely un-referenced.

Connections gains "Sync to Rabbit" and "Rabbit Sync Settings…" — the ticked
set is the device's contents, so unticking is how a playlist comes off, which
nothing could do before. The selection rides preferences.json so both machines
agree. The old per-playlist sync stays, renamed "(Auxio)": removing it now
would leave the device full of files and nothing able to open them until the
app exists.

The app itself is rounds 48-50; its board is andtunes/TASKS.md, including the
measured screen facts (480x640 px at density override 160 — one dp is half its
usual physical size on a 2.88" panel).

Claude-Session: https://claude.ai/code/session_015wrTys5U1fLb4pBD2LKWzV
2026-09-06 03:25:40 -04:00

123 lines
4.2 KiB
Python

import copy
import json
from pathlib import Path
from PyQt6.QtCore import QObject, pyqtSignal
DEFAULTS = {
"ui_scale": "medium", # small | medium | large
"highlight": "blue", # see theme.HIGHLIGHT_COLORS
"now_playing_font": None, # None=prefer Century Gothic, ""=use app
# default, "Family"=use app
"volume": 0.8, # logical 0..1 master output volume
"visualizer_mode": "on", # on (color) | dim (gray) | off — click cycle
# Per-area grayscale overrides (0=black .. 255=white). None = inherit the
# current/default look for that surface, so nothing changes until the user
# drags a slider in Preferences.
"color_background": None, # outer chrome (QPalette.Window)
"color_button": None, # rounded transport button boxes
"color_now_playing_bg": None, # panel behind the now-playing text
"color_now_playing_text": None, # the now-playing title/artist text
"color_stripe": None, # alternating row stripes (QPalette.AlternateBase)
"color_visualizer_gray": None, # visualizer bars in dim (gray) mode
"lastfm": {
"api_key": "",
"api_secret": "",
"username": "",
"session_key": "",
"scrobble_enabled": False,
},
# Track identification (Identify Track…). The application key is the
# user's own, registered free at acoustid.org/new-application.
"acoustid": {
"api_key": "",
},
# Which playlists belong on the andTunes device, by persistent_id. Kept
# here rather than on the Playlist so it stays out of the conflict-merge
# machinery — and because riding the syncthing share is the point: both
# machines should agree on what the Rabbit is carrying.
"device_sync": {
"playlist_ids": [],
"copy_art": True,
},
}
class Preferences(QObject):
"""App preferences persisted to <data_dir>/preferences.json (so they ride
the same syncthing share as the library)."""
changed = pyqtSignal()
def __init__(self, data_dir: Path, parent=None):
super().__init__(parent)
self._path = Path(data_dir) / "preferences.json"
self._data = copy.deepcopy(DEFAULTS)
if self._path.exists():
try:
with open(self._path, "r", encoding="utf-8") as f:
stored = json.load(f)
_deep_merge(self._data, stored)
except (ValueError, OSError):
pass
def get(self, key: str, default=None):
return self._data.get(key, default)
def set(self, key: str, value):
if self._data.get(key) == value:
return
self._data[key] = value
self.save()
self.changed.emit()
def set_live(self, key: str, value):
"""Update a preference and notify listeners *without* writing to disk —
for live drag-preview (e.g. the color sliders). Call save() to persist
once the user stops dragging."""
self._data[key] = value
self.changed.emit()
def update_lastfm(self, **fields):
self._data["lastfm"].update(fields)
self.save()
self.changed.emit()
@property
def lastfm(self) -> dict:
return self._data["lastfm"]
def update_acoustid(self, **fields):
self._data["acoustid"].update(fields)
self.save()
self.changed.emit()
@property
def acoustid(self) -> dict:
return self._data["acoustid"]
def update_device_sync(self, **fields):
self._data["device_sync"].update(fields)
self.save()
self.changed.emit()
@property
def device_sync(self) -> dict:
return self._data["device_sync"]
def save(self):
self._path.parent.mkdir(parents=True, exist_ok=True)
tmp = self._path.with_suffix(".json.tmp")
with open(tmp, "w", encoding="utf-8") as f:
json.dump(self._data, f, indent=2)
tmp.replace(self._path)
def _deep_merge(base: dict, override: dict):
for key, value in override.items():
if isinstance(value, dict) and isinstance(base.get(key), dict):
_deep_merge(base[key], value)
else:
base[key] = value