| 1 | #!/usr/bin/env node |
| 2 | // π€ Accrues a contributor's session music log into the public music ledger |
| 3 | // (the "what was playing while it was built" companion to the carbon/prompt |
| 4 | // ledgers; page: web/status/music.html). |
| 5 | // |
| 6 | // node tools/music-ledger.mjs <session.tsv> [--listener "Full Name"] [--stream] |
| 7 | // |
| 8 | // TWO destinations, same rules: |
| 9 | // - Default (committed): writes web/status/music.json + music-YYYY-MM.json β |
| 10 | // the original review-by-PR path; ships with the pages on the release train. |
| 11 | // - --stream (issue #1224): appends each track as a hash-chained entry to the |
| 12 | // ledgers/music/ stream in the status bucket and rebuilds the derived |
| 13 | // views (ledgers/music/music.json + music-YYYY-MM.json) in place β live in |
| 14 | // seconds, no compile, no deploy. Album art goes up once per album as a |
| 15 | // content-addressed blob; derived views reference it by same-origin path. |
| 16 | // Writes use the ambient AWS profile (the scoped status-ledger-writer key); |
| 17 | // verify any time with `node tools/ledger-verify.mjs music`. |
| 18 | // |
| 19 | // The input TSV is written by a session's Apple Music logger, one row per |
| 20 | // track change: |
| 21 | // ISO time \t track \t artist \t album [\t kind \t context \t shuffle \t stream] |
| 22 | // (the four signal columns are optional β legacy rows default to a streamed |
| 23 | // catalog track with no queue context). |
| 24 | // |
| 25 | // The ledger is SHARDED BY MONTH so it never outgrows a fetch: |
| 26 | // music.json β the index: { updated_at, months[], totals{} } |
| 27 | // music-YYYY-MM.json β one shard: { month, albums{}, tracks[] } |
| 28 | // Album art lives once per album in the shard's albums map (keyed by |
| 29 | // album_id, else by "track β artist") β a data URI when committed, a |
| 30 | // same-origin blob path when streamed; either way the status pages make NO |
| 31 | // external requests (same rule as contributor avatars). |
| 32 | // |
| 33 | // Accrual rules, mirrored by the page and the contract test: |
| 34 | // - A track counts only if it held β₯60s before the next change (skips never |
| 35 | // publish); the final row of a still-running session is kept. |
| 36 | // - Rows merge by (ts, track) β re-runs and multiple sessions never |
| 37 | // double-count; each shard stays sorted oldest-first. |
| 38 | // - Apple ids for the song.link/album.link links come from the iTunes Search |
| 39 | // API, resolved once per song across the whole ledger. |
| 40 | // - Listener names use the ssh-operators.json naming (public already). |
| 41 | import { readFileSync, writeFileSync } from "node:fs"; |
| 42 | import { append, putBlob, putDerived, fetchPublic } from "./ledgerstream.mjs"; |
| 43 | |
| 44 | const args = process.argv.slice(2); |
| 45 | const li = args.indexOf("--listener"); |
| 46 | const LISTENER = li >= 0 ? args[li + 1] : "Paul Campbell"; |
| 47 | const STREAM = args.includes("--stream"); |
| 48 | const tsvPath = args.find((a) => a.endsWith(".tsv")); |
| 49 | if (!tsvPath) { |
| 50 | console.error("usage: node tools/music-ledger.mjs <session.tsv> [--listener \"Full Name\"] [--stream]"); |
| 51 | process.exit(1); |
| 52 | } |
| 53 | const statusDir = new URL("../web/status/", import.meta.url); |
| 54 | |
| 55 | // One loader/saver pair per destination: committed files or the stream's |
| 56 | // derived views β identical shapes, so everything between them is shared. |
| 57 | async function loadJSON(name) { |
| 58 | if (STREAM) { |
| 59 | const b = await fetchPublic("ledgers/music/" + name); |
| 60 | return b ? JSON.parse(b.toString("utf8")) : null; |
| 61 | } |
| 62 | try { return JSON.parse(readFileSync(new URL(name, statusDir), "utf8")); } catch { return null; } |
| 63 | } |
| 64 | function saveJSON(name, obj) { |
| 65 | if (STREAM) putDerived("music", name, obj); |
| 66 | else writeFileSync(new URL(name, statusDir), JSON.stringify(obj, null, 1) + "\n"); |
| 67 | } |
| 68 | |
| 69 | const MIN_MS = 60_000; |
| 70 | let rows = readFileSync(tsvPath, "utf8").trim().split("\n").filter(Boolean) |
| 71 | .map((l) => l.split("\t")) |
| 72 | .filter((c) => c.length >= 4) |
| 73 | .map(([ts, track, artist, album, kind = "URL track", context = "-", shuffle = "false", stream = "-"]) => |
| 74 | ({ ts, track, artist, album, kind, context, shuffle: shuffle === "true", stream })); |
| 75 | rows = rows.filter((r, i) => !rows[i + 1] || new Date(rows[i + 1].ts) - new Date(r.ts) >= MIN_MS) |
| 76 | .filter((r, i, a) => i === 0 || !(r.track === a[i - 1].track && r.artist === a[i - 1].artist)); |
| 77 | |
| 78 | // What Music admits about how a track was chosen β just the raw signals: |
| 79 | // `queued` (playing from a playlist/library queue), `radio` (a stream), and |
| 80 | // `shuffle`. A bare catalog stream carries none: Music can't distinguish a |
| 81 | // direct pick from autoplay, so the ledger shows nothing rather than guess. |
| 82 | const signalsOf = (r) => { |
| 83 | const s = []; |
| 84 | if (r.stream !== "-") s.push("radio"); |
| 85 | else if (r.context !== "-") s.push("queued"); |
| 86 | if (r.shuffle) s.push("shuffle"); |
| 87 | return s; |
| 88 | }; |
| 89 | const sourceOf = (r) => |
| 90 | r.kind === "file track" ? "local file" : r.kind === "shared track" ? "library" : "Apple Music"; |
| 91 | const artKey = (t) => t.album_id != null ? String(t.album_id) : t.track + " β " + t.artist; |
| 92 | |
| 93 | let index = (await loadJSON("music.json")) || { updated_at: "", months: [], totals: {} }; |
| 94 | const shards = new Map(); |
| 95 | const loadShard = async (m) => { |
| 96 | if (!shards.has(m)) { |
| 97 | shards.set(m, (await loadJSON("music-" + m + ".json")) || { month: m, albums: {}, tracks: [] }); |
| 98 | } |
| 99 | return shards.get(m); |
| 100 | }; |
| 101 | for (const m of index.months) await loadShard(m); // ids + totals need the full ledger |
| 102 | |
| 103 | // One id/art resolution per song across the whole ledger. |
| 104 | const known = new Map(); |
| 105 | for (const [, s] of shards) { |
| 106 | for (const t of s.tracks) { |
| 107 | known.set(t.track + " β " + t.artist, |
| 108 | { song_id: t.song_id, album_id: t.album_id, art: s.albums[artKey(t)] ?? null }); |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | let added = 0; |
| 113 | for (const r of rows) { |
| 114 | const iso = new Date(r.ts).toISOString(); |
| 115 | const month = iso.slice(0, 7); |
| 116 | const shard = await loadShard(month); |
| 117 | if (shard.tracks.some((t) => t.ts === iso && t.track === r.track)) continue; |
| 118 | const key = r.track + " β " + r.artist; |
| 119 | let hit = known.get(key); |
| 120 | if (!hit) { |
| 121 | hit = { song_id: null, album_id: null, art: null }; |
| 122 | try { |
| 123 | const q = encodeURIComponent(r.track + " " + r.artist); |
| 124 | const res = await fetch("https://itunes.apple.com/search?term=" + q + "&entity=song&limit=1"); |
| 125 | const s = (await res.json()).results?.[0]; |
| 126 | if (s) { |
| 127 | hit.song_id = s.trackId ?? null; |
| 128 | hit.album_id = s.collectionId ?? null; |
| 129 | if (s.artworkUrl100) { |
| 130 | const art = await fetch(s.artworkUrl100); |
| 131 | const bytes = Buffer.from(await art.arrayBuffer()); |
| 132 | hit.art = STREAM |
| 133 | ? await putBlob("music", bytes, "jpg", "image/jpeg") |
| 134 | : "data:image/jpeg;base64," + bytes.toString("base64"); |
| 135 | } |
| 136 | } |
| 137 | } catch { /* offline accrual: links/art stay null, honest */ } |
| 138 | known.set(key, hit); |
| 139 | } |
| 140 | const t = { |
| 141 | ts: iso, track: r.track, artist: r.artist, album: r.album, |
| 142 | listener: LISTENER, source: sourceOf(r), signals: signalsOf(r), |
| 143 | song_id: hit.song_id, album_id: hit.album_id, |
| 144 | }; |
| 145 | if (hit.art && !(artKey(t) in shard.albums)) shard.albums[artKey(t)] = hit.art; |
| 146 | shard.tracks.push(t); |
| 147 | // The stream is the canonical, hash-chained record; the shard/index writes |
| 148 | // below are its rebuildable derived views. |
| 149 | if (STREAM) await append("music", LISTENER, iso, { ...t, art: hit.art }); |
| 150 | added++; |
| 151 | } |
| 152 | |
| 153 | // Write every touched shard sorted, then rebuild the index from all shards. |
| 154 | const artists = new Set(), albums = new Set(), listeners = new Set(); |
| 155 | let total = 0, newest = ""; |
| 156 | for (const [m, s] of shards) { |
| 157 | s.tracks.sort((a, b) => a.ts.localeCompare(b.ts)); |
| 158 | saveJSON("music-" + m + ".json", s); |
| 159 | total += s.tracks.length; |
| 160 | for (const t of s.tracks) { |
| 161 | artists.add(t.artist); albums.add(t.album); listeners.add(t.listener); |
| 162 | if (t.ts > newest) newest = t.ts; |
| 163 | } |
| 164 | } |
| 165 | index.months = [...shards.keys()].sort().reverse(); // newest month first |
| 166 | index.totals = { tracks: total, artists: artists.size, albums: albums.size, listeners: listeners.size }; |
| 167 | index.updated_at = newest || new Date().toISOString(); |
| 168 | saveJSON("music.json", index); |
| 169 | console.log((STREAM ? "music stream" : "music ledger") + ": +" + added + |
| 170 | " (" + total + " total across " + index.months.length + " month(s))"); |
| 171 | |