From bcba621f3f27cd327f5327671f31cb54d8b4f75c Mon Sep 17 00:00:00 2001 From: trav Date: Thu, 10 Sep 2026 00:49:26 -0400 Subject: [PATCH] v0.17.1: a library to break MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_015wrTys5U1fLb4pBD2LKWzV --- .gitignore | 3 + CLAUDE.md | 14 ++ TASKS.md | 14 ++ andtunes/README.md | 208 ++++++++++++++++++++ andtunes/TASKS.md | 26 ++- lintunes/__init__.py | 2 +- scripts/make_dev_library.py | 378 ++++++++++++++++++++++++++++++++++++ tasks-done.md | 58 ++++++ 8 files changed, 696 insertions(+), 7 deletions(-) create mode 100644 andtunes/README.md create mode 100644 scripts/make_dev_library.py diff --git a/.gitignore b/.gitignore index d4fbe87..16b8737 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ itunes-test-library/ # Local-only Claude settings .claude/settings.local.json + +# Synthetic library for development (scripts/make_dev_library.py) +/dev-library/ diff --git a/CLAUDE.md b/CLAUDE.md index 3f4b1b1..4121288 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 bleeding edge, sync as often as possible) — so never leave master in a 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`, `recover_artwork.py`, `clear_computed_ratings.py`) run manually against a data dir; most default to dry-run and need `--write` to mutate files. diff --git a/TASKS.md b/TASKS.md index 97dad27..984c21e 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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.... +## 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 The desktop half of andTunes: a de-duplicated `Music/andTunes/` tree, a diff --git a/andtunes/README.md b/andtunes/README.md new file mode 100644 index 0000000..d40dc3f --- /dev/null +++ b/andtunes/README.md @@ -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/.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///04 Song.mp3 + Art/<8-hex album key>.jpg 480 px, one per album + Playlists/.m3u entries are ../Media/… relative paths + Buttons/*.png the user's own menu artwork — app reads only + plays/andtunes-.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. diff --git a/andtunes/TASKS.md b/andtunes/TASKS.md index 2988b7d..cdb0304 100644 --- a/andtunes/TASKS.md +++ b/andtunes/TASKS.md @@ -6,6 +6,12 @@ A music player for the Rabbit R1 that never scans anything: LinTunes writes 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.) - 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 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 - commandline-tools + platform 33 + build-tools into `~/Android/Sdk`. - (Java 25 is installed but AGP won't run on it.) Gradle via the wrapper. +- [x] `java-21-openjdk-devel` — installed, `javac` on PATH. (Java 25 is also + present; AGP won't run on it, so point the build at 21.) +- [ ] **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. **No Compose, no androidx, no Media3** — framework `Activity`, `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. - [ ] 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), albums, search. - [ ] Album art thumbs in album rows, full art on now playing. - [ ] The "Now playing" bar at the bottom of every list 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/.png` from the device folder when present, else the bundled drawable. LinTunes ships defaults 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. - [ ] 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")` (the `ffmpeg_available()` runtime-tool pattern — never a pip dep), then diff --git a/lintunes/__init__.py b/lintunes/__init__.py index 1fdd790..334a1a7 100644 --- a/lintunes/__init__.py +++ b/lintunes/__init__.py @@ -1,3 +1,3 @@ """LinTunes — iTunes-style music library manager and player for Linux.""" -__version__ = "0.17.0" +__version__ = "0.17.1" diff --git a/scripts/make_dev_library.py b/scripts/make_dev_library.py new file mode 100644 index 0000000..9870aba --- /dev/null +++ b/scripts/make_dev_library.py @@ -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() diff --git a/tasks-done.md b/tasks-done.md index 58515b7..ef6862e 100644 --- a/tasks-done.md +++ b/tasks-done.md @@ -1,5 +1,63 @@ ## 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) The first half of andTunes, a music player for the Rabbit R1 that never scans