{"id":50718324,"url":"https://github.com/schmug/clodcast","last_synced_at":"2026-06-09T21:02:47.724Z","repository":{"id":360012234,"uuid":"1247543219","full_name":"schmug/clodcast","owner":"schmug","description":"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.","archived":false,"fork":false,"pushed_at":"2026-06-03T13:12:56.000Z","size":964,"stargazers_count":0,"open_issues_count":6,"forks_count":1,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-03T14:12:27.268Z","etag":null,"topics":["claude-code","claude-code-plugin","claude-skill","podcast","qwen3-tts","spotify","tts"],"latest_commit_sha":null,"homepage":null,"language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/schmug.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-23T13:06:28.000Z","updated_at":"2026-06-03T12:21:18.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/schmug/clodcast","commit_stats":null,"previous_names":["schmug/clodcast"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/schmug/clodcast","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/schmug%2Fclodcast","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/schmug%2Fclodcast/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/schmug%2Fclodcast/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/schmug%2Fclodcast/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/schmug","download_url":"https://codeload.github.com/schmug/clodcast/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/schmug%2Fclodcast/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34125332,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-09T02:00:06.510Z","response_time":63,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["claude-code","claude-code-plugin","claude-skill","podcast","qwen3-tts","spotify","tts"],"created_at":"2026-06-09T21:02:46.955Z","updated_at":"2026-06-09T21:02:47.715Z","avatar_url":"https://github.com/schmug.png","language":"Python","funding_links":[],"categories":[],"sub_categories":[],"readme":"# clodcast\n\n[![CI](https://github.com/schmug/clodcast/actions/workflows/ci.yml/badge.svg)](https://github.com/schmug/clodcast/actions/workflows/ci.yml)\n\nA Claude Code skill that turns a list of saved articles (or RSS items) into a fully-produced Spotify episode in one pass:\n\n- Pulls full content for each item\n- Writes a segmented script using a deterministic template (intro + per-item + outro)\n- Renders TTS via Qwen3-TTS with a locked house voice (`ref_audio` cloning, no run-to-run drift)\n- Concatenates with auto-padded silences to satisfy Spotify's chapter rules\n- Builds a date-stamped cover, timeline, and HTML description\n- Uploads via the `save-to-spotify` CLI and polls until the episode is `READY`\n- Updates a per-user dedup log so the same URLs are not re-covered\n\nShips an executable `render.py` and a self-contained `claude -p` prompt so the whole thing can run unattended on a schedule.\n\n## Live example\n\nThis skill ships a real show every morning: **[Cortech — Daily Digest](https://cortech.online/podcast/)**.\nEvery episode on that page was produced end-to-end by `daily-podcast` on an unattended\nschedule — same house voice, same template, same `save-to-spotify` upload path described\nbelow. It's the fastest way to hear what the renderer actually sounds like.\n\n- 🎧 **Listen / browse episodes:** \u003chttps://cortech.online/podcast/\u003e\n- ▶️ **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\n- 📡 **Subscribe (RSS):** \u003chttps://cortech.online/podcast/rss.xml\u003e\n\n(That page and feed are generated by [cortech.online](https://github.com/schmug/cortech.online)\nfrom the Cloudflare R2 bucket each run publishes to — see\n[Optional: publish to a web feed](#optional-publish-to-a-web-feed-cloudflare-r2) below.)\n\n## Install\n\n```bash\n/plugin marketplace add schmug/clodcast\n/plugin install daily-podcast@clodcast\n```\n\n### Releases\n\nVersions follow [semver](https://semver.org/) and are tagged `vX.Y.Z`. See the\n[**Releases**](https://github.com/schmug/clodcast/releases) page for tagged\nversions and the [**CHANGELOG**](CHANGELOG.md) for what changed in each. Both are\nmaintained automatically by [release-please](https://github.com/googleapis/release-please)\nfrom conventional commits: every push to `main` updates a standing \"release PR\" with\nthe next version and changelog; **merging that PR** cuts the tag and GitHub Release.\n\n## Dependencies\n\n- **`save-to-spotify` CLI** on `PATH`, authenticated\n  - `curl -fsSL https://saveto.spotify.com/install.sh | bash`\n  - `save-to-spotify auth login`\n- **Apple Silicon Mac** (Qwen3-TTS via MLX uses Metal). Swap the renderer if you want a different TTS provider.\n- **Python 3.10+** — runtime deps are declared in [`pyproject.toml`](pyproject.toml) (`mlx-audio`, `soundfile`, `mutagen`, `Pillow`, `numpy`, `feedparser`)\n  - `pip install -r requirements.txt` (or `pip install -e .` for an editable checkout)\n- **`ffmpeg`** and **`ffprobe`**\n- ~4 GB free disk for the first model download (Qwen3-TTS Base 1.7B-8bit)\n\n## Setup\n\nOne-time config:\n\n```bash\nmkdir -p ~/.config/daily-podcast\ncat \u003e ~/.config/daily-podcast/config.json \u003c\u003c 'EOF'\n{\n  \"show_id\": \"spotify:show:\u003cyour-show-id\u003e\",\n  \"show_name\": \"Your Show Name\",\n  \"host_name\": \"Your Name\",\n  \"opml_files\": [\"/path/to/your-feeds.opml\"],\n  \"lookback_hours\": 24,\n  \"target_item_count\": 10\n}\nEOF\n```\n\nGet a `show_id` by running `save-to-spotify --json shows` (creates a default show if you don't have one) and copying the URI.\n\n### Optional: publish to a web feed (Cloudflare R2)\n\nBeyond Spotify, each finished episode can also be published to a Cloudflare R2 bucket,\nwhich [cortech.online](https://github.com/schmug/cortech.online) turns into a `/podcast/`\npage and an iTunes RSS feed at `/podcast/rss.xml`. This is additive — if it's not\nconfigured, runs behave exactly as before.\n\nAdd the bucket + public URL to `config.json`:\n\n```jsonc\n  \"r2_bucket\": \"clodcast\",\n  \"r2_public_base_url\": \"https://audio.cortech.online\"   // your R2 public domain\n```\n\nProvide credentials via env (never in `config.json` or git) — or a `0600`\n`~/.config/daily-podcast/secrets.json` with the same keys:\n\n```bash\nexport R2_ACCESS_KEY_ID=...      # R2 API token\nexport R2_SECRET_ACCESS_KEY=...\nexport R2_ACCOUNT_ID=...         # Cloudflare account ID\n# optional:\nexport PAGES_DEPLOY_HOOK_URL=...  # POSTed after publish so the site rebuilds in ~30s\n```\n\nThe optional **Pages deploy hook** is POSTed after a successful publish so the site\nrebuilds. It resolves the same cron-friendly way as the credentials — env first,\nthen `secrets.json` (`\"PAGES_DEPLOY_HOOK_URL\"`), then `config.json`\n(`\"pages_deploy_hook_url\"`). A **scheduled** run (launchd/cron) never inherits your\ninteractive shell env, so put the hook in `secrets.json` (0600) for unattended runs —\nthat's also its preferred home because the URL can trigger builds. `config.json`\nsupport is a convenience for the shareable file; if all three are unset, no hook fires\n(unchanged).\n\nWhen all five resolve, a successful run publishes `\u003cslug\u003e.mp3` + a `manifest.json`\nentry (which carries both the Spotify-flavored HTML `description` and a clean plain-text\n`summary` for web/RSS consumers) and prints `\"r2_status\": \"published\"`. The run always\nsucceeds regardless of the R2 outcome (the Spotify episode is canonical): the 3-state\n`\"r2_status\"` is `\"published\"`, `\"skipped\"` (R2 not configured), or `\"failed\"` (configured\nbut the upload errored — surfaced so an operator can spot a silent web-feed miss). The\n`--workdir` resume path back-fills to R2 the same way. See\n[SKILL.md](skills/daily-podcast/SKILL.md#publishing-to-the-web-cloudflare-r2) for details.\n\n## Usage\n\n### Interactive (one episode in a conversation)\n\nAsk Claude to ship today's podcast. The skill activates automatically:\n\n\u003e \"ship today's daily digest\"\n\n### Headless (unattended schedule)\n\nRun the orchestrator, which gathers + curates deterministically and summarizes each item\nin its own isolated `claude -p` subprocess — so a cyber-content classifier block drops\nonly that item instead of failing the whole run:\n\n```bash\npython3 skills/daily-podcast/orchestrate.py\n```\n\nFinal stdout is a single line: `SHIPPED \u003cepisode_uri\u003e ...` or `FAILED \u003creason\u003e`.\n\nHook it up to launchd, cron, or any scheduler.\n\n\u003e **Heads up — child `claude -p` needs its own credentials.** Each item is summarized in a\n\u003e child `claude -p` subprocess that authenticates from disk/env, not from the parent's\n\u003e in-memory login. A scheduler can start those children with no usable credential, in which\n\u003e case every item 401s and the run fails fast with an actionable line (instead of silently\n\u003e reporting \"no viable items\"). Give the job a durable credential — a persistent on-disk\n\u003e token, or `ANTHROPIC_API_KEY` in the scheduler's own environment — and verify it from\n\u003e inside the scheduled context. See SKILL.md, *\"Unattended runs need durable credentials\"*.\n\n### Scheduled runs (pre-flight + disk hygiene)\n\nFor unattended runs, pre-flight with `render.py --selftest` so a broken dependency or an\nexpired `save-to-spotify auth` fails loudly *before* the scheduled hour:\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\ncd \"$HOME/clodcast\"\npython3 skills/daily-podcast/render.py --selftest || { echo \"selftest failed\"; exit 1; }\npython3 skills/daily-podcast/orchestrate.py\n```\n\nFor disk hygiene, `render.py --prune-workdirs N` is the mechanism — pass it when calling\n`render.py` directly with `--manifest`. `orchestrate.py` does not forward this flag.\n\n`--selftest` (mutually exclusive with `--manifest`) checks ffmpeg/ffprobe, `save-to-spotify`\nauth, `config.json`, and the house-voice clip — printing a pass/fail line per check and a\nJSON summary — and exits non-zero on any failure. It runs in under 5 seconds. On a successful\nreal run the auto-created workdir is deleted (pass `--keep-workdir` to retain it; a failed run\nalways keeps it for debugging).\n\n### Config files\n\nThe orchestrator writes two additional state files alongside `covered.json` and `runs.jsonl`:\n\n- `~/.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).\n- `~/.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).\n\n### Run log\n\nEvery run appends one JSON record to `~/.config/daily-podcast/runs.jsonl` — on success,\n`--dry-run`, and failure alike. It's append-only (one line per day), so grepping a single file\nanswers across-runs questions — which voice ran yesterday, which run failed and why, whether\nloudness is drifting from Spotify's -16 LUFS target — without spelunking ephemeral workdirs.\nEach record has a stable key set (`timestamp`, `status`, `episode_uri`, `voice`, `voice_mode`,\n`chapter_count`, `duration_s`, `error_message`, `git_sha`, `loudnorm`, …; missing values are\n`null`, never absent), so it parses cleanly line-by-line:\n\n```bash\n# Every failure and its error message\njq -r 'select(.status == \"failed\") | .error_message' ~/.config/daily-podcast/runs.jsonl\n# Loudness (output_i, LUFS) per run — watch for drift\njq -r 'select(.loudnorm) | \"\\(.timestamp)  \\(.loudnorm.output_i)\"' ~/.config/daily-podcast/runs.jsonl\n```\n\nRetention is the operator's job (the file grows ~one line/day); rotate it manually if you like.\n\n## Voice\n\nThe 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.\n\nOn 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.\n\nTo change the voice:\n1. Capture a new ~20-30 second reference clip (any TTS or human recording)\n2. Save it to `~/.config/daily-podcast/voices/house.wav` (PCM_16, mono, 24 kHz preferred)\n3. Update `~/.config/daily-podcast/voices/house.txt` with the exact transcript\n4. Done — every subsequent `voice: \"house\"` render uses the new clip\n\nOther voice options (set in manifest):\n- `\"voice\": \"random\"` — preset rotation over `[Ryan, Aiden, Ethan, Chelsie]`\n- `\"voice\": \"Ryan\"` (or any preset) — single fixed preset\n- `\"voice_instruct\": \"...\"` — VoiceDesign mode, full natural-language override\n\n**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.\n\n## Development\n\nInstall the dev tools (lint + tests). The runtime deps are Apple-Silicon-only, so for tooling alone just install the two tools directly:\n\n```bash\npip install ruff pytest      # tooling only (no MLX)\n# or, for a full editable env on Apple Silicon:\npip install -e \".[dev]\"\n```\n\n**Lint \u0026 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:\n\n```bash\nruff check .\nruff format --check .   # enforced — fails on any diff\n```\n\nThe 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:\n\n```bash\ngit config blame.ignoreRevsFile .git-blame-ignore-revs\n```\n\n(GitHub honors this file automatically in its blame view.)\n\n**Tests:**\n\n```bash\npytest\n```\n\n**Pre-commit hooks** — run `ruff check --fix` plus whitespace/EOF/YAML/JSON hygiene on staged files (the reference clip in `refs/` is excluded):\n\n```bash\npip install pre-commit \u0026\u0026 pre-commit install\n```\n\nAfter that, `git commit` runs the hooks automatically; `pre-commit run --all-files` checks the whole tree.\n\n## License\n\nMIT\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fschmug%2Fclodcast","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fschmug%2Fclodcast","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fschmug%2Fclodcast/lists"}