10 Commits
Author SHA1 Message Date
pastilhas d0282fd66d release 2026.09.23.01
Carries the /random endpoint: the server now picks shuffle tracks, so a
phone no longer flattens every album's meta into its heap to choose
twenty songs.
2026-09-23 00:16:54 +01:00
pastilhas 9fb948fe83 music: the server picks the random tracks
The app shuffled by flattening every album's meta.json in scope into one
array and choosing from it. On a hundred-thousand-track library that
meant a six-figure object array living in the phone's JS heap — marked
by every garbage collection — and copied again on each draw. Measured on
device: the JS thread never went idle during playback and the phone
reached 'serious' thermal state.

Picking twenty songs never required any of that. The manifest already
holds per-album track counts, so the choice is arithmetic over numbers
and only the albums actually picked are read.

Uniform over TRACKS, not albums: choosing an album first would make a
two-track EP as likely as a sixty-track box set, so albums are weighted
by count via a running total and a binary search. Same distribution as
shuffling everything and taking the first n, which is what a shuffle
means. Results are re-shuffled before returning, because grouping the
reads by album would otherwise hand back something sorted by album.
2026-09-23 00:14:06 +01:00
pastilhas de8949efa7 slim-embedded-art: rewrite tags that are mostly padding
The final 47 oversized files held no big art and no big lyrics - just
empty reserved space, up to 478 KB of padding against 39 bytes of
frames, all in the DJ Sets tree. The player pages through padding like
anything else. A tag past 200 KB total is rewritten regardless of what
fills it; the save already zeroes padding.
2026-08-29 08:39:14 +01:00
pastilhas f17a88be9a slim-embedded-art: drop junk lyrics frames over 50 KB
The 49 files the art pass left behind were fat with USLT, not APIC -
the worst held the complete text of Angela's Ashes (1.5 MB of Frank
McCourt) in an Eels track, some tagger malfunction. AVPlayer pages
through the whole tag before the first audio frame regardless of what
fills it, so absurd lyrics cost the same silence as absurd art. No
song has 50 KB of lyrics; anything past it is junk and is removed.
2026-08-29 07:48:07 +01:00
pastilhas 76d7ce6ccd drop the temporary stream-range log — it found its bug
28 range requests per song start on iOS turned out to be AVPlayer paging
through ~1 MB of embedded cover art in 32 KB round trips. The library
has been slimmed (23,833 files rewritten, art capped at 80 KB, ID3v1
stripped); the log served its purpose.
2026-08-29 07:36:19 +01:00
pastilhas 8814f90780 slim-embedded-art: strip the ID3v1 appendix on rewrite (v1=0)
Redundant with the v2 frames, 128 bytes of legacy, and the thing whose
sync update tripped the audio guard. Applies to files being rewritten;
files already slimmed before this keep their synced v1.
2026-08-29 02:05:50 +01:00
pastilhas f63e6000f9 slim-embedded-art: the audio hash excludes a trailing ID3v1 tag
mutagen updates an existing v1 tag on save - it normalised the genre
byte 0xFF to 0x0F on Shamrocks and Shenanigans and the guard refused a
whole album of good rewrites over one metadata byte. The v1 block is
metadata, not audio; the guard now hashes strictly the frames between
the v2 tag and any trailing TAG block.
2026-08-29 01:59:22 +01:00
pastilhas e0e96d10d7 slim-embedded-art: hash the audio region from mutagen own tag parse
A tagger that writes a non-standard size field puts the header claim and
the real tag end in different places. mutagen preserves audio from where
it parsed the tag to end; the guard hashed from where the header claimed,
and refused good rewrites across a whole album. Both hashes now use the
same authority. Failed files stay untouched and a re-run picks them up.
2026-08-29 01:58:30 +01:00
pastilhas fe1405f273 slim-embedded-art: recompress oversized embedded covers across the library
iOS AVPlayer reads a streamed file head in ~32 KB range requests and cannot
reach audio until it is past the ID3 tag - a 955 KB embedded JPEG cost ~30
sequential round trips, a two-second silence Android never feels. 28% of the
library (20,487 files, 1,488 albums) carries art that size.

Keeps an embedded front cover (other users of these files see it) but at
600px/JPEG stepped to fit 80 KB, ~3 chunks. Audio region is hashed before
and after; any mismatch keeps the original. Rescues art to cover.jpg first
when the folder has none.
2026-08-29 01:36:21 +01:00
pastilhas 3368f5def9 temporary: log each stream request range, chasing the iOS 28-requests-per-song storm 2026-08-29 01:09:28 +01:00
4 changed files with 366 additions and 1 deletions
+1 -1
View File
@@ -40,7 +40,7 @@ import type { PluginManifest } from '@@/plugins/manifest';
// there was nothing to declare and no reason to build the field — see ./PLUGIN.md.
export const manifest: PluginManifest = {
publisher: 'officerdev',
version: '2026.08.27.01',
version: '2026.09.23.01',
platform: '>=1.0.0',
label: 'Music',
+203
View File
@@ -0,0 +1,203 @@
#!/usr/bin/env python3
"""
Slim the embedded cover art across the music library.
WHY. iOS AVPlayer fetches the head of a streamed file in ~32 KB range requests, one round trip
each, and it cannot reach audio until it has read past the whole ID3 tag. A 955 KB embedded JPEG
(Ben Harper, 2026-08-29) therefore cost ~30 sequential round trips - a two-second silence on the
iPhone that Android (ExoPlayer, one connection) never feels. 28% of the library rips carry art
this size.
THE LIMIT, AND WHY. Every track KEEPS an embedded front cover - the folder cover.jpg is this
plugin's primary art, but embedded art is what any OTHER player/user of the files sees, so it
stays. It is recompressed to fit the transport: 600x600 max (the iTunes standard, plenty for any
lock screen), JPEG, quality stepped down from 85 until it fits in EMBED_TARGET = 80 KB. 80 KB
plus the text frames keeps the whole tag within ~3 range-request chunks - imperceptible - while
staying visually indistinguishable at the sizes any UI renders it.
Only files whose picture frames total more than PROCESS_OVER = 100 KB are touched at all; a file
already carrying reasonable art is left byte-identical.
SAFETY. Work happens on a temp copy; the post-tag byte region (the audio, verbatim) is hashed
before and after and must match or the original is kept. If an album folder has NO cover.jpg or
folder.jpg, the original full-resolution embedded art is rescued to cover.jpg before compression.
Usage:
slim-embedded-art.py dry-run over ~/Music
slim-embedded-art.py "Albums/Ben Harper/[1994] ..." dry-run, one directory
slim-embedded-art.py [subpath] --apply actually rewrite
"""
import hashlib
import io
import os
import shutil
import sys
from mutagen.id3 import ID3, APIC, ID3NoHeaderError
from PIL import Image
ROOT = os.path.expanduser("~/Music")
EMBED_TARGET = 80_000
PROCESS_OVER = 100_000
# No song has 50 KB of lyrics. The frame that set this limit held the complete text of Frank
# McCourt's "Angela's Ashes" - 1.5 MB of memoir in an Eels track's USLT, courtesy of some tagger
# malfunction - and AVPlayer pages through ALL of it before the first audio frame, art or not.
LYRICS_SANE = 50_000
# The last 47 oversized files held no big art and no big lyrics - just PADDING: empty space some
# tagger reserved for future edits, up to 478 KB of it against 39 bytes of actual frames (the DJ
# Sets tree, 2026-08-29). The player pages through padding like anything else, so a tag this far
# past its content gets rewritten (the save already zeroes padding).
TAG_SANE = 200_000
def audio_region_hash(path, offset=None):
"""SHA-256 of the audio: after the ID3v2 tag, before any trailing ID3v1 tag.
The v2 offset comes from mutagen's own parse. The trailing 128-byte ID3v1 block (starts
"TAG") is EXCLUDED: mutagen updates an existing v1 tag on save - measured on Shamrocks and
Shenanigans (2026-08-29), it normalised the v1 genre byte 0xFF to 0x0F (Rap) and the guard
refused a whole album of perfectly good rewrites over that one metadata byte.
"""
if offset is None:
try:
offset = ID3(path).size
except Exception:
offset = 0
if not offset:
with open(path, "rb") as f:
head = f.read(10)
if head[:3] == b"ID3":
offset = 10 + ((head[6] << 21) | (head[7] << 14) | (head[8] << 7) | head[9])
if head[5] & 0x10: # v2.4 footer
offset += 10
size = os.path.getsize(path)
end = size
if size - offset >= 128:
with open(path, "rb") as f:
f.seek(size - 128)
if f.read(3) == b"TAG":
end = size - 128
with open(path, "rb") as f:
f.seek(offset)
h = hashlib.sha256()
remaining = end - offset
while remaining > 0:
chunk = f.read(min(1 << 20, remaining))
if not chunk:
break
h.update(chunk)
remaining -= len(chunk)
return h.hexdigest()
def pictures(tag):
return tag.getall("APIC") + tag.getall("PIC")
def compress_cover(data):
"""(jpeg_bytes, note) at <= EMBED_TARGET, stepping quality then size down."""
img = Image.open(io.BytesIO(data))
if img.mode != "RGB":
img = img.convert("RGB")
for side in (600, 500, 400):
work = img.copy()
work.thumbnail((side, side))
for q in (85, 80, 70, 60, 50):
buf = io.BytesIO()
work.save(buf, "JPEG", quality=q, optimize=True)
if buf.tell() <= EMBED_TARGET:
return buf.getvalue(), f"{side}px q{q}"
return buf.getvalue(), f"{side}px q{q} OVER" # pathological; still vastly smaller
def rescue_cover(dirpath, data):
"""Write the original art to cover.jpg when the folder has no art file at all."""
for name in ("cover.jpg", "folder.jpg"):
if os.path.exists(os.path.join(dirpath, name)):
return False
img = Image.open(io.BytesIO(data))
if img.mode != "RGB":
img = img.convert("RGB")
img.save(os.path.join(dirpath, "cover.jpg"), "JPEG", quality=90)
return True
def process(path, apply):
try:
tag = ID3(path)
except ID3NoHeaderError:
return None
pics = pictures(tag)
total = sum(len(p.data) for p in pics)
junk_lyrics = [f for f in tag.getall("USLT") if len(f.text or "") > LYRICS_SANE]
bloated = tag.size > TAG_SANE
if total <= PROCESS_OVER and not junk_lyrics and not bloated:
return None
largest = max(pics, key=lambda p: len(p.data)) if pics else None
if not apply:
parts = []
if total > PROCESS_OVER: parts.append(f"{total // 1024} KB of art")
if junk_lyrics: parts.append(f"{sum(len(f.text) for f in junk_lyrics) // 1024} KB of junk lyrics")
if not parts and bloated: parts.append(f"{tag.size // 1024} KB tag of mostly padding")
return "would slim " + " + ".join(parts)
before = audio_region_hash(path)
tmp = path + ".slimtmp"
shutil.copy2(path, tmp)
try:
note = "art untouched"
t = ID3(tmp)
if largest is not None and total > PROCESS_OVER:
rescued = rescue_cover(os.path.dirname(path), largest.data)
jpeg, note = compress_cover(largest.data)
t.delall("APIC")
t.delall("PIC")
t.add(APIC(encoding=0, mime="image/jpeg", type=3, desc="", data=jpeg))
else:
rescued = False
jpeg = b""
for f in junk_lyrics:
t.delall(f.HashKey)
v2 = 4 if tag.version >= (2, 4, 0) else 3
# v1=0 REMOVES any trailing ID3v1 tag: it is a 128-byte legacy appendix fully redundant
# with the v2 frames, and keeping it in sync is what tripped the audio guard before the
# guard learned to exclude it. Decided 2026-08-29: strip it wherever we rewrite.
t.save(v1=0, v2_version=v2, padding=lambda info: 0)
if audio_region_hash(tmp) != before:
raise RuntimeError("audio bytes changed - keeping original")
os.replace(tmp, path)
note_out = f"{total // 1024} KB -> {len(jpeg) // 1024} KB ({note})"
if junk_lyrics:
note_out += f" -{sum(len(f.text) for f in junk_lyrics) // 1024} KB junk lyrics"
return note_out + (" +rescued cover.jpg" if rescued else "")
except Exception as e:
if os.path.exists(tmp):
os.remove(tmp)
return f"FAILED: {e}"
def main():
args = [a for a in sys.argv[1:] if a != "--apply"]
apply = "--apply" in sys.argv
base = os.path.join(ROOT, args[0]) if args else ROOT
done = failed = skipped = 0
for dirpath, _, files in os.walk(base):
for name in sorted(files):
if not name.lower().endswith(".mp3"):
continue
result = process(os.path.join(dirpath, name), apply)
rel = os.path.join(dirpath, name)[len(ROOT) + 1 :]
if result is None:
skipped += 1
elif result.startswith("FAILED"):
failed += 1
print(f" !! {rel} {result}", flush=True)
else:
done += 1
if done % 250 == 0 or not apply:
print(f" {rel} {result}", flush=True)
print(f"{'applied' if apply else 'dry-run'}: {done} slimmed, {skipped} already fine, {failed} failed", flush=True)
if __name__ == "__main__":
main()
+20
View File
@@ -3,6 +3,7 @@ import { join, basename } from 'node:path';
import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
import { createSidecarConnector } from '@@/sidecar/connect';
import { streamAudioFile } from './stream-audio';
import { randomSongs } from './random';
import { cliampUpgradeData, musicWebsocket } from '../cliamp/cliamp-ws';
import { ensurePulseAudio } from '../cliamp/pulse-audio';
import { startNightlyReindex, stopNightlyReindex } from './nightly-reindex';
@@ -73,6 +74,8 @@ const asKeys = (v: unknown): string[] | null =>
//
// GET /stream?path=<home-relative> audio with Range→206 (Content-Range/Length/Accept-Ranges)
// + `X-Audio-Duration` (seconds, ffprobe). 400/404/416.
// GET /random?path=<rel>&n=<1..100> n random tracks from that scope, uniform over TRACKS (albums
// weighted by count). Reads only the albums picked — see random.ts.
// GET /manifest pure read of the last completed index (NO build triggered) —
// { version, generatedAt, albums: { "<rel>": { v, cover, tracks, videos?, disco? } } }
// GET /meta?path=<rel> album meta.json (IndexMeta). ETag: <v>; If-None-Match → 304.
@@ -251,6 +254,23 @@ const server = Bun.serve({
}
// ── Sync surface ──
/*
A random handful of tracks, chosen here rather than on the phone.
See random.ts for why: the app used to flatten every album's meta into one array to pick twenty
songs, which on a large library meant a six-figure object array living in the JS heap and being
copied on every draw. The manifest already holds per-album track counts, so the choice costs
arithmetic plus a read of only the albums actually picked.
*/
if (url.pathname === '/random') {
const scope = url.searchParams.get('path') ?? '';
const raw = Number(url.searchParams.get('n') ?? '20');
// Clamped: this reads one meta.json per distinct album picked, so an unbounded n is an
// unbounded amount of disk work from a query string.
const n = Number.isFinite(raw) ? Math.max(1, Math.min(100, Math.floor(raw))) : 20;
return json({ songs: await randomSongs(scope, n) }, { headers: NO_CACHE });
}
if (url.pathname === '/manifest') {
// Pure read — returns the last completed index. It does NOT trigger a build (that could kick off a
// long/full rebuild on a plain app refresh); use POST /reindex explicitly to pick up disk changes.
+142
View File
@@ -0,0 +1,142 @@
import { getManifest, metaFilePath, type IndexMeta, type IndexTrack } from './indexer';
/**
* A random handful of tracks, chosen by the SERVER.
*
* ## Why this is not the app's job
*
* The app used to shuffle by building a song index for the whole scope — every album's meta.json,
* flattened into one array — and then picking from it. For a library of a hundred thousand tracks
* that meant holding a hundred thousand objects in the JS heap permanently, copying the array on
* every draw, and paying for all of it in garbage collection on a phone. Measured on device: the JS
* thread never went idle during playback, and the device reached "serious" thermal state.
*
* Picking twenty songs never needed any of that. The manifest already knows how many tracks each
* album holds, so the choice can be made from COUNTS and only the chosen albums read.
*
* ## Uniform over tracks, not albums
*
* Picking an album at random and then a track from it over-represents short albums: a two-track EP
* would come up as often as a sixty-track box set. So albums are weighted by their track count — a
* running total, then a random offset into it, binary-searched back to the album that contains it.
* That is the same distribution as shuffling every track and taking the first n, which is what a
* shuffle is understood to mean.
*/
export type RandomSong = {
/** Home-relative path, the id the player streams by. */
homePath: string;
albumRel: string;
file: string;
title: string;
artist: string;
album: string;
durationSec?: number;
/** Whether this album has a cover the app can request. Saves it guessing and 404ing. */
cover: boolean;
};
const readMeta = async (rel: string): Promise<IndexMeta | null> => {
const path = metaFilePath(rel);
if (!path) return null;
try {
const f = Bun.file(path);
return (await f.exists()) ? ((await f.json()) as IndexMeta) : null;
} catch {
return null;
}
};
const titleOf = (t: IndexTrack): string => t.title || t.file.replace(/\.[^.]+$/, '');
export async function randomSongs(scopeRel: string, n: number): Promise<RandomSong[]> {
const manifest = await getManifest();
/*
In scope, and actually holding tracks.
An empty scope means the whole library. Otherwise it is the folder itself or anything beneath it —
`startsWith(scope + '/')` rather than `startsWith(scope)`, so "Albums" does not also match a
sibling called "Albums Live".
*/
const rels = Object.keys(manifest.albums).filter((rel) => {
const entry = manifest.albums[rel];
if (!entry || !entry.tracks) return false;
return scopeRel === '' || rel === scopeRel || rel.startsWith(`${scopeRel}/`);
});
if (!rels.length) return [];
// Running total of track counts — the weighting described above. Numbers only; no track is read yet.
const cumulative: number[] = [];
let total = 0;
for (const rel of rels) {
total += manifest.albums[rel]!.tracks;
cumulative.push(total);
}
if (total === 0) return [];
/** Which album owns global track offset `x`. */
const albumFor = (x: number): number => {
let lo = 0;
let hi = cumulative.length - 1;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (cumulative[mid]! <= x) lo = mid + 1;
else hi = mid;
}
return lo;
};
/*
Draw distinct offsets, then group by album so each album's meta.json is read ONCE however many of
its tracks were picked. `want` is capped at the library size, and the attempt limit stops a tiny
library spinning forever trying to find offsets it does not have.
*/
const want = Math.min(n, total);
const offsets = new Set<number>();
for (let attempts = 0; offsets.size < want && attempts < want * 20; attempts++) {
offsets.add(Math.floor(Math.random() * total));
}
const byAlbum = new Map<number, number[]>();
for (const off of offsets) {
const idx = albumFor(off);
const base = idx === 0 ? 0 : cumulative[idx - 1]!;
const within = off - base;
const list = byAlbum.get(idx) ?? [];
list.push(within);
byAlbum.set(idx, list);
}
const songs: RandomSong[] = [];
await Promise.all(
[...byAlbum.entries()].map(async ([idx, withins]) => {
const rel = rels[idx]!;
const meta = await readMeta(rel);
if (!meta?.tracks?.length) return;
for (const within of withins) {
// The manifest's count and the meta file can disagree if the index is mid-rebuild; modulo
// keeps that a slightly different song rather than an empty slot.
const t = meta.tracks[within % meta.tracks.length];
if (!t) continue;
songs.push({
homePath: `Music/${rel}/${t.file}`,
albumRel: rel,
file: t.file,
title: titleOf(t),
artist: t.artist || t.albumArtist || '',
album: t.album || '',
durationSec: t.durationSec,
cover: !!manifest.albums[rel]?.cover,
});
}
}),
);
// Grouping by album clustered them; a shuffle must not come back sorted by album.
for (let i = songs.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[songs[i], songs[j]] = [songs[j]!, songs[i]!];
}
return songs;
}