v0.17.1: a library to break

Round 47 was verified by loading trav's real 21,531-track library. Nothing was
written and nothing was at risk, but it's the wrong habit and he said so. The
alternative is better for development anyway: reproducible, in-repo, and full
of the awkward cases on purpose rather than by luck.

scripts/make_dev_library.py builds a complete synthetic library — data dir and
music tree — from ffmpeg sine waves in six containers, with art on some albums
and not others, every playlist type (regular, folder, live/non-live/
unsupported/limited+nested smart, system, empty, duplicates), a play journal
and a tombstone. ~4 MB, gitignored; the generator is the artifact worth
keeping, not the sine waves.

The edge cases are the point. A collision pair identical in artist/album/title/
number, slashes and colons in every name, a track with no artist, a multi-disc
release, a compilation whose album_artist differs, a dangling location, a
208-character title, and non-ASCII plus an emoji all the way out to the m3u
filename. CLAUDE.md now carries the rule: never develop against the real
library, and when a feature needs a shape the fixture lacks, add it here.

Using it found two bugs in it — a relative --out made every location resolve
against the wrong root, and four-minute uncompressed clips made it 62 MB.

andTunes is paused on a weak connection (the Android SDK is a ~1-1.5 GB
one-time download; after that --offline builds need nothing). andtunes/
README.md now carries everything needed to resume cold: measured device facts,
the dp trap, exact toolchain commands, locked design decisions, and the
library.json contract. JDK 21 is installed and ticked off.

Also answers the scroll wheel question: it reads as volume because the ROM's
key layout maps the wheel's KEY_UP/KEY_DOWN to KEYCODE_VOLUME_UP/DOWN, but a
focused activity sees key events first — so andTunes can claim it by consuming
both those and DPAD_UP/DOWN, in onKeyDown and onKeyUp. No system file touched,
no other app affected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wrTys5U1fLb4pBD2LKWzV
This commit is contained in:
2026-09-10 00:49:26 -04:00
co-authored by Claude Opus 5
parent cc1ca5ef78
commit bcba621f3f
8 changed files with 696 additions and 7 deletions
+3
View File
@@ -18,3 +18,6 @@ itunes-test-library/
# Local-only Claude settings # Local-only Claude settings
.claude/settings.local.json .claude/settings.local.json
# Synthetic library for development (scripts/make_dev_library.py)
/dev-library/
+14
View File
@@ -415,6 +415,20 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
AND push** (trav's standing request, 2026-07-03: both machines ride the AND push** (trav's standing request, 2026-07-03: both machines ride the
bleeding edge, sync as often as possible) — so never leave master in a bleeding edge, sync as often as possible) — so never leave master in a
half-working state between commits you push. half-working state between commits you push.
- **Never develop or verify against trav's real library.** It is 21,531 tracks
of irreplaceable music and no amount of read-only care makes it the right
thing to point a half-finished feature at. `scripts/make_dev_library.py`
builds a synthetic one that has the same *shapes* — every playlist type
(regular, folder, live/non-live/unsupported smart, system, empty,
duplicates), six container formats, art on some albums and not others, a
play journal, a tombstone, and a deliberate pile of naming edge cases (a
collision pair differing only by id, an emoji album, a 208-character title,
forbidden characters, a dangling location). Build it with
`python3 scripts/make_dev_library.py --out dev-library` and run against it
with `--data-dir dev-library/data`; it's gitignored, the generator isn't.
When a feature needs a shape the fixture lacks, **add it to the generator**
rather than reaching for the real library.
- `scripts/` holds one-off maintenance tools (`audit_artwork.py`, - `scripts/` holds one-off maintenance tools (`audit_artwork.py`,
`recover_artwork.py`, `clear_computed_ratings.py`) run manually against a data `recover_artwork.py`, `clear_computed_ratings.py`) run manually against a data
dir; most default to dry-run and need `--write` to mutate files. dir; most default to dry-run and need `--write` to mutate files.
+14
View File
@@ -9,6 +9,20 @@ When a round closes, move its finished items to `tasks-done.md`.
- [ ] archive the done tasks in here to another file, this is crufty.... - [ ] archive the done tasks in here to another file, this is crufty....
## Round 48 (2026-09-10) — A library to break: done, see tasks-done.md
`scripts/make_dev_library.py` + the andTunes resume docs. Left on the table:
- [ ] **The fixture has no iTunes XML.** `test_itunes_importer.py` still leans
on the gitignored `itunes-test-library/`. A generator flag that emits a
matching `iTunes Library.xml` would make the import path developable
without trav's own export too.
- [ ] **No fixture for a Syncthing conflict.** The merge rules are the
subtlest thing in the codebase and the generator could plant a
`*.sync-conflict-*` pair on demand — two copies with different
`date_modified`, one stamped and one not — instead of every conflict
test hand-rolling one.
## Round 47 (2026-09-06) — LinTunes writes the device's library: done, see tasks-done.md ## Round 47 (2026-09-06) — LinTunes writes the device's library: done, see tasks-done.md
The desktop half of andTunes: a de-duplicated `Music/andTunes/` tree, a The desktop half of andTunes: a de-duplicated `Music/andTunes/` tree, a
+208
View File
@@ -0,0 +1,208 @@
# andTunes
A music player for the Rabbit R1 that **never scans anything**.
Every other Android player rebuilds its library on the device. Auxio reads
Android's MediaStore on each launch, which on a Helio P35 is the "your songs
will show up here" hang — and Musicolet, AIMP, Vinyl, Retro, Gramophone and
Fossify are all MediaStore-backed too. Symfonium and VLC keep their own
database but still scan the device to fill it, and both assume a phone-sized
screen. (trav sideloaded Musicolet in September 2026 to check: "it was…
fine?" — nothing off the shelf is built around already knowing the answer.)
LinTunes *does* already know the answer. It has the artist, album, year and
track number of every file it just copied, so it writes them into
`Music/andTunes/library.json` at sync time and the app parses one file
instead of indexing a filesystem. That is the entire architectural bet.
**Status (2026-09-10): the desktop half is done and shipped** in LinTunes
v0.17.0 (`lintunes/andtunes/`, round 47). The Android app has not been
started. Work is paused on a slow connection — see *Toolchain* below for the
one blocking download.
---
## The device
Measured on trav's R1, not taken from the internet (which says 240×282 and is
wrong):
| | |
|---|---|
| Panel | **480 × 640 px**, physical density 320, **override density 160** |
| Build | Android **13**, API **33**, `gsi_r1-userdebug` (r1_escape AOSP flash) |
| CPU | MediaTek Helio P35, arm64-v8a, 4 GB RAM |
| Storage | 128 GB, ~65 GB free |
| Scroll wheel | input device `och1970_holl_key` → **KEY_UP / KEY_DOWN** |
| Side/PTT button | `mtk-kpd`: KEY_VOLUMEDOWN + KEY_POWER |
| Headset | `mt63xx-accdet`: KEY_PLAYPAUSE → a MediaSession gets media keys free |
### The dp trap
Density is overridden to 160, so an app sees a 480 × 640 **dp** canvas — but
the panel is 2.88", about 278 real ppi. **One dp is roughly half its usual
physical size here.** A stock 48 dp list row is 4.4 mm tall and unusable.
> **Rule: double every stock dp value.** Body text 28–32 sp, list rows ≥ 88 dp,
> menu tiles ~213 dp. A 2 × 3 grid gives six 240 × 213 dp buttons, ≈ 22 × 19 mm
> each.
Do **not** "fix" this by changing the device's density. trav has it where he
wants it and asked for it to be left alone.
### The scroll wheel, and whether we can have it
trav reports the wheel acts as **volume** in every app, even though the kernel
device emits KEY_UP/KEY_DOWN — so the ROM ships a key layout mapping those to
`KEYCODE_VOLUME_UP`/`DOWN`. Confirm which one actually arrives when the device
is next plugged in:
```sh
adb shell 'ls /system/usr/keylayout/ | grep -i holl'
adb shell 'cat /system/usr/keylayout/och1970_holl_key.kl' # if it exists
```
Either way **an app can absolutely claim it for itself**, without touching a
system file or affecting any other app. A foreground Activity sees key events
before the system's volume handling, so:
```kotlin
override fun onKeyDown(code: Int, e: KeyEvent): Boolean = when (code) {
KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_DPAD_UP -> { wheelUp(); true }
KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_DPAD_DOWN -> { wheelDown(); true }
else -> super.onKeyDown(code, e)
}
```
Handle **both** mappings so it works whichever the ROM sends, and consume
`onKeyUp` for the same codes too — returning true only from `onKeyDown` can
still let the system act on the release. Returning true is what stops the
volume UI appearing. This is ordinary Android (camera apps do it for the
shutter); it applies only while an andTunes activity is focused, so volume
behaves normally everywhere else.
Worth deciding once it's testable: the wheel scrolls lists everywhere, and on
the now-playing screen it probably *should* stay volume — that's the one
screen where the stock behaviour is the useful one.
---
## Toolchain
Already done on this machine:
- `java-21-openjdk-devel` — installed, `javac` on PATH. (Java 25 is also
installed; AGP won't run on it, so the Gradle build must be pointed at 21.)
- `adb` / `fastboot` — in `/usr/local/sbin`, platform-tools 31.0.3.
**The one thing left, and the reason this is paused: the Android SDK is a
~1–1.5 GB download** and trav is on a very weak connection.
```sh
# 1. commandline-tools (~150 MB) from developer.android.com/studio
# (bottom of the page, "Command line tools only", linux zip)
mkdir -p ~/Android/Sdk/cmdline-tools
unzip commandlinetools-linux-*_latest.zip -d ~/Android/Sdk/cmdline-tools
mv ~/Android/Sdk/cmdline-tools/cmdline-tools ~/Android/Sdk/cmdline-tools/latest
export ANDROID_HOME="$HOME/Android/Sdk"
export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$PATH"
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk
# 2. the platform we build against (~150 MB total)
sdkmanager --licenses
sdkmanager "platforms;android-33" "build-tools;34.0.0"
```
Then the **first** Gradle build pulls the wrapper (~130 MB) and the Android
Gradle Plugin's dependencies (several hundred MB). After that it's all cached
and `./gradlew --offline assembleDebug` works with no connection at all — so
the download is a one-time cost, but it is not a small one. Do it on a decent
connection.
Fedora note: don't `dnf install android-tools` over the existing
`/usr/local/sbin/adb`; two adb versions on PATH fight over the server port.
---
## Building and installing (once the SDK is there)
```sh
cd andtunes
./gradlew assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apk
adb shell am start -n me.teafry.andtunes/.MenuActivity
# the number the whole project is judged on:
adb shell am start -W -n me.teafry.andtunes/.MenuActivity | grep TotalTime
```
Target: menu visible in **under 400 ms**.
---
## Design decisions already locked
- **Kotlin, no Compose, no androidx, no Media3.** Framework `Activity`,
`ListView`, `MediaPlayer`, `MediaSession`, `Notification.MediaStyle`, R8
minify. Cold start is the feature; there is nothing to initialize if nothing
is linked in. Same instinct as `trash.py` and `sync_identity.py` on the
LinTunes side.
- **Package** `me.teafry.andtunes`, minSdk 26, targetSdk 33, arm64-v8a.
- **`MANAGE_EXTERNAL_STORAGE`** with a first-run screen sending the user to
the toggle. `READ_MEDIA_AUDIO` grants the audio files but *not*
`library.json`, which isn't media — that's the whole reason. Fallback if it
turns out to be blocked: a one-time SAF folder pick.
- **Screens are activities**, so "the last screen we were at" is Android's own
back stack with no bookkeeping. `MenuActivity` (six tiles, loads nothing),
one generic `ListActivity` parameterised for every list in the spec,
`NowPlayingActivity`, and a foreground `PlaybackService`.
- **No back button on the now-playing screen** — Android's own is always on
screen and the panel is tiny (trav's call).
- **End of a queue pauses.** It does not roll into shuffle-all. Repeat is
off / all / one.
- **Replaceable button artwork**: the app loads `Buttons/<name>.png` from the
device folder when present, else a bundled drawable. LinTunes fills in
defaults only where the file is *missing*, so trav's own art is never
overwritten.
## What the app reads
LinTunes writes all of this; see `lintunes/andtunes/layout.py` for the
authority and `manifest.py` for the exact rows.
```
/sdcard/Music/andTunes/
library.json format 1; refuse an unknown format loudly
Media/<Artist>/<Album>/04 Song.mp3
Art/<8-hex album key>.jpg 480 px, one per album
Playlists/<Name>.m3u entries are ../Media/… relative paths
Buttons/*.png the user's own menu artwork — app reads only
plays/andtunes-<installid>.json the app writes counts back here (phase 3)
```
```json
{"format":1,"generated":"…","generator":"lintunes 0.17.0",
"tracks":[{"id":123,"title":"Alhambra","path":"Media/…/04 Alhambra.mp3",
"artist":"Ahmad Jamal","album":"…","year":1961,"track":4,
"secs":214,"art":"Art/3f2a1b9c.jpg"}],
"playlists":[{"id":"A1B2C3D4","name":"Roadtrip","tracks":[123,456]}]}
```
Rows are **sparse** — empty and zero fields are omitted, so use
`optString`/`optInt`. Artists and albums are deliberately *not* shipped: group
the flat list in one pass at load rather than parsing three redundant lists.
Titles are stored in full even when the filename was truncated, so always
display `title`, never the filename.
## Testing without a Rabbit
`python3 scripts/make_dev_library.py --out dev-library` builds a synthetic
LinTunes library, and syncing it produces a real `Music/andTunes/` tree you can
push onto any Android device or emulator with `adb push`. That tree includes
the awkward cases on purpose — an emoji album name, a 208-character title, two
tracks that differ only by id, a track with no artist.
## Board
`TASKS.md` in this directory.
+20 -6
View File
@@ -6,6 +6,12 @@ A music player for the Rabbit R1 that never scans anything: LinTunes writes
Legend: `[ ]` todo · `[~]` in progress · `[x]` done. Legend: `[ ]` todo · `[~]` in progress · `[x]` done.
**Status: paused 2026-09-10** on a weak connection — the Android SDK is a
~1–1.5 GB download. Everything needed to resume cold is in `README.md`:
device facts, toolchain commands, locked design decisions, and the exact
shape of the `library.json` LinTunes already writes. Phases are andTunes'
own; which LinTunes round each lands in is decided when it starts.
## Device facts (measured 2026-09-06, `adb shell wm size` etc.) ## Device facts (measured 2026-09-06, `adb shell wm size` etc.)
- Panel **480 × 640 px**, physical density 320, **override density 160** — so - Panel **480 × 640 px**, physical density 320, **override density 160** — so
@@ -20,11 +26,15 @@ Legend: `[ ]` todo · `[~]` in progress · `[x]` done.
KEY_VOLUMEDOWN + KEY_POWER. Headset exposes KEY_PLAYPAUSE, so a MediaSession KEY_VOLUMEDOWN + KEY_POWER. Headset exposes KEY_PLAYPAUSE, so a MediaSession
gets media keys for free. gets media keys for free.
## Round 48 — plays a song (v0.1.0) ## Phase 1 — plays a song (v0.1.0)
- [ ] Toolchain: `sudo dnf install java-21-openjdk-devel`, Android - [x] `java-21-openjdk-devel` — installed, `javac` on PATH. (Java 25 is also
commandline-tools + platform 33 + build-tools into `~/Android/Sdk`. present; AGP won't run on it, so point the build at 21.)
(Java 25 is installed but AGP won't run on it.) Gradle via the wrapper. - [ ] **Android SDK — the one blocker.** commandline-tools + platform 33 +
build-tools into `~/Android/Sdk`, then a first Gradle build to warm the
wrapper and AGP caches. ~1–1.5 GB all told; after that
`./gradlew --offline assembleDebug` needs no connection. Exact commands
in `README.md`.
- [ ] Gradle project, package `me.teafry.andtunes`, minSdk 26 / targetSdk 33. - [ ] Gradle project, package `me.teafry.andtunes`, minSdk 26 / targetSdk 33.
**No Compose, no androidx, no Media3** — framework `Activity`, **No Compose, no androidx, no Media3** — framework `Activity`,
`ListView`, `MediaPlayer`, `MediaSession`, `Notification.MediaStyle`, `ListView`, `MediaPlayer`, `MediaSession`, `Notification.MediaStyle`,
@@ -46,13 +56,17 @@ Legend: `[ ]` todo · `[~]` in progress · `[x]` done.
- [ ] End of queue **pauses** unless repeat is on. Repeat: off / all / one. - [ ] End of queue **pauses** unless repeat is on. Repeat: off / all / one.
- [ ] Verify with `adb shell am start -W` — target: menu visible < 400 ms. - [ ] Verify with `adb shell am start -W` — target: menu visible < 400 ms.
## Round 49 — the rest of the screens ## Phase 2 — the rest of the screens
- [ ] Playlists, artists (→ albums + "All songs" + shuffle for that artist), - [ ] Playlists, artists (→ albums + "All songs" + shuffle for that artist),
albums, search. albums, search.
- [ ] Album art thumbs in album rows, full art on now playing. - [ ] Album art thumbs in album rows, full art on now playing.
- [ ] The "Now playing" bar at the bottom of every list screen. - [ ] The "Now playing" bar at the bottom of every list screen.
- [ ] Scroll wheel → list scrolling, and volume on the now-playing screen. - [ ] Scroll wheel → list scrolling, and volume on the now-playing screen.
Consume `KEYCODE_VOLUME_UP/DOWN` **and** `DPAD_UP/DOWN` in both
`onKeyDown` and `onKeyUp` — the ROM's key layout decides which arrives,
and claiming them in a focused activity affects no other app. First
check `/system/usr/keylayout/` on the device; see `README.md`.
- [ ] Replaceable button images: load `Buttons/<name>.png` from the device - [ ] Replaceable button images: load `Buttons/<name>.png` from the device
folder when present, else the bundled drawable. LinTunes ships defaults folder when present, else the bundled drawable. LinTunes ships defaults
into `Buttons/` only when the file is missing, so the user's own into `Buttons/` only when the file is missing, so the user's own
@@ -61,7 +75,7 @@ Legend: `[ ]` todo · `[~]` in progress · `[x]` done.
the queue. the queue.
- [ ] Resume last track + position on launch. - [ ] Resume last track + position on launch.
## Round 50 — install from LinTunes, counts coming back ## Phase 3 — install from LinTunes, counts coming back
- [ ] `Connections → Install andTunes on Rabbit…`: `shutil.which("adb")` - [ ] `Connections → Install andTunes on Rabbit…`: `shutil.which("adb")`
(the `ffmpeg_available()` runtime-tool pattern — never a pip dep), then (the `ffmpeg_available()` runtime-tool pattern — never a pip dep), then
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux.""" """LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.17.0" __version__ = "0.17.1"
+378
View File
@@ -0,0 +1,378 @@
#!/usr/bin/env python3
"""Build a throwaway LinTunes library to develop and verify against.
trav's real library is 21,531 tracks of irreplaceable music, and pointing dev
work at it to "check something works" is a bad trade however careful the code
is. This builds a synthetic one that has the same *shapes* — every playlist
type, several container formats, embedded art on some albums and not others,
and a deliberate pile of naming edge cases — so a change can be exercised
end-to-end without the real thing ever being opened.
python3 scripts/make_dev_library.py --out dev-library
python3 -m lintunes.main --data-dir dev-library/data
The output is gitignored: the generator is the artifact worth keeping, not a
few megabytes of sine waves. Regenerating is idempotent — pass --force to
overwrite an existing one.
Needs ffmpeg on PATH (same dependency the test fixtures already have).
The edge cases here are not decoration. Each one has bitten something:
* two tracks identical in artist/album/title/number → the [track_id] suffix
rule in device_sync and andtunes/layout;
* slashes and colons in names → sanitize_name;
* a track with no album, and one with no artist → Unknown Artist/Album;
* a multi-disc release → the "2-04" prefix;
* a compilation whose album_artist differs → album grouping and
album art keying;
* a dangling location → the "skipped" counters;
* a 200-character title → the 150-char truncation;
* non-ASCII and an emoji → UTF-8 all the way to
the device's m3u.
"""
import argparse
import json
import os
import random
import shutil
import subprocess
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from lintunes import tagging # noqa: E402
from lintunes.models import Library, Playlist, Track # noqa: E402
from lintunes.models.playlist import PlaylistType # noqa: E402
from lintunes.smart import ( # noqa: E402
SmartCriteria, SmartGroup, SmartLimit, SmartRule,
)
from lintunes.storage import json_storage # noqa: E402
NOW = datetime(2026, 9, 10, 12, 0, 0, tzinfo=timezone.utc)
def _iso(days_ago: int = 0) -> str:
return (NOW - timedelta(days=days_ago)).strftime("%Y-%m-%dT%H:%M:%SZ")
# --------------------------------------------------------------------------- #
# Media generation
# --------------------------------------------------------------------------- #
CODECS = {
".mp3": ["-codec:a", "libmp3lame", "-b:a", "128k"],
".m4a": ["-codec:a", "aac", "-b:a", "128k"],
".flac": ["-codec:a", "flac"],
".wav": ["-codec:a", "pcm_s16le"],
".aiff": ["-codec:a", "pcm_s16be"],
".ogg": ["-codec:a", "libvorbis"],
}
def _ffmpeg(args) -> bool:
return subprocess.run(["ffmpeg", "-y", "-loglevel", "error", *args],
capture_output=True).returncode == 0
def make_audio(path: Path, seconds: float, freq: int) -> bool:
"""A short sine wave in whatever container the suffix asks for."""
path.parent.mkdir(parents=True, exist_ok=True)
codec = CODECS.get(path.suffix)
if codec is None:
return False
return _ffmpeg(["-f", "lavfi", "-i",
f"sine=frequency={freq}:duration={seconds}",
*codec, str(path)])
def make_cover(path: Path, color: str, size: int = 500) -> bytes | None:
"""A flat-colour JPEG standing in for album art."""
path.parent.mkdir(parents=True, exist_ok=True)
ok = _ffmpeg(["-f", "lavfi", "-i", f"color={color}:size={size}x{size}",
"-frames:v", "1", str(path)])
return path.read_bytes() if ok else None
# --------------------------------------------------------------------------- #
# The library's shape
# --------------------------------------------------------------------------- #
# (artist, album, album_artist, year, [(title, disc, track, suffix)], cover)
ALBUMS = [
("The Wheelhouse", "Hollow Season", "", 2011, [
("Low Tide", 1, 1, ".mp3"), ("Paper Kites", 1, 2, ".mp3"),
("Hollow Season", 1, 3, ".mp3"), ("Nine Volt", 1, 4, ".flac"),
], "steelblue"),
("Marguerite Vaux", "Nuit Blanche", "", 1968, [
("Étoile Filante", 1, 1, ".mp3"), ("Café Noir", 1, 2, ".m4a"),
("Rue de la Lune", 1, 3, ".mp3"),
], "darkred"),
# Two discs: exercises the "2-04" filename prefix.
("Osric Bell", "The Long Way Round", "", 1994, [
("Departure", 1, 1, ".mp3"), ("Midpoint", 1, 2, ".mp3"),
("Nightfall", 2, 1, ".mp3"), ("Arrival", 2, 4, ".mp3"),
], "seagreen"),
# A compilation: album_artist differs from every track artist, which is
# what album grouping and the album-art key have to get right.
("Kettle Drum Six", "Sounds of the Third Floor", "Various Artists", 2003, [
("Basement Tape", 1, 1, ".m4a"),
], "goldenrod"),
("Nell Ortega", "Sounds of the Third Floor", "Various Artists", 2003, [
("Stairwell", 1, 2, ".mp3"),
], None), # same album as above — one cover must serve both
# No cover at all, and a lossless container.
("Quiet Machines", "Untitled Sessions", "", 2019, [
("Drift I", 1, 1, ".flac"), ("Drift II", 1, 2, ".wav"),
], None),
# Formats Android or the browser may refuse — the future format gate.
("Analog Ghost", "Tape Hiss", "", 1987, [
("Reel One", 1, 1, ".aiff"), ("Reel Two", 1, 2, ".ogg"),
], "rebeccapurple"),
]
# Deliberate awkwardness. (title, artist, album, suffix, note)
EDGE_TRACKS = [
("AC/DC Tribute: Part 1", "Hi-Fi Bandits", "Live at 8:15", ".mp3",
"forbidden characters in every name"),
("Song", "Twin Records", "Doubles", ".mp3", "collision A"),
("Song", "Twin Records", "Doubles", ".mp3", "collision B"),
("Orphan Take", "", "", ".mp3", "no artist, no album"),
("わたしの音楽 🎧", "Sakura Denwa", "東京の夜", ".mp3", "non-ASCII + emoji"),
("A Title That Simply Refuses To Stop Going On And On " * 4, "Verbose",
"Excess", ".mp3", "200+ characters, hits the 150-char truncation"),
]
def build(out: Path, seed: int = 47):
random.seed(seed)
# Absolute, always: Track.location is absolute in memory and stored
# relative to the data dir by json_storage. Handing it a relative path
# makes every location resolve against the wrong root on load.
out = out.resolve()
data_dir = out / "data"
music_root = out / "Music" # music_folder; tree lives in Music/Music
tree = music_root / "Music"
library = Library()
library.music_folder = str(music_root)
library.music_folder_set_at = _iso(30)
library.import_date = _iso(120)
covers = out / ".covers"
next_id = 1
made, failed = 0, []
def add(track: Track):
nonlocal next_id
track.track_id = next_id
track.persistent_id = f"{next_id:016X}"
library.tracks[next_id] = track
next_id += 1
return track
# ---- albums ----
for artist, album, album_artist, year, songs, colour in ALBUMS:
cover = (make_cover(covers / f"{album}.jpg", colour)
if colour else None)
disc_count = max(d for _t, d, _n, _s in songs)
for title, disc, number, suffix in songs:
folder = tree / _safe(album_artist or artist) / _safe(album)
stem = (f"{disc}-{number:02d}" if disc_count > 1
else f"{number:02d}")
path = folder / f"{stem} {_safe(title)}{suffix}"
seconds = _clip_seconds(suffix)
if not make_audio(path, seconds, random.randint(180, 900)):
failed.append(str(path))
continue
if cover:
try:
tagging.write_artwork(path, cover)
except Exception:
pass # .wav/.ogg can't carry a cover; that's data too
made += 1
add(Track(
name=title, artist=artist, album=album,
album_artist=album_artist,
year=year, disc_number=disc, disc_count=disc_count,
track_number=number, track_count=len(songs),
total_time=int(seconds * 1000), location=str(path),
size=path.stat().st_size, kind=_kind(suffix),
genre=random.choice(["Rock", "Jazz", "Electronic", "Folk"]),
date_added=_iso(random.randint(1, 900)),
play_count=random.randint(0, 40),
rating=random.choice([0, 0, 60, 80, 100]),
compilation=bool(album_artist == "Various Artists"),
))
# ---- edge cases ----
for title, artist, album, suffix, _note in EDGE_TRACKS:
folder = tree / _safe(artist or "Unknown Artist") / _safe(album or "Unknown Album")
path = folder / f"{_safe(title)[:120]}{suffix}"
while path.exists(): # the deliberate collision pair
path = path.with_name(f"{path.stem}_{random.randint(10, 99)}{suffix}")
seconds = _clip_seconds(suffix)
if not make_audio(path, seconds, random.randint(180, 900)):
failed.append(str(path))
continue
made += 1
add(Track(name=title, artist=artist, album=album,
total_time=int(seconds * 1000), location=str(path),
size=path.stat().st_size, kind=_kind(suffix),
date_added=_iso(random.randint(1, 400))))
# A track whose file was moved or deleted out from under the library —
# every planner has a "skipped, no local file" path that needs exercising.
add(Track(name="Ghost Entry", artist="Nobody", album="Missing",
total_time=123_000, location=str(tree / "Nobody" / "Missing" / "gone.mp3"),
kind="MPEG audio file", date_added=_iso(10)))
ids = sorted(library.tracks)
_build_playlists(library, ids)
# ---- write it out ----
if data_dir.exists():
shutil.rmtree(data_dir)
json_storage.save_library(library, data_dir)
_write_plays(data_dir, ids)
_write_prefs(data_dir, library)
shutil.rmtree(covers, ignore_errors=True)
return library, made, failed
def _clip_seconds(suffix: str) -> float:
"""Short on purpose. Durations need to *differ* (sorting, smart-playlist
limits, m3u #EXTINF), not to be realistic — and a four-minute .wav is
40 MB of sine wave nobody listens to."""
if suffix in (".wav", ".aiff"):
return round(random.uniform(2, 5), 1) # uncompressed
return round(random.uniform(4, 20), 1)
def _kind(suffix: str) -> str:
return {".mp3": "MPEG audio file", ".m4a": "AAC audio file",
".flac": "FLAC audio file", ".wav": "WAV audio file",
".aiff": "AIFF audio file",
".ogg": "Ogg Vorbis audio file"}.get(suffix, "")
def _safe(name: str) -> str:
"""Only what a filesystem insists on — the library keeps the real name."""
for bad in '\\/:*?"<>|':
name = name.replace(bad, " ")
return " ".join(name.split()).strip(". ") or "Untitled"
def _build_playlists(library: Library, ids: list):
def pl(name, pid, track_ids=(), **kw):
p = Playlist(name=name, persistent_id=pid, track_ids=list(track_ids),
date_modified=_iso(random.randint(1, 60)), **kw)
library.playlists[pid] = p
return p
pl("Morning", "A1000000", ids[:6])
pl("Long Drive", "A2000000", ids[3:14])
# Duplicates: the same song twice in one playlist must copy once and list
# twice in the m3u.
pl("On Repeat", "A3000000", [ids[0], ids[1], ids[0], ids[2], ids[0]])
pl("Empty Shelf", "A4000000", [])
pl("mix: summer/2003 🎧", "A5000000", ids[-8:]) # awkward name
folder = pl("Boxes", "F1000000", playlist_type=PlaylistType.FOLDER)
pl("Inside A", "A6000000", ids[2:7],
parent_persistent_id=folder.persistent_id)
pl("Inside B", "A7000000", ids[7:11],
parent_persistent_id=folder.persistent_id)
# Live smart: membership recomputed on every load, never persisted.
pl("Well Played", "S1000000", playlist_type=PlaylistType.SMART,
smart_criteria=SmartCriteria(
root=SmartGroup("all", [SmartRule("play_count", "greater", 10)]),
live_update=True))
# Non-live smart: track_ids *are* the content, a snapshot that persists.
pl("Snapshot 2019", "S2000000", ids[:5],
playlist_type=PlaylistType.SMART,
smart_criteria=SmartCriteria(
root=SmartGroup("all", [SmartRule("year", "greater", 2000)]),
live_update=False))
# Unsupported criteria: also a snapshot, for a different reason.
pl("Imported Oddity", "S3000000", ids[5:9],
playlist_type=PlaylistType.SMART,
smart_criteria=SmartCriteria(unsupported=True))
# Limited + nested, the shapes the editor treats specially.
pl("Top Jazz", "S4000000", playlist_type=PlaylistType.SMART,
smart_criteria=SmartCriteria(
root=SmartGroup("all", [
SmartRule("genre", "is", "Jazz"),
SmartGroup("any", [SmartRule("rating", "greater", 60),
SmartRule("loved", "is", True)]),
]),
limit=SmartLimit(enabled=True, count=10, by="items",
selection="most_played"),
has_nested=True))
pl("Music", "SYS00001", ids, is_system=True,
playlist_type=PlaylistType.SYSTEM)
# A removal recorded inside the retention window, and a library-level
# tombstone: the merge rules are the subtlest thing in the codebase and
# deserve to exist in the fixture.
library.playlists["A1000000"].track_events = {
str(ids[8]): [_iso(2), "remove"], str(ids[9]): [_iso(1), "add"]}
library.deleted_tracks = {str(max(ids) + 1): _iso(3)}
def _write_plays(data_dir: Path, ids: list):
"""One machine's play journal, in the per-machine-totals shape."""
plays = data_dir / "plays"
plays.mkdir(parents=True, exist_ok=True)
entries = {str(tid): {"play_count": random.randint(1, 12),
"play_date_utc": _iso(random.randint(0, 200))}
for tid in random.sample(ids, k=min(8, len(ids)))}
(plays / "devbox.json").write_text(
json.dumps({"machine": "devbox", "tracks": entries}, indent=2),
encoding="utf-8")
def _write_prefs(data_dir: Path, library: Library):
(data_dir / "preferences.json").write_text(json.dumps({
"ui_scale": "medium",
"highlight": "blue",
"device_sync": {
# Something already ticked, so a sync has work to do out of the box.
"playlist_ids": ["A1000000", "A3000000", "S2000000"],
"copy_art": True,
},
}, indent=2), encoding="utf-8")
def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--out", default="dev-library", type=Path,
help="where to build it (default: dev-library)")
ap.add_argument("--force", action="store_true",
help="overwrite an existing library")
ap.add_argument("--seed", type=int, default=47)
args = ap.parse_args()
if shutil.which("ffmpeg") is None:
sys.exit("ffmpeg is needed to generate the audio files.")
if args.out.exists() and not args.force:
sys.exit(f"{args.out} already exists — pass --force to rebuild it.")
if args.out.exists():
shutil.rmtree(args.out)
library, made, failed = build(args.out, args.seed)
print(f"{args.out}: {made} audio files, {len(library.tracks)} tracks, "
f"{len(library.playlists)} playlists")
for path in failed:
print(f" ! could not generate {path}", file=sys.stderr)
print(f"\nRun it with:\n python3 -m lintunes.main --data-dir {args.out}/data")
if __name__ == "__main__":
main()
+58
View File
@@ -1,5 +1,63 @@
## Done ## Done
### Round 48 (2026-09-10) — A library to break (v0.17.1)
Round 47 was verified by loading trav's real 21,531-track library. Nothing was
written to it and nothing was at risk, but that is the wrong habit and he said
so: "it's too precious… mistakes happen and it makes me nervous." He's right —
the safety of a given run isn't the point when the alternative is a fixture
that's better for development anyway: reproducible, in-repo, and full of the
awkward cases on purpose rather than by luck.
- [x] **`scripts/make_dev_library.py`.** Builds a complete synthetic library —
data dir *and* music tree — from ffmpeg sine waves in six containers
(mp3, m4a, flac, wav, aiff, ogg), with embedded art on some albums and
none on others, every playlist type (regular, folder + children,
live/non-live/unsupported/limited+nested smart, system, empty,
duplicates), a per-machine play journal and a tombstone. ~4 MB, a few
seconds to build, `--force` to rebuild, gitignored — the generator is
the artifact worth keeping, not the sine waves.
- [x] **The edge cases are the point.** Each one has bitten something: a
collision pair identical in artist/album/title/number (the `[track_id]`
suffix), slashes and colons in every name (`sanitize_name`), a track
with no artist and none with an album (Unknown Artist/Album), a
multi-disc release (the `2-04` prefix), a compilation whose
`album_artist` differs from every track artist (album grouping and the
art key), a dangling `location` (the "skipped, no local file" counters),
a 208-character title (the 150-char truncation — and the manifest still
carries it in full, which is what the app displays), and non-ASCII plus
an emoji all the way out to the device's m3u filename.
- [x] **Two bugs in the generator, found by using it.** Passing a relative
`--out` made every `Track.location` resolve against the wrong root on
load, because locations are absolute in memory and stored relative to
the data dir; and four-minute uncompressed clips made the fixture 62 MB.
Durations only need to *differ* (sorting, smart-playlist limits,
`#EXTINF`), so they're now 2–20 s and the whole thing is 4.4 MB.
- [x] **Round 47 re-verified against the fixture**, not the real library:
first sync, an idempotent re-sync (0 copied, 6 kept), unticking, and the
awkward playlist end-to-end — `Song [19].mp3` / `Song [20].mp3` for the
collision pair, `mix summer 2003 🎧.m3u`, the dangling track counted as
skipped rather than fatal.
- [x] **A standing rule in CLAUDE.md**: never develop or verify against the
real library; when a feature needs a shape the fixture lacks, add it to
the generator.
- [x] **andTunes documented for a cold resume.** `andtunes/README.md` now
carries the measured device facts, the dp trap, the exact toolchain
commands, the locked design decisions and the `library.json` contract;
`andtunes/TASKS.md` renumbered to andTunes' own phases. Work is paused
on a weak connection — the Android SDK is a ~1–1.5 GB one-time download,
after which `./gradlew --offline` needs no connection at all. JDK 21 is
already installed and ticked off.
- [x] **The scroll wheel question answered.** It reads as volume in every app
because the ROM's key layout maps the wheel's KEY_UP/KEY_DOWN to
`KEYCODE_VOLUME_UP/DOWN` — but a focused activity sees key events first,
so andTunes can claim it by consuming both those *and* `DPAD_UP/DOWN`
(the mapping is unconfirmed) in `onKeyDown` **and** `onKeyUp`. No system
file touched, no other app affected.
Not done, deliberately: no iTunes XML in the fixture (the importer tests still
use the gitignored real export), and no synthetic Syncthing conflict pair.
### Round 47 (2026-09-06) — LinTunes writes the device's library (v0.17.0) ### Round 47 (2026-09-06) — LinTunes writes the device's library (v0.17.0)
The first half of andTunes, a music player for the Rabbit R1 that never scans The first half of andTunes, a music player for the Rabbit R1 that never scans