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:
@@ -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/
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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,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"
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user