Files
lintunes/lintunes/andtunes/layout.py
T
travandClaude Opus 5 cc1ca5ef78 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).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wrTys5U1fLb4pBD2LKWzV
2026-09-06 03:25:40 -04:00

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"