An open API service indexing awesome lists of open source software.

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.

Awesome Lists containing this project

README

          

# clodcast

[![CI](https://github.com/schmug/clodcast/actions/workflows/ci.yml/badge.svg)](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