Render podcast episodes as 1080p art-slideshow MP4s and publish them to a YouTube Podcasts playlist: covers/thumbnails generated from your art, resumable bat...
---
name: "Podcast Video Publisher"
description: "Render podcast episodes as 1080p art-slideshow MP4s and publish them to a YouTube Podcasts playlist: covers/thumbnails generated from your art, resumable batch upload, playlist ordering, retiring old versions. Use when releasing podcast episodes to YouTube, batch-upgrading existing episode videos, rebuilding a podcast playlist, or debugging YouTube API upload/thumbnail/playlist failures."
---
# Podcast Video Publisher
Turn a folder of podcast audio + a folder of artwork into a complete,
correctly-ordered YouTube Podcasts section. Battle-tested by shipping a
12-episode season in one day (Ghost Signals with Kannaka — canonical
home: https://github.com/NickFlach/kannaka-radio).
## What This Skill Does
1. **Render** — `scripts/podcast-slideshow.py` builds a 1920×1080 MP4 per
episode: art slides (~45 s each, blur-filled background, sharp center
panel, dip-to-black fades) plus a generated cover card (hero art +
episode title) that doubles as the thumbnail. Video is padded then
muxed `-shortest`, so A/V drift is 0.00 s by construction.
2. **Publish** — `scripts/podcast-upload-batch.js` runs resumable phases:
`upload → thumbs → playlist → order → retire → verify`, with all
progress in a state file so any failure (quota, rate limit, network)
resumes exactly where it stopped.
3. **Self-heal** — `scripts/podcast-finisher.js` is a long-running watcher
that retries rate-limited thumbnails (2 h cadence) and applies playlist
positions the moment position updates become possible.
All six scripts in `scripts/` are self-contained (Node stdlib + ffmpeg +
Pillow only — no npm install).
## Setup
1. Drop the `scripts/` folder into your project root (`<project>/scripts/`).
The scripts treat the parent of `scripts/` as the project root.
2. Install prerequisites: ffmpeg + ffprobe on PATH, Python 3 with Pillow,
Node 18+.
3. Create YouTube OAuth credentials: `node scripts/youtube-grant.js`
(prompts for an OAuth client id/secret from Google Cloud Console with
the full `youtube` scope). Writes `<project>/.youtube.json` —
**never commit this file**.
4. Set `PODCAST_PLAYLIST` in `scripts/podcast-upload-batch.js` and
`scripts/podcast-finisher.js` to your podcast playlist ID.
5. Fonts: cover cards default to Segoe UI (`C:/Windows/Fonts/`) — edit
`FONT_BOLD`/`FONT_REG` in `podcast-slideshow.py` on Linux/macOS.
## Project Layout
```
<project>/
├── .youtube.json # OAuth creds (gitignore!)
├── scripts/ # this skill's scripts
└── workspace/podcasts/
├── episodes.json # [{num, title, audio}, ...]
├── metadata.json # [{num, title, description, tags,
│ # video, cover, oldVideoId?}, ...]
├── art/ # square images ≥1024px
│ └── manifest.json # [{file, title}, ...]
├── renders/ # output MP4s + cover PNGs
└── upload-state.json # created by the pipeline (gitignore)
```
## Quick Start
```bash
python scripts/podcast-slideshow.py --all # render (idempotent)
node scripts/podcast-upload-batch.js # publish, re-run to resume
node scripts/podcast-finisher.js # only if thumbs/order pending
```
## Phase Reference (`podcast-upload-batch.js`)
| Phase | What it does | Quota |
|-------|--------------|-------|
| `upload` | `videos.insert` per episode, missing-episodes first | 1600/video |
| `thumbs` | `thumbnails.set` with the rendered cover | ~50 |
| `playlist` | remove stale items, insert episodes into the podcast playlist | 50/item |
| `order` | `playlistItems.update` explicit positions 1→N | 50/item |
| `retire` | unlist each replaced video + prepend an "upgraded edition" pointer to its description (old links keep working) | 50/video |
| `verify` | read back playlist order + processing status of every upload | ~1 |
Run one phase with `--phase <name>`. Delete an entry from
`upload-state.json` to force a redo of just that item.
## YouTube Gotchas (all hit in production — trust these)
1. **Uploads can silently vanish.** `videos.insert` may return 200 + a
video ID and the video is simply gone minutes later — no error, no
`rejectionReason`. Always re-verify IDs with `videos.list` before
playlist adds (which otherwise fail "Video not found"), and keep
uploads resumable per-item. Re-uploading the identical file works.
2. **Thumbnails: 2 MB hard limit + long rate-limit.** 1080p PNGs blow the
2,097,152-byte cap (`413`) — save covers as JPEG quality ~88. After
~10 rapid sets you get `429 "too many thumbnails recently"` and the
cooldown outlasts 40+ minutes — hence the finisher's 2 h retry cadence.
3. **Podcast playlists force publish-date ordering.** Display order is
exactly `publishedAt DESC`; insert order is irrelevant, delete +
re-insert changes nothing, and `playlistItems.update` with a position
fails with *"Playlist sort type need to be MANUAL to support
position"*. `publishedAt` is immutable (a private→public round-trip
does NOT reset it) and the sort setting is not in the Data API. Fix:
flip the playlist to **Manual** ordering in YouTube Studio (one click,
owner only), then run `--phase order`. Or upload in episode order in
the first place.
4. **Daily quota is not always 10k.** A 13-upload day (~21k nominal
units) cleared fine. Don't pre-abort on quota math, but assume any
phase can die mid-run — that's what the state file is for.
## Design Notes
- Cover cards are drawn with Pillow (hero art right, blurred/darkened
art as backdrop, kicker + wrapped title + footer left) — consistent
branding and readable at thumbnail size.
- Slides render as independent per-slide MP4 segments (parallel,
resumable) joined with the concat demuxer; audio is muxed once at the
end, so chapter timestamps in descriptions stay valid.
- Descriptions: keep every factual claim traceable to the episode audio
(transcribe with faster-whisper if needed), and scrub anything private
before publishing — no server names, IPs, file paths, or credentials.
don't have the plugin yet? install it then click "run inline in claude" again.
added explicit inputs section with oauth setup, project structure, and external service details; expanded procedure to step-level inputs/outputs with phase-by-phase breakdown; added decision points for all production gotchas (missing credentials, quota hits, rate limits, playlist sort type, silent video vanish), resumability, and race conditions; documented output contract with upload-state.json schema and youtube side effects; added outcome signal with verification steps after each major step.
Turn a folder of podcast audio plus a folder of artwork into a complete, correctly-ordered YouTube Podcasts section. Battle-tested by shipping a 12-episode season in one day (Ghost Signals with Kannaka, canonical home: https://github.com/NickFlach/kannaka-radio).
This skill renders podcast episodes into 1920×1080 MP4 videos (art slides with audio, generated cover cards as thumbnails) and publishes them to YouTube in a properly-ordered Podcasts playlist. Use it when releasing new podcast episodes to YouTube, batch-upgrading existing episode videos, rebuilding a podcast playlist from scratch, or debugging YouTube API failures (upload rejections, thumbnail quota hits, playlist ordering). The skill is resumable at every phase, so network failures, rate limits, or quota exhaustion never require a full restart.
Prerequisites (system)
YouTube OAuth credentials
youtube scopenode scripts/youtube-grant.js (one-time setup) to generate .youtube.json in project root.youtube.json contains access token + refresh token; never commit it; add to .gitignoreProject folder structure
scripts/ folder (all six scripts from this skill) in project rootworkspace/podcasts/episodes.json: array of objects with keys num (episode number), title, audio (file path to MP3/WAV)workspace/podcasts/metadata.json: array of objects with keys num, title, description, tags (array), video (output filename), cover (art file reference), oldVideoId (optional, for retiring old versions)workspace/podcasts/art/: folder of square images ≥1024pxworkspace/podcasts/art/manifest.json: array of objects with keys file (filename in art folder), title (display name)Configuration (edit in scripts)
scripts/podcast-slideshow.py: set FONT_BOLD and FONT_REG to correct paths for your OS (Windows defaults to Segoe UI in C:/Windows/Fonts/; Linux/macOS users edit these paths)scripts/podcast-upload-batch.js: set PODCAST_PLAYLIST to your YouTube Podcasts playlist ID (found in YouTube Studio > Playlists > select your podcast)scripts/podcast-finisher.js: set PODCAST_PLAYLIST to same ID as aboveExternal service: YouTube Data API
youtube OAuth scope (full read/write access to videos, thumbnails, playlists)step 1: render slideshow videos
input: workspace/podcasts/episodes.json, workspace/podcasts/metadata.json, audio files referenced in episodes.json, art images in workspace/podcasts/art/
run: python scripts/podcast-slideshow.py --all
process: script iterates each episode in episodes.json, loads referenced audio file, loads cover art by filename from metadata.json, generates a 1920×1080 MP4 where:
-shortest, guaranteeing 0.00 s A/V driftoutput: workspace/podcasts/renders/ folder populated with one MP4 per episode and one PNG cover per episode (cover PNGs are JPEG quality ~88 to stay under 2 MB limit)
idempotent: yes; re-running skips episodes with existing renders unless you delete the render file first
step 2: upload videos and thumbnails to YouTube
input: workspace/podcasts/renders/ (MP4 files), workspace/podcasts/metadata.json (episode metadata), .youtube.json (OAuth token), workspace/podcasts/upload-state.json (created on first run; tracks progress across phases)
run: node scripts/podcast-upload-batch.js (optionally add --phase <phase_name> to run a single phase; see phase reference below)
process: script runs six phases in order (or single phase if specified). each phase reads/writes upload-state.json so any failure (network, quota, rate limit) can be resumed by re-running the command.
phases (in order):
videos.insert per episode, uploads missing episodes first (those without a videoId in upload-state.json), quota ~1600 units per videothumbnails.set with rendered cover PNG per episode, quota ~50 units per thumbnailplaylistItems.update to set explicit positions 1→N, enforcing episode order, quota ~50 units per itemoldVideoId in metadata.json, prepends "upgraded edition" pointer to its description (preserving old links), quota ~50 units per videovideos.list and playlistItems.list, quota ~1 unitoutput: workspace/podcasts/upload-state.json updated after each phase with videoIds, thumbnailStatus, playlistItemIds, and phase completion markers; videos, thumbnails, and playlist items created/updated on YouTube
resumable: yes; if any phase fails, re-run podcast-upload-batch.js to resume exactly where it stopped. to force a redo of a single episode, delete its entry from upload-state.json and re-run.
step 3: retry rate-limited operations and verify final state
input: workspace/podcasts/upload-state.json, .youtube.json, PODCAST_PLAYLIST id
run: node scripts/podcast-finisher.js (runs as a long-lived watcher, do not run if batch upload is in-flight)
process: script runs indefinitely on a 2-hour cadence. each loop:
thumbnails.set for episodes marked rate-limited (429 status from phase 2)playlistItems.update position calls for episodes with pending positionsuse when: thumbnails or playlist ordering is pending due to rate limits, and you want them finished without manual retry loops. safe to run alongside batch upload if you're targeting different episodes (but safer to let batch upload finish first).
output: .youtube.json token refreshed as needed; upload-state.json updated with successful retries; YouTube playlist and video records updated
if oauth token missing or expired (.youtube.json not found)
node scripts/youtube-grant.js once to generate itif rendered MP4s missing from workspace/podcasts/renders/
python scripts/podcast-slideshow.py --all)if an episode upload succeeds (videoId returned, 200 OK) but video is missing minutes later
videos.list before playlist adds (batch upload does this in verify phase)if thumbnail upload hits 413 (2 MB limit exceeded)
if playlist insert/order phase fails with "Playlist sort type need to be MANUAL to support position"
publishedAt is immutable; private-to-public round-trip does NOT reset itif batch upload hits daily quota mid-phase
if you want to run a single phase only (not all six)
node scripts/podcast-upload-batch.js --phase <phase_name>node scripts/podcast-upload-batch.js --phase thumbs (only upload/retry thumbnails)if finisher is retrying rate-limited items but batch upload is also running
upload-state.json (created in workspace/podcasts/)
episodes (array of episode objects), phases (object tracking which phases completed){num, videoId?, thumbnailStatus?, playlistItemId?, retireVideoId?, retired?, error?}{upload: boolean, thumbs: boolean, playlist: boolean, order: boolean, retire: boolean, verify: boolean} tracking completionYouTube (side effects)
videos.insert: one unlisted video per episode, title and description from metadata.json, tags appliedthumbnails.set: cover PNG (JPEG quality ~88) set as thumbnail for each videoplaylistItems.insert: each video added to podcast playlist in episode orderplaylistItems.update: explicit position set 1→N (only if playlist is Manual sort)renders/ folder (side effects)
<metadata.video> (e.g., "ep001.mp4")<metadata.video>.png (cover card, saved as JPEG quality ~88 to stay under 2 MB)after step 1 (slideshow render)
python scripts/podcast-slideshow.py --all completes with exit code 0workspace/podcasts/renders/ folder contains one MP4 + one PNG per episode in metadata.jsonafter step 2 (batch upload)
node scripts/podcast-upload-batch.js completes with exit code 0 and prints "all phases complete"workspace/podcasts/upload-state.json exists, all episodes have a videoId field, all phases are marked trueafter step 3 (finisher cleanup, if needed)
node scripts/podcast-finisher.js runs for 2 hours, then exits or reports all retries completeworkspace/podcasts/upload-state.json thumbnailStatus and positions are all "ok" (no pending)user-facing verification