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). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015wrTys5U1fLb4pBD2LKWzV
117 lines
4.3 KiB
Python
117 lines
4.3 KiB
Python
"""Where everything lives under ``Music/andTunes/`` on the device.
|
|
|
|
The old per-playlist sync stores a song once *per playlist*, which is fine for
|
|
handing Auxio a folder and wrong for an app with Artists/Albums/Songs screens:
|
|
"all songs it knows about" would have to de-duplicate at read time, and a song
|
|
in three playlists would cost three copies of the file. So andTunes gets an
|
|
iTunes-shaped media tree — each track stored exactly once — with playlists
|
|
reduced to m3u files pointing into it.
|
|
|
|
Music/andTunes/
|
|
library.json the index the app reads at startup
|
|
Media/<Artist>/<Album>/04 Song.mp3
|
|
Art/<album key>.jpg one cover per album, not per track
|
|
Playlists/<Name>.m3u
|
|
Buttons/*.png the user's own menu artwork
|
|
plays/ counts the device writes back
|
|
|
|
Two of those directories are the *device's*, not ours: `Buttons` holds artwork
|
|
the user dropped there by hand and `plays` is written by the app. Sync never
|
|
deletes inside either — see PRESERVED_DIRS.
|
|
"""
|
|
|
|
import hashlib
|
|
import os
|
|
from pathlib import Path
|
|
|
|
from ..device_sync import sanitize_name
|
|
|
|
# Bumped when the on-device layout or manifest changes shape. The app refuses
|
|
# a format it doesn't know rather than half-reading it.
|
|
FORMAT_VERSION = 1
|
|
|
|
ROOT_NAME = "andTunes" # under <storage>/Music/
|
|
MEDIA_DIR = "Media"
|
|
ART_DIR = "Art"
|
|
PLAYLISTS_DIR = "Playlists"
|
|
BUTTONS_DIR = "Buttons"
|
|
PLAYS_DIR = "plays"
|
|
MANIFEST_NAME = "library.json"
|
|
|
|
# Subtrees of the andTunes root that sync reads but never writes or prunes.
|
|
PRESERVED_DIRS = frozenset({BUTTONS_DIR, PLAYS_DIR})
|
|
|
|
UNKNOWN_ARTIST = "Unknown Artist"
|
|
UNKNOWN_ALBUM = "Unknown Album"
|
|
|
|
|
|
class DeviceLayoutError(Exception):
|
|
"""A path that would land outside the andTunes root."""
|
|
|
|
|
|
def root_for(device) -> Path:
|
|
"""The andTunes root on a connected device."""
|
|
return device.music_dir / ROOT_NAME
|
|
|
|
|
|
def assert_inside(root: Path, path: Path) -> Path:
|
|
"""Return `path`, or raise if it isn't strictly inside `root`.
|
|
|
|
Every delete on the device goes through here. andTunes is the first
|
|
feature that removes a *directory* from the Rabbit, and the blast radius
|
|
of getting that wrong is somebody's music — so the containment check is a
|
|
hard gate rather than a careful caller. It also means the old
|
|
``Music/<Playlist>/`` folders are structurally untouchable: they aren't
|
|
under the root, so no code path in this package can reach them.
|
|
"""
|
|
root_r = Path(os.path.abspath(root))
|
|
path_r = Path(os.path.abspath(path))
|
|
if path_r == root_r or not path_r.is_relative_to(root_r):
|
|
raise DeviceLayoutError(f"{path} is not inside {root}")
|
|
return path
|
|
|
|
|
|
def _artist_folder(track) -> str:
|
|
return sanitize_name(track.album_artist or track.artist or UNKNOWN_ARTIST)
|
|
|
|
|
|
def _album_folder(track) -> str:
|
|
return sanitize_name(track.album or UNKNOWN_ALBUM)
|
|
|
|
|
|
def media_relpath(track) -> str:
|
|
"""``Media/<Artist>/<Album>/04 Song.mp3``, relative to the andTunes root.
|
|
|
|
Numbering follows the iTunes tree `filename_tags` already knows how to
|
|
read back: a bare ``NN`` track number, or ``D-NN`` once a release has more
|
|
than one disc. A track with no number keeps just its title.
|
|
"""
|
|
stem = sanitize_name(track.name or Path(track.location).stem
|
|
or f"Track {track.track_id}")
|
|
if track.track_number:
|
|
if track.disc_number and (track.disc_count > 1 or track.disc_number > 1):
|
|
stem = f"{track.disc_number}-{track.track_number:02d} {stem}"
|
|
else:
|
|
stem = f"{track.track_number:02d} {stem}"
|
|
ext = Path(track.location).suffix
|
|
return f"{MEDIA_DIR}/{_artist_folder(track)}/{_album_folder(track)}/{stem}{ext}"
|
|
|
|
|
|
def album_key(track) -> str:
|
|
"""A stable 8-hex id for the album a track belongs to.
|
|
|
|
Keyed on album artist + album so every track of a release shares one cover
|
|
file — 300 albums instead of 3000 covers, and one mutagen open per album
|
|
at sync time instead of one per song.
|
|
"""
|
|
ident = f"{(track.album_artist or track.artist).lower()}\x00{track.album.lower()}"
|
|
return hashlib.sha1(ident.encode("utf-8")).hexdigest()[:8]
|
|
|
|
|
|
def art_relpath(key: str) -> str:
|
|
return f"{ART_DIR}/{key}.jpg"
|
|
|
|
|
|
def playlist_relpath(name: str) -> str:
|
|
return f"{PLAYLISTS_DIR}/{sanitize_name(name)}.m3u"
|