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
+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()