#!/usr/bin/env node // 🤖 Accrues a contributor's session music log into the public music ledger // (the "what was playing while it was built" companion to the carbon/prompt // ledgers; page: web/status/music.html). // // node tools/music-ledger.mjs [--listener "Full Name"] [--stream] // // TWO destinations, same rules: // - Default (committed): writes web/status/music.json + music-YYYY-MM.json — // the original review-by-PR path; ships with the pages on the release train. // - --stream (issue #1224): appends each track as a hash-chained entry to the // ledgers/music/ stream in the status bucket and rebuilds the derived // views (ledgers/music/music.json + music-YYYY-MM.json) in place — live in // seconds, no compile, no deploy. Album art goes up once per album as a // content-addressed blob; derived views reference it by same-origin path. // Writes use the ambient AWS profile (the scoped status-ledger-writer key); // verify any time with `node tools/ledger-verify.mjs music`. // // The input TSV is written by a session's Apple Music logger, one row per // track change: // ISO time \t track \t artist \t album [\t kind \t context \t shuffle \t stream] // (the four signal columns are optional — legacy rows default to a streamed // catalog track with no queue context). // // The ledger is SHARDED BY MONTH so it never outgrows a fetch: // music.json — the index: { updated_at, months[], totals{} } // music-YYYY-MM.json — one shard: { month, albums{}, tracks[] } // Album art lives once per album in the shard's albums map (keyed by // album_id, else by "track — artist") — a data URI when committed, a // same-origin blob path when streamed; either way the status pages make NO // external requests (same rule as contributor avatars). // // Accrual rules, mirrored by the page and the contract test: // - A track counts only if it held ≥60s before the next change (skips never // publish); the final row of a still-running session is kept. // - Rows merge by (ts, track) — re-runs and multiple sessions never // double-count; each shard stays sorted oldest-first. // - Apple ids for the song.link/album.link links come from the iTunes Search // API, resolved once per song across the whole ledger. // - Listener names use the ssh-operators.json naming (public already). import { readFileSync, writeFileSync } from "node:fs"; import { append, putBlob, putDerived, fetchPublic } from "./ledgerstream.mjs"; const args = process.argv.slice(2); const li = args.indexOf("--listener"); const LISTENER = li >= 0 ? args[li + 1] : "Paul Campbell"; const STREAM = args.includes("--stream"); const tsvPath = args.find((a) => a.endsWith(".tsv")); if (!tsvPath) { console.error("usage: node tools/music-ledger.mjs [--listener \"Full Name\"] [--stream]"); process.exit(1); } const statusDir = new URL("../web/status/", import.meta.url); // One loader/saver pair per destination: committed files or the stream's // derived views — identical shapes, so everything between them is shared. async function loadJSON(name) { if (STREAM) { const b = await fetchPublic("ledgers/music/" + name); return b ? JSON.parse(b.toString("utf8")) : null; } try { return JSON.parse(readFileSync(new URL(name, statusDir), "utf8")); } catch { return null; } } function saveJSON(name, obj) { if (STREAM) putDerived("music", name, obj); else writeFileSync(new URL(name, statusDir), JSON.stringify(obj, null, 1) + "\n"); } const MIN_MS = 60_000; let rows = readFileSync(tsvPath, "utf8").trim().split("\n").filter(Boolean) .map((l) => l.split("\t")) .filter((c) => c.length >= 4) .map(([ts, track, artist, album, kind = "URL track", context = "-", shuffle = "false", stream = "-"]) => ({ ts, track, artist, album, kind, context, shuffle: shuffle === "true", stream })); rows = rows.filter((r, i) => !rows[i + 1] || new Date(rows[i + 1].ts) - new Date(r.ts) >= MIN_MS) .filter((r, i, a) => i === 0 || !(r.track === a[i - 1].track && r.artist === a[i - 1].artist)); // What Music admits about how a track was chosen — just the raw signals: // `queued` (playing from a playlist/library queue), `radio` (a stream), and // `shuffle`. A bare catalog stream carries none: Music can't distinguish a // direct pick from autoplay, so the ledger shows nothing rather than guess. const signalsOf = (r) => { const s = []; if (r.stream !== "-") s.push("radio"); else if (r.context !== "-") s.push("queued"); if (r.shuffle) s.push("shuffle"); return s; }; const sourceOf = (r) => r.kind === "file track" ? "local file" : r.kind === "shared track" ? "library" : "Apple Music"; const artKey = (t) => t.album_id != null ? String(t.album_id) : t.track + " — " + t.artist; let index = (await loadJSON("music.json")) || { updated_at: "", months: [], totals: {} }; const shards = new Map(); const loadShard = async (m) => { if (!shards.has(m)) { shards.set(m, (await loadJSON("music-" + m + ".json")) || { month: m, albums: {}, tracks: [] }); } return shards.get(m); }; for (const m of index.months) await loadShard(m); // ids + totals need the full ledger // One id/art resolution per song across the whole ledger. const known = new Map(); for (const [, s] of shards) { for (const t of s.tracks) { known.set(t.track + " — " + t.artist, { song_id: t.song_id, album_id: t.album_id, art: s.albums[artKey(t)] ?? null }); } } let added = 0; for (const r of rows) { const iso = new Date(r.ts).toISOString(); const month = iso.slice(0, 7); const shard = await loadShard(month); if (shard.tracks.some((t) => t.ts === iso && t.track === r.track)) continue; const key = r.track + " — " + r.artist; let hit = known.get(key); if (!hit) { hit = { song_id: null, album_id: null, art: null }; try { const q = encodeURIComponent(r.track + " " + r.artist); const res = await fetch("https://itunes.apple.com/search?term=" + q + "&entity=song&limit=1"); const s = (await res.json()).results?.[0]; if (s) { hit.song_id = s.trackId ?? null; hit.album_id = s.collectionId ?? null; if (s.artworkUrl100) { const art = await fetch(s.artworkUrl100); const bytes = Buffer.from(await art.arrayBuffer()); hit.art = STREAM ? await putBlob("music", bytes, "jpg", "image/jpeg") : "data:image/jpeg;base64," + bytes.toString("base64"); } } } catch { /* offline accrual: links/art stay null, honest */ } known.set(key, hit); } const t = { ts: iso, track: r.track, artist: r.artist, album: r.album, listener: LISTENER, source: sourceOf(r), signals: signalsOf(r), song_id: hit.song_id, album_id: hit.album_id, }; if (hit.art && !(artKey(t) in shard.albums)) shard.albums[artKey(t)] = hit.art; shard.tracks.push(t); // The stream is the canonical, hash-chained record; the shard/index writes // below are its rebuildable derived views. if (STREAM) await append("music", LISTENER, iso, { ...t, art: hit.art }); added++; } // Write every touched shard sorted, then rebuild the index from all shards. const artists = new Set(), albums = new Set(), listeners = new Set(); let total = 0, newest = ""; for (const [m, s] of shards) { s.tracks.sort((a, b) => a.ts.localeCompare(b.ts)); saveJSON("music-" + m + ".json", s); total += s.tracks.length; for (const t of s.tracks) { artists.add(t.artist); albums.add(t.album); listeners.add(t.listener); if (t.ts > newest) newest = t.ts; } } index.months = [...shards.keys()].sort().reverse(); // newest month first index.totals = { tracks: total, artists: artists.size, albums: albums.size, listeners: listeners.size }; index.updated_at = newest || new Date().toISOString(); saveJSON("music.json", index); console.log((STREAM ? "music stream" : "music ledger") + ": +" + added + " (" + total + " total across " + index.months.length + " month(s))");