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