eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

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).
41import { readFileSync, writeFileSync } from "node:fs";
42import { append, putBlob, putDerived, fetchPublic } from "./ledgerstream.mjs";
43
44const args = process.argv.slice(2);
45const li = args.indexOf("--listener");
46const LISTENER = li >= 0 ? args[li + 1] : "Paul Campbell";
47const STREAM = args.includes("--stream");
48const tsvPath = args.find((a) => a.endsWith(".tsv"));
49if (!tsvPath) {
50 console.error("usage: node tools/music-ledger.mjs <session.tsv> [--listener \"Full Name\"] [--stream]");
51 process.exit(1);
52}
53const 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.
57async 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}
64function 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
69const MIN_MS = 60_000;
70let 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 }));
75rows = 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.
82const 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};
89const sourceOf = (r) =>
90 r.kind === "file track" ? "local file" : r.kind === "shared track" ? "library" : "Apple Music";
91const artKey = (t) => t.album_id != null ? String(t.album_id) : t.track + " β€” " + t.artist;
92
93let index = (await loadJSON("music.json")) || { updated_at: "", months: [], totals: {} };
94const shards = new Map();
95const 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};
101for (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.
104const known = new Map();
105for (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
112let added = 0;
113for (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.
154const artists = new Set(), albums = new Set(), listeners = new Set();
155let total = 0, newest = "";
156for (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}
165index.months = [...shards.keys()].sort().reverse(); // newest month first
166index.totals = { tracks: total, artists: artists.size, albums: albums.size, listeners: listeners.size };
167index.updated_at = newest || new Date().toISOString();
168saveJSON("music.json", index);
169console.log((STREAM ? "music stream" : "music ledger") + ": +" + added +
170 " (" + total + " total across " + index.months.length + " month(s))");
171