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
379 lines
16 KiB
Python
379 lines
16 KiB
Python
#!/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()
|