diff --git a/scripts/slim-embedded-art.py b/scripts/slim-embedded-art.py new file mode 100755 index 0000000..5bec3cf --- /dev/null +++ b/scripts/slim-embedded-art.py @@ -0,0 +1,152 @@ +#!/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 + + +def audio_region_hash(path): + """SHA-256 of everything after the ID3v2 tag - the audio, byte-for-byte.""" + with open(path, "rb") as f: + head = f.read(10) + off = 0 + if head[:3] == b"ID3": + off = 10 + ((head[6] << 21) | (head[7] << 14) | (head[8] << 7) | head[9]) + if head[5] & 0x10: # v2.4 footer + off += 10 + f.seek(off) + h = hashlib.sha256() + while chunk := f.read(1 << 20): + h.update(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) + if total <= PROCESS_OVER: + return None + largest = max(pics, key=lambda p: len(p.data)) + if not apply: + return f"would slim {total // 1024} KB of art" + + before = audio_region_hash(path) + tmp = path + ".slimtmp" + shutil.copy2(path, tmp) + try: + rescued = rescue_cover(os.path.dirname(path), largest.data) + jpeg, note = compress_cover(largest.data) + t = ID3(tmp) + t.delall("APIC") + t.delall("PIC") + t.add(APIC(encoding=0, mime="image/jpeg", type=3, desc="", data=jpeg)) + v2 = 4 if tag.version >= (2, 4, 0) else 3 + t.save(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})" + 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()