The sync was fine. Everything it put on the Rabbit was there and correct -- 2,432 media files, 208 covers, a manifest whose every field type-checks against the parser -- and andTunes still answered "No library / Couldn't read library.json". Fifteen tracks took all 2,439 songs down with them. Library.group() keyed albumsByKey case-folded and artistsByName raw-case, and only ever built the Artist inside `if (album == null)`. So the second track of an album whose artist is spelled differently found the album already there, skipped the block that would have made the artist, and dereferenced the null that came back. Six artists in trav's library are spelled two ways: RJD2/Rjd2, Toro Y Moi/Toro y Moi, FatBoy Slim/Fatboy Slim, LOVING/Loving, Land Of The Loops/Land of the Loops, Salami Rose Joe Louis/salami rose joe louis. It had worked until the 12th because that sync was the first to carry both spellings of one of those albums. Both maps fold case now, and the artist is fetched-or-made before the album block and held, so no lookup left in group() can come back null. Artist gains a key the way Album always had one, and ListActivity navigates by it -- ALBUM_ROW already passed album.key, so artists just stopped being the exception. The device filesystem is case-insensitive, so those two spellings are one folder there. plan_andtunes_sync compared exact strings and saw every such file as stale *and* missing, deleting and re-copying it over MTP on every sync forever; the diff and the collision rule both fold now. plan.stale still carries the device's own spelling, since that is what _delete_stale unlinks by. Verified on the Rabbit: 2,439 songs listed, RJD2 one row of 12 songs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SKXUgsBBwe3qaHEjeV8ubP
412 lines
18 KiB
Python
412 lines
18 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;
|
|
* one artist spelled two ways on one album → the case-folded
|
|
("RJD2" / "Rjd2") grouping in the app and
|
|
the folded device diff;
|
|
* 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;
|
|
* AIFF, ALAC-in-.m4a and a FairPlay .m4p → the andTunes / web-mix
|
|
format gate (FLAC or
|
|
refuse), in "Odd Formats".
|
|
"""
|
|
|
|
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"],
|
|
# Pseudo-suffixes for what a suffix alone can't say: Apple Lossless lives
|
|
# in an ordinary .m4a, and a FairPlay .m4p is (for our purposes) AAC in an
|
|
# MP4 container that iTunes calls "Protected". Neither is really DRM'd
|
|
# here — the *kind* is what every format gate reads.
|
|
"alac": ["-codec:a", "alac"],
|
|
"m4p": ["-codec:a", "aac", "-b:a", "64k", "-f", "mp4"],
|
|
}
|
|
FILE_SUFFIX = {"alac": ".m4a", "m4p": ".m4p"}
|
|
|
|
|
|
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, codec_key: str = "") -> bool:
|
|
"""A short sine wave in whatever container the suffix asks for."""
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
codec = CODECS.get(codec_key or 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"),
|
|
("Reel Three", 1, 3, "alac"),
|
|
], "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"),
|
|
("Iced Lightning", "RJD2", "Since We Last Spoke", ".mp3",
|
|
"one artist spelled two ways, sharing an album"),
|
|
("Making Days Longer", "Rjd2", "Since We Last Spoke", ".mp3",
|
|
"the other spelling — the pair is the point"),
|
|
("わたしの音楽 🎧", "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"),
|
|
("Locked Away", "Fair Play", "Protected", "m4p",
|
|
"FairPlay-protected: refused, never attempted"),
|
|
]
|
|
|
|
|
|
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)}{FILE_SUFFIX.get(suffix, suffix)}"
|
|
seconds = _clip_seconds(suffix)
|
|
if not make_audio(path, seconds, random.randint(180, 900), suffix):
|
|
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")
|
|
ext = FILE_SUFFIX.get(suffix, suffix)
|
|
path = folder / f"{_safe(title)[:120]}{ext}"
|
|
while path.exists(): # the deliberate collision pair
|
|
path = path.with_name(f"{path.stem}_{random.randint(10, 99)}{ext}")
|
|
seconds = _clip_seconds(suffix)
|
|
if not make_audio(path, seconds, random.randint(180, 900), suffix):
|
|
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", "alac"):
|
|
return round(random.uniform(2, 5), 1) # (near-)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",
|
|
"alac": "Apple Lossless audio file",
|
|
"m4p": "Protected AAC audio file"}.get(suffix, "")
|
|
|
|
# What a format gate has to think about; gathered into "Odd Formats".
|
|
ODD_KINDS = {"AIFF audio file", "WAV audio file", "Ogg Vorbis audio file",
|
|
"Apple Lossless audio file", "Protected AAC audio file"}
|
|
|
|
|
|
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", [])
|
|
# Every container a player might refuse: AIFF and ALAC must become FLAC,
|
|
# the FairPlay one must be reported and never attempted.
|
|
pl("Odd Formats", "A8000000",
|
|
[tid for tid in ids if library.tracks[tid].kind in ODD_KINDS])
|
|
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): {"plays": random.randint(1, 12),
|
|
"last_played": _iso(random.randint(0, 200))}
|
|
for tid in random.sample(ids, k=min(8, len(ids)))}
|
|
# The same flat {track id: totals} map PlayJournal.save() writes, with
|
|
# record_play()'s keys. The machine is the filename; there is no wrapper
|
|
# object.
|
|
(plays / "devbox.json").write_text(
|
|
json.dumps(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", "A8000000"],
|
|
"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()
|