https://github.com/schmug/clodcast
Daily-digest podcast skill — turns a list of saved articles into a fully-produced Spotify episode using Qwen3-TTS, on top of the save-to-spotify CLI.
https://github.com/schmug/clodcast
claude-code claude-code-plugin claude-skill podcast qwen3-tts spotify tts
Last synced: 14 days ago
JSON representation
Daily-digest podcast skill — turns a list of saved articles into a fully-produced Spotify episode using Qwen3-TTS, on top of the save-to-spotify CLI.
- Host: GitHub
- URL: https://github.com/schmug/clodcast
- Owner: schmug
- License: mit
- Created: 2026-05-23T13:06:28.000Z (about 1 month ago)
- Default Branch: main
- Last Pushed: 2026-06-03T13:12:56.000Z (21 days ago)
- Last Synced: 2026-06-03T14:12:27.268Z (21 days ago)
- Topics: claude-code, claude-code-plugin, claude-skill, podcast, qwen3-tts, spotify, tts
- Language: Python
- Size: 941 KB
- Stars: 0
- Watchers: 0
- Forks: 1
- Open Issues: 6
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
# clodcast
[](https://github.com/schmug/clodcast/actions/workflows/ci.yml)
A Claude Code skill that turns a list of saved articles (or RSS items) into a fully-produced Spotify episode in one pass:
- Pulls full content for each item
- Writes a segmented script using a deterministic template (intro + per-item + outro)
- Renders TTS via Qwen3-TTS with a locked house voice (`ref_audio` cloning, no run-to-run drift)
- Concatenates with auto-padded silences to satisfy Spotify's chapter rules
- Builds a date-stamped cover, timeline, and HTML description
- Uploads via the `save-to-spotify` CLI and polls until the episode is `READY`
- Updates a per-user dedup log so the same URLs are not re-covered
Ships an executable `render.py` and a self-contained `claude -p` prompt so the whole thing can run unattended on a schedule.
## Live example
This skill ships a real show every morning: **[Cortech — Daily Digest](https://cortech.online/podcast/)**.
Every episode on that page was produced end-to-end by `daily-podcast` on an unattended
schedule — same house voice, same template, same `save-to-spotify` upload path described
below. It's the fastest way to hear what the renderer actually sounds like.
- 🎧 **Listen / browse episodes:**
- ▶️ **Hear one now:** [Daily Digest — June 5, 2026](https://clodcast.cortech.online/daily-digest-june-5-2026.mp3) (mp3, 6:19) — one of the daily episodes
- 📡 **Subscribe (RSS):**
(That page and feed are generated by [cortech.online](https://github.com/schmug/cortech.online)
from the Cloudflare R2 bucket each run publishes to — see
[Optional: publish to a web feed](#optional-publish-to-a-web-feed-cloudflare-r2) below.)
## Install
```bash
/plugin marketplace add schmug/clodcast
/plugin install daily-podcast@clodcast
```
### Releases
Versions follow [semver](https://semver.org/) and are tagged `vX.Y.Z`. See the
[**Releases**](https://github.com/schmug/clodcast/releases) page for tagged
versions and the [**CHANGELOG**](CHANGELOG.md) for what changed in each. Both are
maintained automatically by [release-please](https://github.com/googleapis/release-please)
from conventional commits: every push to `main` updates a standing "release PR" with
the next version and changelog; **merging that PR** cuts the tag and GitHub Release.
## Dependencies
- **`save-to-spotify` CLI** on `PATH`, authenticated
- `curl -fsSL https://saveto.spotify.com/install.sh | bash`
- `save-to-spotify auth login`
- **Apple Silicon Mac** (Qwen3-TTS via MLX uses Metal). Swap the renderer if you want a different TTS provider.
- **Python 3.10+** — runtime deps are declared in [`pyproject.toml`](pyproject.toml) (`mlx-audio`, `soundfile`, `mutagen`, `Pillow`, `numpy`, `feedparser`)
- `pip install -r requirements.txt` (or `pip install -e .` for an editable checkout)
- **`ffmpeg`** and **`ffprobe`**
- ~4 GB free disk for the first model download (Qwen3-TTS Base 1.7B-8bit)
## Setup
One-time config:
```bash
mkdir -p ~/.config/daily-podcast
cat > ~/.config/daily-podcast/config.json << 'EOF'
{
"show_id": "spotify:show:",
"show_name": "Your Show Name",
"host_name": "Your Name",
"opml_files": ["/path/to/your-feeds.opml"],
"lookback_hours": 24,
"target_item_count": 10
}
EOF
```
Get a `show_id` by running `save-to-spotify --json shows` (creates a default show if you don't have one) and copying the URI.
### Optional: publish to a web feed (Cloudflare R2)
Beyond Spotify, each finished episode can also be published to a Cloudflare R2 bucket,
which [cortech.online](https://github.com/schmug/cortech.online) turns into a `/podcast/`
page and an iTunes RSS feed at `/podcast/rss.xml`. This is additive — if it's not
configured, runs behave exactly as before.
Add the bucket + public URL to `config.json`:
```jsonc
"r2_bucket": "clodcast",
"r2_public_base_url": "https://audio.cortech.online" // your R2 public domain
```
Provide credentials via env (never in `config.json` or git) — or a `0600`
`~/.config/daily-podcast/secrets.json` with the same keys:
```bash
export R2_ACCESS_KEY_ID=... # R2 API token
export R2_SECRET_ACCESS_KEY=...
export R2_ACCOUNT_ID=... # Cloudflare account ID
# optional:
export PAGES_DEPLOY_HOOK_URL=... # POSTed after publish so the site rebuilds in ~30s
```
The optional **Pages deploy hook** is POSTed after a successful publish so the site
rebuilds. It resolves the same cron-friendly way as the credentials — env first,
then `secrets.json` (`"PAGES_DEPLOY_HOOK_URL"`), then `config.json`
(`"pages_deploy_hook_url"`). A **scheduled** run (launchd/cron) never inherits your
interactive shell env, so put the hook in `secrets.json` (0600) for unattended runs —
that's also its preferred home because the URL can trigger builds. `config.json`
support is a convenience for the shareable file; if all three are unset, no hook fires
(unchanged).
When all five resolve, a successful run publishes `.mp3` + a `manifest.json`
entry (which carries both the Spotify-flavored HTML `description` and a clean plain-text
`summary` for web/RSS consumers) and prints `"r2_status": "published"`. The run always
succeeds regardless of the R2 outcome (the Spotify episode is canonical): the 3-state
`"r2_status"` is `"published"`, `"skipped"` (R2 not configured), or `"failed"` (configured
but the upload errored — surfaced so an operator can spot a silent web-feed miss). The
`--workdir` resume path back-fills to R2 the same way. See
[SKILL.md](skills/daily-podcast/SKILL.md#publishing-to-the-web-cloudflare-r2) for details.
## Usage
### Interactive (one episode in a conversation)
Ask Claude to ship today's podcast. The skill activates automatically:
> "ship today's daily digest"
### Headless (unattended schedule)
Run the orchestrator, which gathers + curates deterministically and summarizes each item
in its own isolated `claude -p` subprocess — so a cyber-content classifier block drops
only that item instead of failing the whole run:
```bash
python3 skills/daily-podcast/orchestrate.py
```
Final stdout is a single line: `SHIPPED ...` or `FAILED `.
Hook it up to launchd, cron, or any scheduler.
> **Heads up — child `claude -p` needs its own credentials.** Each item is summarized in a
> child `claude -p` subprocess that authenticates from disk/env, not from the parent's
> in-memory login. A scheduler can start those children with no usable credential, in which
> case every item 401s and the run fails fast with an actionable line (instead of silently
> reporting "no viable items"). Give the job a durable credential — a persistent on-disk
> token, or `ANTHROPIC_API_KEY` in the scheduler's own environment — and verify it from
> inside the scheduled context. See SKILL.md, *"Unattended runs need durable credentials"*.
### Scheduled runs (pre-flight + disk hygiene)
For unattended runs, pre-flight with `render.py --selftest` so a broken dependency or an
expired `save-to-spotify auth` fails loudly *before* the scheduled hour:
```bash
#!/usr/bin/env bash
set -euo pipefail
cd "$HOME/clodcast"
python3 skills/daily-podcast/render.py --selftest || { echo "selftest failed"; exit 1; }
python3 skills/daily-podcast/orchestrate.py
```
For disk hygiene, `render.py --prune-workdirs N` is the mechanism — pass it when calling
`render.py` directly with `--manifest`. `orchestrate.py` does not forward this flag.
`--selftest` (mutually exclusive with `--manifest`) checks ffmpeg/ffprobe, `save-to-spotify`
auth, `config.json`, and the house-voice clip — printing a pass/fail line per check and a
JSON summary — and exits non-zero on any failure. It runs in under 5 seconds. On a successful
real run the auto-created workdir is deleted (pass `--keep-workdir` to retain it; a failed run
always keeps it for debugging).
### Config files
The orchestrator writes two additional state files alongside `covered.json` and `runs.jsonl`:
- `~/.config/daily-podcast/feed_usage.json` — records the last date each feed contributed a segment; drives the variety penalty so the same feed doesn't dominate consecutive episodes. Updated only on a successful real run (`--dry-run` leaves it unchanged).
- `~/.config/daily-podcast/dropped.jsonl` — append-only log of every item that was blocked, refused, timed out, errored, or hit an auth failure during a run. One JSON line per dropped item: `{timestamp, run_date, feed_name, url, reason, detail}` (`reason` ∈ `refused`/`blocked`/`auth`/`timeout`/`error`). Useful for diagnosing feed-level issues or cyber-content policy patterns; an all-`auth` night means child `claude -p` could not authenticate (see SKILL.md).
### Run log
Every run appends one JSON record to `~/.config/daily-podcast/runs.jsonl` — on success,
`--dry-run`, and failure alike. It's append-only (one line per day), so grepping a single file
answers across-runs questions — which voice ran yesterday, which run failed and why, whether
loudness is drifting from Spotify's -16 LUFS target — without spelunking ephemeral workdirs.
Each record has a stable key set (`timestamp`, `status`, `episode_uri`, `voice`, `voice_mode`,
`chapter_count`, `duration_s`, `error_message`, `git_sha`, `loudnorm`, …; missing values are
`null`, never absent), so it parses cleanly line-by-line:
```bash
# Every failure and its error message
jq -r 'select(.status == "failed") | .error_message' ~/.config/daily-podcast/runs.jsonl
# Loudness (output_i, LUFS) per run — watch for drift
jq -r 'select(.loudnorm) | "\(.timestamp) \(.loudnorm.output_i)"' ~/.config/daily-podcast/runs.jsonl
```
Retention is the operator's job (the file grows ~one line/day); rotate it manually if you like.
## Voice
The default "house" voice is `ref_audio` cloning from a ~22 second reference clip. The Base 1.7B Qwen3-TTS model regenerates that voice's timbre and prosody for any new text, so the voice stays consistent across episodes.
On first run, the bundled default is copied to `~/.config/daily-podcast/voices/house.{wav,txt}`. Anything you put there wins over the bundled copy and survives plugin updates.
To change the voice:
1. Capture a new ~20-30 second reference clip (any TTS or human recording)
2. Save it to `~/.config/daily-podcast/voices/house.wav` (PCM_16, mono, 24 kHz preferred)
3. Update `~/.config/daily-podcast/voices/house.txt` with the exact transcript
4. Done — every subsequent `voice: "house"` render uses the new clip
Other voice options (set in manifest):
- `"voice": "random"` — preset rotation over `[Ryan, Aiden, Ethan, Chelsie]`
- `"voice": "Ryan"` (or any preset) — single fixed preset
- `"voice_instruct": "..."` — VoiceDesign mode, full natural-language override
**Want to design your own voice from scratch?** See [docs/durable-voices.md](docs/durable-voices.md) — covers why `ref_audio` cloning beats VoiceDesign for long-running shows, the iteration workflow that produced the bundled house voice, common failure modes to avoid (over-enunciation, theatrical drift, noir weight), and how to verify a new clip is stable.
## Development
Install the dev tools (lint + tests). The runtime deps are Apple-Silicon-only, so for tooling alone just install the two tools directly:
```bash
pip install ruff pytest # tooling only (no MLX)
# or, for a full editable env on Apple Silicon:
pip install -e ".[dev]"
```
**Lint & format** — both `ruff check` and `ruff format --check` are enforced (CI fails on a format diff). The renderer's once hand-tuned layout was reformatted to `ruff format` in one isolated commit; run `ruff format .` to fix any diff before committing:
```bash
ruff check .
ruff format --check . # enforced — fails on any diff
```
The reformat commit is listed in [`.git-blame-ignore-revs`](.git-blame-ignore-revs) so the bulk-format churn doesn't pollute `git blame`. To skip it locally:
```bash
git config blame.ignoreRevsFile .git-blame-ignore-revs
```
(GitHub honors this file automatically in its blame view.)
**Tests:**
```bash
pytest
```
**Pre-commit hooks** — run `ruff check --fix` plus whitespace/EOF/YAML/JSON hygiene on staged files (the reference clip in `refs/` is excluded):
```bash
pip install pre-commit && pre-commit install
```
After that, `git commit` runs the hooks automatically; `pre-commit run --all-files` checks the whole tree.
## License
MIT