v0.20.0: the Rabbit's plays come home
andTunes phase 3. The R1 is now one more machine in the Round 38 play-count model, and nothing lands on it that Android can't play. Play counts: andTunes 0.2.0 counts a play on a natural finish (LinTunes' rule) and keeps Music/andTunes/plays/andtunes-<install id>.json in exactly the per-machine-totals journal shape, written beside the old one and renamed over it. Each sync first folds it into <data_dir>/plays/ with a new play_journal.merge_totals, the per-track max: the file now has two homes and both desktops may bring it back, so the max converges and an older copy can never pull a count down. Nothing is written when nothing moved, and PlayJournal.load needed no change. Format gate: the planner reuses export/web_support.conversion_for outright, since Android's MediaPlayer decodes the browser's set. FairPlay is refused and reported; ALAC, AIFF and oddities land as FLAC, converted once into a per-track cache and size-diffed after that. With no ffmpeg, the export's "sync without them?" question. A file ffmpeg can't read costs that song, not the sync. "Sync Playlist to Rabbit (Auxio)" is retired from the menu; device_sync's helpers stay because export and andTunes import them. The dev fixture grew a real ALAC track, a Protected AAC .m4p and an "Odd Formats" playlist. Verified on the R1 with it: AIFF and ALAC arrived as FLAC and played, the FairPlay track was refused, four plays came back on the next sync and each track's effective count rose by exactly one, and a third sync copied nothing. Found along the way: USB re-enumeration can wedge gvfsd-mtp, after which anything touching the mount (find_device, the GUI tests) hangs in an uninterruptible wait. CLAUDE.md now has the recovery. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ZCVBTJRFJ2XfMshu2gtUv
This commit is contained in:
+36
-12
@@ -29,7 +29,10 @@ The edge cases here are not decoration. Each one has bitten something:
|
||||
* 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.
|
||||
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
|
||||
@@ -70,7 +73,14 @@ CODECS = {
|
||||
".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:
|
||||
@@ -78,10 +88,10 @@ def _ffmpeg(args) -> bool:
|
||||
capture_output=True).returncode == 0
|
||||
|
||||
|
||||
def make_audio(path: Path, seconds: float, freq: int) -> bool:
|
||||
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(path.suffix)
|
||||
codec = CODECS.get(codec_key or path.suffix)
|
||||
if codec is None:
|
||||
return False
|
||||
return _ffmpeg(["-f", "lavfi", "-i",
|
||||
@@ -131,6 +141,7 @@ ALBUMS = [
|
||||
# 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"),
|
||||
]
|
||||
|
||||
@@ -144,6 +155,8 @@ EDGE_TRACKS = [
|
||||
("わたしの音楽 🎧", "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"),
|
||||
]
|
||||
|
||||
|
||||
@@ -183,9 +196,9 @@ def build(out: Path, seed: int = 47):
|
||||
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}"
|
||||
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)):
|
||||
if not make_audio(path, seconds, random.randint(180, 900), suffix):
|
||||
failed.append(str(path))
|
||||
continue
|
||||
if cover:
|
||||
@@ -211,11 +224,12 @@ def build(out: Path, seed: int = 47):
|
||||
# ---- 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}"
|
||||
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)}{suffix}")
|
||||
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)):
|
||||
if not make_audio(path, seconds, random.randint(180, 900), suffix):
|
||||
failed.append(str(path))
|
||||
continue
|
||||
made += 1
|
||||
@@ -248,8 +262,8 @@ 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
|
||||
if suffix in (".wav", ".aiff", "alac"):
|
||||
return round(random.uniform(2, 5), 1) # (near-)uncompressed
|
||||
return round(random.uniform(4, 20), 1)
|
||||
|
||||
|
||||
@@ -257,7 +271,13 @@ 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, "")
|
||||
".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:
|
||||
@@ -280,6 +300,10 @@ def _build_playlists(library: Library, ids: 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)
|
||||
@@ -346,7 +370,7 @@ def _write_prefs(data_dir: Path, library: Library):
|
||||
"highlight": "blue",
|
||||
"device_sync": {
|
||||
# Something already ticked, so a sync has work to do out of the box.
|
||||
"playlist_ids": ["A1000000", "A3000000", "S2000000"],
|
||||
"playlist_ids": ["A1000000", "A3000000", "S2000000", "A8000000"],
|
||||
"copy_art": True,
|
||||
},
|
||||
}, indent=2), encoding="utf-8")
|
||||
|
||||
Reference in New Issue
Block a user