From 26626254f36aded46b48ff5199c97b40c40ade13 Mon Sep 17 00:00:00 2001 From: prompt.ac/@jeffrey Date: Thu, 02 Jul 2026 23:39:30 +0000 Subject: [PATCH] marketing/podcast: RSS feed + index.json + publish (subscribable AC Readings) - feed.mjs: aggregates episode sidecars into out/index.json (catalog) + out/feed.xml (RSS 2.0 + iTunes namespace), renders series cover.png - publish.mjs: stages the public set into publish/ and syncs to s3://assets-aesthetic-computer/podcast (assets.aesthetic.computer/podcast); DRY by default, --push to actually upload - produce.mjs: writes out/.json metadata sidecar (stable pubDate) - package.json: podcast:feed / podcast:publish / podcast:publish:push Feed lives at https://assets.aesthetic.computer/podcast/feed.xml once pushed. Publishing is always a deliberate per-run choice, never automatic. --- marketing/.gitignore | 1 + marketing/podcast/SCORE.md | 31 +++++++++++++++++++++++++------ marketing/podcast/bin/feed.mjs | 152 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ marketing/podcast/bin/produce.mjs | 18 ++++++++++++++++++ marketing/podcast/bin/publish.mjs | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 3 +++ 6 file(s) changed, 279 insertion(s)(+), 6 deletion(s)(-) diff --git a/marketing/.gitignore b/marketing/.gitignore --- a/marketing/.gitignore +++ b/marketing/.gitignore @@ -16,3 +16,4 @@ # podcast — keep the pipeline (bin/, SCORE.md), skip rendered audio + say cache. # jingles regenerate from jingle.mjs; episodes + say cache are large/regen-able. podcast/out/ podcast/assets/ +podcast/publish/ diff --git a/marketing/podcast/SCORE.md b/marketing/podcast/SCORE.md --- a/marketing/podcast/SCORE.md +++ b/marketing/podcast/SCORE.md @@ -31,21 +31,40 @@ Drops footnotes, section headings, colophon, URLs; keeps the argument. Emits `{ title, author, date, paragraphs[], wordCount }`. 2. **`jingle.mjs`** — synthesizes `intro.wav` / `outro.wav`: a short pentatonic bell motif (ascending in, resolving out). Deterministic, $0, no samples. -3. **`produce.mjs`** — the orchestrator. Narrates intro + each paragraph + outro +3. **`cover.mjs`** — square cover art per episode via xelatex (same fonts as the + essays): the pink drop-shadow YWFT title + AC color bar. Embedded into the mp3 + as ID3 album art and kept as `out/-cover.png`. +4. **`produce.mjs`** — the orchestrator. Narrates intro + each paragraph + outro via `/api/say`, measures the real body duration with ffprobe to fill in the - announced length, then assembles jingle + VO + paragraph breaths with ffmpeg - (loudnorm → mp3, ID3 tagged). Output: `out/.mp3`. + announced length, assembles jingle + VO + paragraph breaths with ffmpeg + (loudnorm → mp3), embeds the cover, and writes a metadata sidecar + `out/.json`. Output: `out/.mp3`. +5. **`feed.mjs`** — aggregates the sidecars into `out/index.json` (catalog) + + `out/feed.xml` (RSS 2.0 + iTunes), and renders the series cover `out/cover.png`. +6. **`publish.mjs`** — stages the public set into `publish/` and syncs it to the + CDN. **Dry by default** (prints the command); `--push` actually uploads. + +## Feed / hosting + +Episodes + feed live on DO Spaces (`assets-aesthetic-computer`), served at +**`https://assets.aesthetic.computer/podcast/`** — same bucket as `/pop`. The +subscribable feed is `https://assets.aesthetic.computer/podcast/feed.xml`. +Publishing is a deliberate per-run choice (`publish.mjs --push`), never automatic — +a reading only goes public when you say so. ## Usage ```bash cd marketing/podcast -node bin/produce.mjs ../../papers/essay-named-markets/named-markets.tex -node bin/produce.mjs ../../opinion/lotus-notes.md --open +node bin/produce.mjs ../../papers/essay-named-markets/named-markets.tex --open +node bin/feed.mjs # build index.json + feed.xml + series cover +node bin/publish.mjs # dry run: stage publish/ + print the sync cmd +node bin/publish.mjs --push # actually upload → feed goes live ``` Flags: `--open` (slab-afplay the result), `--force` (bypass say cache), -`--stability 0.55 --similarity 0.8 --speed 0.98` (voice tuning). +`--stability 0.55 --similarity 0.8 --speed 0.98` (voice tuning), +`--base ` on `feed.mjs` (override the asset host). ## Reused from /pop diff --git a/marketing/podcast/bin/feed.mjs b/marketing/podcast/bin/feed.mjs new file mode 100644 --- /dev/null +++ b/marketing/podcast/bin/feed.mjs @@ -0,0 +1,152 @@ +#!/usr/bin/env node +// feed.mjs — build the "Aesthetic Computer — Readings" podcast feed. +// +// Reads every episode metadata sidecar (out/.json written by produce.mjs) +// and emits: +// out/index.json — catalog (newest first), for a web player +// out/feed.xml — RSS 2.0 + iTunes namespace, for podcast apps +// out/cover.png — the series/channel cover (generated once) +// +// Enclosure + image URLs point at the public asset host. Nothing is uploaded +// here; publish.mjs does that. +// +// Usage: node bin/feed.mjs [--base https://assets.aesthetic.computer/podcast] + +import { readFileSync, writeFileSync, readdirSync, existsSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { renderCover } from "./cover.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(HERE, ".."); +const OUT = resolve(ROOT, "out"); + +const argv = process.argv.slice(2); +const baseFlag = argv.indexOf("--base"); +const BASE = (baseFlag >= 0 ? argv[baseFlag + 1] : null) + || process.env.PODCAST_BASE + || "https://assets.aesthetic.computer/podcast"; + +const CHANNEL = { + title: "Aesthetic Computer — Readings", + link: "https://aesthetic.computer", + language: "en-us", + author: "@jeffrey", + ownerName: "Jeffrey Scudder", + ownerEmail: "mail@aesthetic.computer", + description: + "Readings of essays from Aesthetic Computer, in @jeffrey's voice. Each " + + "episode is a single essay, read start to finish like a lesson — a little " + + "bell to open, the length announced, and a fixed closing.", +}; + +const xml = (s) => String(s) + .replace(/&/g, "&").replace(//g, ">") + .replace(/"/g, """).replace(/'/g, "'"); + +function hms(sec) { + const h = Math.floor(sec / 3600); + const m = Math.floor((sec % 3600) / 60); + const s = Math.round(sec % 60); + const p = (n) => String(n).padStart(2, "0"); + return h > 0 ? `${h}:${p(m)}:${p(s)}` : `${m}:${p(s)}`; +} + +// ── gather episodes ──────────────────────────────────────────────────── +const sidecars = readdirSync(OUT) + .filter((f) => f.endsWith(".json") && f !== "index.json") + .map((f) => { try { return JSON.parse(readFileSync(resolve(OUT, f), "utf8")); } catch { return null; } }) + .filter((e) => e && e.slug && e.audio); + +sidecars.sort((a, b) => new Date(b.pubDate) - new Date(a.pubDate)); // newest first + +if (!sidecars.length) { + console.error("✗ no episodes found in out/ — run produce.mjs first."); + process.exit(1); +} + +// ── series cover (generated once) ────────────────────────────────────── +const seriesCover = resolve(OUT, "cover.png"); +if (!existsSync(seriesCover)) { + const { full } = renderCover( + { slug: "series", title: "Readings", author: "@jeffrey", date: "" }, OUT, + ); + // renderCover writes -cover.png; adopt it as the channel cover. + writeFileSync(seriesCover, readFileSync(full)); +} + +// ── index.json ───────────────────────────────────────────────────────── +const index = { + ...CHANNEL, + base: BASE, + cover: `${BASE}/cover.png`, + feed: `${BASE}/feed.xml`, + updated: sidecars[0].pubDate, + episodes: sidecars.map((e) => ({ + slug: e.slug, + title: e.title, + date: e.date, + length: e.lengthText, + durationSec: e.durationSec, + bytes: e.bytes, + words: e.wordCount, + audio: `${BASE}/${e.audio}`, + cover: `${BASE}/${e.cover}`, + description: e.description, + pubDate: e.pubDate, + })), +}; +writeFileSync(resolve(OUT, "index.json"), JSON.stringify(index, null, 2) + "\n"); + +// ── feed.xml (RSS 2.0 + iTunes) ──────────────────────────────────────── +const items = sidecars.map((e) => ` + ${xml(e.title)} + ${xml(CHANNEL.link)}/readings/${xml(e.slug)} + ac-reading-${xml(e.slug)} + ${xml(e.pubDate)} + ${xml(e.description)} + ${xml(e.description)} + ${xml(CHANNEL.author)} + ${hms(e.durationSec)} + + false + + `).join("\n"); + +const feed = ` + + + ${xml(CHANNEL.title)} + ${xml(CHANNEL.link)} + ${xml(CHANNEL.language)} + ${xml(CHANNEL.description)} + ${xml(sidecars[0].pubDate)} + ${xml(CHANNEL.author)} + ${xml(CHANNEL.description)} + episodic + false + + ${xml(CHANNEL.ownerName)} + ${xml(CHANNEL.ownerEmail)} + + + + + + + + ${xml(`${BASE}/cover.png`)} + ${xml(CHANNEL.title)} + ${xml(CHANNEL.link)} + +${items} + + +`; +writeFileSync(resolve(OUT, "feed.xml"), feed); + +console.log(`✓ ${sidecars.length} episode${sidecars.length === 1 ? "" : "s"} · base ${BASE}`); +console.log(` out/index.json`); +console.log(` out/feed.xml`); +console.log(` out/cover.png`); +for (const e of sidecars) console.log(` · ${e.slug} — ${e.title} (${e.lengthText})`); diff --git a/marketing/podcast/bin/produce.mjs b/marketing/podcast/bin/produce.mjs --- a/marketing/podcast/bin/produce.mjs +++ b/marketing/podcast/bin/produce.mjs @@ -204,6 +204,24 @@ rmSync(build, { recursive: true, force: true }); console.log(` cover: ${coverFull}`); const total = dur(outMp3); + +// Episode metadata sidecar → drives the RSS feed / index.json. Preserve the +// original pubDate across re-runs so the feed order stays stable. +const sidecarPath = resolve(ROOT, "out", `${script.slug}.json`); +let pubDate = new Date().toUTCString(); +if (existsSync(sidecarPath)) { + try { const prev = JSON.parse(readFileSync(sidecarPath, "utf8")); if (prev.pubDate) pubDate = prev.pubDate; } catch { /* ignore */ } +} +writeFileSync(sidecarPath, JSON.stringify({ + slug: script.slug, title: script.title, author: speaker, date: script.date, + description: script.paragraphs[0], lengthText, + durationSec: Math.round(total), bytes: readFileSync(outMp3).length, + wordCount: script.wordCount, + audio: `${script.slug}.mp3`, cover: `${script.slug}-cover.png`, + source: positional[0], pubDate, +}, null, 2) + "\n"); +console.log(` meta: ${sidecarPath}`); + console.log(`\n✓ ${outMp3}`); console.log(` ${Math.floor(total / 60)}m ${String(Math.round(total % 60)).padStart(2, "0")}s · ${(readFileSync(outMp3).length / 1024 / 1024).toFixed(1)} MB\n`); diff --git a/marketing/podcast/bin/publish.mjs b/marketing/podcast/bin/publish.mjs new file mode 100644 --- /dev/null +++ b/marketing/podcast/bin/publish.mjs @@ -0,0 +1,80 @@ +#!/usr/bin/env node +// publish.mjs — stage the public feed and (optionally) sync it to the CDN. +// +// Copies only the public artifacts — episode mp3s, per-episode covers, the +// series cover, feed.xml, index.json — into publish/, leaving the say cache +// (out/cache) and intermediate build files behind. Then syncs publish/ to +// s3://assets-aesthetic-computer/podcast (served at +// https://assets.aesthetic.computer/podcast). +// +// DRY BY DEFAULT: prints the sync command. Pass --push to actually upload +// (publishing makes the readings public — a deliberate, per-run choice). +// +// Usage: +// node bin/feed.mjs && node bin/publish.mjs # stage + show command +// node bin/publish.mjs --push # actually upload + +import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(HERE, ".."); +const OUT = resolve(ROOT, "out"); +const PUB = resolve(ROOT, "publish"); +const PUSH = process.argv.includes("--push"); + +const BUCKET = "s3://assets-aesthetic-computer/podcast"; +const ENDPOINT = "https://sfo3.digitaloceanspaces.com"; + +if (!existsSync(resolve(OUT, "feed.xml")) || !existsSync(resolve(OUT, "index.json"))) { + console.error("✗ out/feed.xml or out/index.json missing — run `node bin/feed.mjs` first."); + process.exit(1); +} + +const index = JSON.parse(readFileSync(resolve(OUT, "index.json"), "utf8")); + +// Assemble the publish set. +rmSync(PUB, { recursive: true, force: true }); +mkdirSync(PUB, { recursive: true }); + +const want = new Set(["feed.xml", "index.json", "cover.png"]); +for (const ep of index.episodes) { + want.add(`${ep.slug}.mp3`); + want.add(`${ep.slug}-cover.png`); +} +let staged = 0, missing = []; +for (const name of want) { + const src = resolve(OUT, name); + if (existsSync(src)) { writeFileSync(resolve(PUB, name), readFileSync(src)); staged++; } + else missing.push(name); +} + +console.log(`✓ staged ${staged} file${staged === 1 ? "" : "s"} → publish/`); +for (const ep of index.episodes) console.log(` · ${ep.slug}.mp3 + cover`); +if (missing.length) console.log(` ⚠ missing (skipped): ${missing.join(", ")}`); + +const syncArgs = [ + "s3", "sync", PUB, BUCKET, + "--endpoint-url", ENDPOINT, + "--exclude", "*.DS_Store", + "--acl", "public-read", + "--content-type", "", // let aws guess per-extension; placeholder removed below +]; + +// aws guesses content-types by extension; drop the placeholder pair. +syncArgs.splice(syncArgs.indexOf("--content-type"), 2); + +if (!PUSH) { + console.log(`\nDRY RUN — nothing uploaded. To publish (makes the readings public):`); + console.log(` aws ${syncArgs.join(" ")}`); + console.log(`\nAfter upload the feed is subscribable at:`); + console.log(` ${index.feed}`); + process.exit(0); +} + +console.log(`\n→ uploading publish/ → ${BUCKET} …`); +const r = spawnSync("aws", syncArgs, { stdio: "inherit" }); +if (r.status !== 0) { console.error(`✗ aws s3 sync failed (${r.status})`); process.exit(1); } +console.log(`\n✓ published. Subscribe at: ${index.feed}`); diff --git a/package.json b/package.json --- a/package.json +++ b/package.json @@ -94,6 +94,9 @@ "assets:sync:down": "aws s3 sync s3://assets-aesthetic-computer system/public/assets --endpoint-url https://sfo3.digitaloceanspaces.com --exclude 'false.work/spiderlily-*.zip*' || echo 'Sync completed with some directory conflicts (safe to ignore)'", "assets:sync:up": "aws s3 sync system/public/assets s3://assets-aesthetic-computer --endpoint-url https://sfo3.digitaloceanspaces.com --exclude '*.DS_Store' --exclude 'false.work/spiderlily-*.zip*' --acl public-read", "pop:assets:down": "aws s3 sync s3://assets-aesthetic-computer/pop system/public/assets/pop --endpoint-url https://sfo3.digitaloceanspaces.com", "pop:assets:up": "aws s3 sync system/public/assets/pop s3://assets-aesthetic-computer/pop --endpoint-url https://sfo3.digitaloceanspaces.com --exclude '*.DS_Store' --acl public-read", + "podcast:feed": "node marketing/podcast/bin/feed.mjs", + "podcast:publish": "node marketing/podcast/bin/publish.mjs", + "podcast:publish:push": "node marketing/podcast/bin/publish.mjs --push", "publish:m4l": "node ac-m4l/publish.mjs", "session:reset": "f() { cd session-server; npx jamsocket backend terminate $1 };f", "session:alive": "cd session-server; npx jamsocket backend list", -- tangled.sh