From 05b99be6ec9c6c9f309b10535ae46d879e2aeebf Mon Sep 17 00:00:00 2001 From: Magnus Hedemark Date: Sat, 29 Aug 2026 22:02:23 -0400 Subject: [PATCH] docs(transistor): thicken podcast hosting skill against current API research Full skill-builder rebuild of transistor per issue #407: - scripts/transistor (renamed from transistor-cli): 19 commands covering the verified API surface - user probe (GET /v1; the /v1/user route does not exist), shows/episodes with corrected pagination[page]/pagination[per], the dedicated episode publish endpoint (PATCH /v1/episodes/:id/publish with episode[status]=draft|scheduled|published), authorize-upload flow, the three real analytics routes with downloads[] array summing, subscriber management incl. batch, and webhooks. Write bodies are form-encoded bracket keys exactly as documented; dry-run plans carry method/path/params/body; publish guard refuses audio-less episodes. Fixed stale claims: /analytics/show -> /v1/analytics/..., totals -> downloads arrays, pagination[limit] -> pagination[per], user email -> name/time_zone, dropped invented episodes_count/subscribers_count and POST /v1/shows (show creation is dashboard-only). - scripts/test_transistor.py: 50 offline tests (pytest + unittest green, proxy-trap clean) covering help, argument errors, dry-run plans, canned JSON:API compound-document parsing (data/attributes/relationships/ included[]), write-path body shapes, publish guard, create->audio-> publish pipeline, and HTTP error signatures. - references/: auth+JSON:API envelope with jq patterns, endpoint catalog, publish lifecycle with documented request/response shapes, gotchas field guide + worked recipes; all cited to live-verified sources. - evals/evals.json: 8 schema-v1 cases incl. two should-not-trigger probes. - SKILL.md rewritten (308 lines), README refreshed, root README blurb and generated catalogs synced (marketplace.json + llms.txt descriptions). Publish-body shape reconciliation: the contract's data.id+data.type JSON:API PATCH premise was falsified by current official docs (verified 2026-08-29) and the flimzy/transistor Go SDK; implemented reality escalated in handoff (see library/transistor-api-facts.md). Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .claude-plugin/marketplace.json | 2 +- README.md | 2 +- llms.txt | 2 +- transistor/README.md | 60 +- transistor/SKILL.md | 316 +++++- transistor/evals/evals.json | 94 ++ transistor/references/auth-and-basics.md | 165 ++++ transistor/references/endpoint-catalog.md | 137 +++ .../references/episode-publish-lifecycle.md | 201 ++++ transistor/references/gotchas-and-recipes.md | 256 +++++ transistor/scripts/test_transistor.py | 912 ++++++++++++++++++ transistor/scripts/transistor | 757 +++++++++++++++ transistor/scripts/transistor-cli | 160 --- 13 files changed, 2832 insertions(+), 232 deletions(-) create mode 100644 transistor/evals/evals.json create mode 100644 transistor/references/auth-and-basics.md create mode 100644 transistor/references/endpoint-catalog.md create mode 100644 transistor/references/episode-publish-lifecycle.md create mode 100644 transistor/references/gotchas-and-recipes.md create mode 100644 transistor/scripts/test_transistor.py create mode 100755 transistor/scripts/transistor delete mode 100755 transistor/scripts/transistor-cli diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 91266f9..24e8eab 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1299,7 +1299,7 @@ "./transistor" ], "strict": false, - "description": "Manage Transistor.fm podcast hosting from the terminal: view shows, list episodes, check analytics, and get subscriber counts. Use when the user mentions Transistor, podcast hosting, podcast analytics, show management, or episode tracking." + "description": "Operate Transistor.fm podcast hosting from the terminal: verify API access, browse shows and episodes with JSON:API-aware output, run the episode publish lifecycle (create draft, attach audio, publish or schedule via the dedicated publish endpoint), pull download analytics, and manage private podcast subscribers and webhooks. Use when the user mentions Transistor, Transistor.fm, podcast hosting, episode publishing, private podcast subscribers, or podcast download analytics. Do not use this skill for other podcast hosts (Buzzsprout, Libsyn, Megaphone, Spotify for Creators), for editing or producing audio, or for feed/RSS parsing — the bundled CLI manages a Transistor account through its v1 API and cannot create new shows (dashboard-only)." }, { "name": "travel-guide", diff --git a/README.md b/README.md index 274b871..622c382 100644 --- a/README.md +++ b/README.md @@ -589,7 +589,7 @@ Trakt.tv media discovery from the terminal. Browse trending, anticipated, and po ### [transistor](transistor/SKILL.md) -Transistor.fm podcast hosting from the terminal. Manage shows and episodes, view subscriber analytics. API key from transistor.fm settings. +Operate Transistor.fm podcast hosting from the terminal: verify API access, browse shows and episodes with JSON:API-aware output, run the episode publish lifecycle (create draft, attach audio, publish or schedule via the dedicated publish endpoint), pull download analytics, and manage private podcast subscribers and webhooks. ### [travel-guide](travel-guide/SKILL.md) diff --git a/llms.txt b/llms.txt index 0f1a4bf..342cc38 100644 --- a/llms.txt +++ b/llms.txt @@ -145,7 +145,7 @@ - [tmdb](tmdb/SKILL.md): Query TMDb metadata for films and television, then enrich results with details, credits, providers, and external IDs. Do not use this skill for torrent search, streaming playback, or personal watch-history tracking. - [traefik](traefik/SKILL.md): Deploy, configure, and troubleshoot Traefik v3 reverse proxy — covers all providers, routing, TLS/ACME, middlewares, and production patterns with YAML examples. Load when setting up or debugging a Traefik instance. - [trakt](trakt/SKILL.md): Discover and compare Trakt.tv trending, popular, and anticipated movies and shows from the terminal. Do not use this skill for TMDb catalog metadata, credits, images, or provider lookups; use the tmdb skill for those tasks. -- [transistor](transistor/SKILL.md): Manage Transistor.fm podcast hosting from the terminal: view shows, list episodes, check analytics, and get subscriber counts. Use when the user mentions Transistor, podcast hosting, podcast analytics, show management, or episode tracking. +- [transistor](transistor/SKILL.md): Operate Transistor.fm podcast hosting from the terminal: verify API access, browse shows and episodes with JSON:API-aware output, run the episode publish lifecycle (create draft, attach audio, publish or schedule via the dedicated publish endpoint), pull download analytics, and manage private podcast subscribers and webhooks. Use when the user mentions Transistor, Transistor.fm, podcast hosting, episode publishing, private podcast subscribers, or podcast download analytics. Do not use this skill for other podcast hosts (Buzzsprout, Libsyn, Megaphone, Spotify for Creators), for editing or producing audio, or for feed/RSS parsing — the bundled CLI manages a Transistor account through its v1 API and cannot create new shows (dashboard-only). - [travel-guide](travel-guide/SKILL.md): Create personalized, source-grounded travel dossiers from a destination, dates, duration, travelers, and constraints. Ask only the questions that change the plan, use explicitly permitted personal context without exposing it, research current logistics, and produce a cited, visually coherent PDF or responsive companion web page. Use when someone wants an individualized itinerary, trip brief, travel field guide, or shareable travel website. Do not use for real-time booking, ticket purchasing, visa or legal advice, or generic destination summaries without a specific traveler and trip. - [vercel-eve](vercel-eve/SKILL.md): Build, develop, deploy, self-host, secure, and troubleshoot durable backend AI agents with Vercel Eve. Use when creating an Eve agent, adding tools, skills, subagents, channels, schedules, sandboxing, durable sessions, observability, or deploying Eve on Vercel or a Node host. Do not use for the separate Vercel AI SDK Agent APIs such as ToolLoopAgent or WorkflowAgent; use an AI SDK-specific skill for those. - [verification-methodology](verification-methodology/SKILL.md): Verify work against explicit criteria using direct, source-faithful evidence, reproducible checks, and clear verdicts. Use before declaring an artifact, implementation, or claim complete; do not use for exploratory research without pass/fail criteria. diff --git a/transistor/README.md b/transistor/README.md index 655bde6..7ead58e 100644 --- a/transistor/README.md +++ b/transistor/README.md @@ -1,37 +1,65 @@ # Transistor.fm — Podcast Hosting from the Terminal -Manage your Transistor.fm podcast shows, episodes, subscribers, and analytics — all from the terminal. +Manage your Transistor.fm podcast account over its official API: browse +shows and episodes, publish episodes, pull download analytics, and run +private-podcast subscriber lists — all from the terminal. ## Why Install This Skill -When your agent loads this skill, it can **manage your podcast hosting** without the web dashboard. That means: +When your agent loads this skill, it can **operate your Transistor.fm +podcast hosting** without the dashboard, including the part no other tool +gives an agent: the full episode publish lifecycle. -- **List shows** — all your podcasts with episode and subscriber counts -- **Browse episodes** — recent episodes with publish dates -- **Check analytics** — subscriber counts and trends -- **Filter by show** — drill into a specific podcast's episodes +- **Publish episodes end to end** — create a draft, attach audio (URL or + authorized local-file upload), then publish or schedule it through + Transistor's dedicated publish endpoint +- **Browse your catalog** — shows, episodes, drafts, season/number + metadata, with JSON:API compound documents unwrapped for jq +- **Track downloads** — per-day analytics windows for shows and episodes, + summed and ready for reports +- **Run private podcasts** — list, add (single or batch), and revoke + subscribers; register webhooks so you push instead of poll +- **Stay under the rate limit** — dry-run request plans and clear 429 + guidance (Transistor allows 10 requests per 10 seconds) ## What You Get -| Directory | Purpose | -|-----------|---------| -| `SKILL.md` | Complete command reference with examples | -| `scripts/transistor-cli` | CLI tool for Transistor.fm v1 REST API | +| Path | Purpose | +|------|---------| +| `SKILL.md` | Command reference, publish-lifecycle recipe, jq guidance, gotchas | +| `scripts/transistor` | Bundled Python CLI for the Transistor.fm v1 API (read + write commands) | +| `scripts/test_transistor.py` | Offline mocked test suite (canned JSON:API documents, zero network) | +| `references/auth-and-basics.md` | API-key auth, JSON:API envelope and jq patterns, pagination, errors | +| `references/endpoint-catalog.md` | Every endpoint's method, path, and parameters | +| `references/episode-publish-lifecycle.md` | Draft → audio → publish/schedule/unpublish, exact request shapes | +| `references/gotchas-and-recipes.md` | Symptom → cause → fix guide plus multi-step workflows | ## Quick Start ```bash -export TRANSISTOR_API_KEY="your-transistor-api-key" -transistor-cli shows -transistor-cli episodes +export TRANSISTOR_API_KEY="" # Dashboard -> Account -> API Access + +transistor user # verify the key +transistor shows # list your podcasts +transistor episodes --status draft # what is not out yet? + +# Publish pipeline: create (draft) -> attach audio -> publish +EP=$(transistor episode-create --show --title "Ep 12" \ + --audio-url "https://example.com/ep12.mp3" --json | jq -r '.id') +transistor episode-publish --id "$EP" ``` -API key from Settings → API Keys in the Transistor.fm dashboard. +`--help` and `--dry-run` work without an API key; preview any request with +`transistor --dry-run episode-publish --id 123`. ## Triggers -Load this for Transistor.fm, podcast hosting, podcast analytics, show management, or episode tracking. +Load this skill when the user mentions Transistor or Transistor.fm, podcast +hosting, publishing a podcast episode, scheduling or unpublishing episodes, +podcast download analytics, or private podcast subscribers. ## Requirements -Python 3.8+ with `requests` library. +Python 3.8+ with `requests`, plus a Transistor.fm API key (Account page → +API Access). The key carries your dashboard role per podcast; treat it like +a password. No other services or credentials are involved. diff --git a/transistor/SKILL.md b/transistor/SKILL.md index 7d6b168..a776b08 100644 --- a/transistor/SKILL.md +++ b/transistor/SKILL.md @@ -1,98 +1,308 @@ --- name: transistor -description: 'Manage Transistor.fm podcast hosting from the terminal: view shows, - list episodes, check analytics, and get subscriber counts. Use when the user mentions - Transistor, podcast hosting, podcast analytics, show management, or episode tracking.' +description: >- + Operate Transistor.fm podcast hosting from the terminal: verify API access, + browse shows and episodes with JSON:API-aware output, run the episode + publish lifecycle (create draft, attach audio, publish or schedule via the + dedicated publish endpoint), pull download analytics, and manage private + podcast subscribers and webhooks. Use when the user mentions Transistor, + Transistor.fm, podcast hosting, episode publishing, private podcast + subscribers, or podcast download analytics. Do not use this skill for other + podcast hosts (Buzzsprout, Libsyn, Megaphone, Spotify for Creators), for + editing or producing audio, or for feed/RSS parsing — the bundled CLI + manages a Transistor account through its v1 API and cannot create new + shows (dashboard-only). license: MIT -compatibility: Requires TRANSISTOR_API_KEY env var (from Settings → API Keys in the - Transistor.fm dashboard), Python 3.8+, and the `requests` library. Uses the Transistor.fm - v1 REST API with JSON:API format responses. +compatibility: Requires TRANSISTOR_API_KEY env var (Account page -> API Access + at https://dashboard.transistor.fm/account), Python 3.8+, and `requests`. + Read commands need a working key; `--help` and `--dry-run` never do. metadata: - tags: transistor, podcast, podcast-hosting, analytics, api-client - sources: https://transistor.fm/, https://developers.transistor.fm/ + tags: transistor, podcast, podcast-hosting, episodes, analytics, api-client + sources: https://developers.transistor.fm/, https://support.transistor.fm/ --- -# transistor-cli — Transistor.fm Podcast Hosting +# transistor — Transistor.fm podcast hosting from the terminal -Manage your Transistor.fm podcast shows, episodes, subscribers, and analytics from the terminal. Uses the Transistor.fm v1 REST API. +Drive a Transistor.fm account over its v1 JSON:API: shows, episodes, the +draft→publish lifecycle, per-day download analytics, private-podcast +subscribers, and webhooks. Responses are JSON:API documents; the bundled CLI +unwraps them (`--json`) while preserving the raw shapes agents need for jq. +Write commands are guarded: episode creation is always a draft, and +publishing goes through its own dedicated endpoint. ## Setup -1. Get your API key from Transistor.fm: **Settings → API Keys** (bottom of the page) -2. Set the environment variable: +1. Find your API key on the Transistor dashboard **Account page → API + Access** (https://dashboard.transistor.fm/account) and export it: ```bash -export TRANSISTOR_API_KEY="your-transistor-api-key" +export TRANSISTOR_API_KEY="" ``` -`--help` and `--dry-run` work without credentials. +2. Verify the key (GET /v1 — the authorization probe; there is no + /v1/user route): + +```bash +transistor user # name and time zone +transistor user --json | jq '{id, name, time_zone}' +``` + +A key carries the dashboard role of its user (owner / admin / team member) +per podcast. `--help` and `--dry-run` work without credentials. Requests are +rate-limited to 10 per 10 seconds; the CLI dies with a clear 429 message +instead of hammering. ## Essential Commands -### user — Current user info +### user — authorization probe ```bash -transistor-cli user # email and timezone -transistor-cli user --json # machine-readable +transistor user # who does this key belong to? +transistor user --json ``` -### shows — List all shows +### shows / show — browse podcasts ```bash -transistor-cli shows # all shows with episode/subscriber counts -transistor-cli shows --json # machine-readable with IDs +transistor shows # newest-updated first +transistor shows --private --json # private podcasts only +transistor show --id # full attributes incl. feed_url +transistor shows --page 1 --per 20 --json ``` -Shows title, episode count, subscriber count, and ID. +Show ids and slugs are interchangeable on most show-scoped routes. Show +resources carry no counts fields — count via `episodes --show ... --json`, +then `meta.totalCount`. -### episodes — List episodes +### episodes / episode — browse episodes ```bash -transistor-cli episodes # last 20 episodes across all shows -transistor-cli episodes --show 12345 # filter by show ID -transistor-cli episodes --limit 50 # more results -transistor-cli episodes --show 12345 --json # machine-readable +transistor episodes # newest first, all shows +transistor episodes --show --status draft # drafts for one show +transistor episodes --show --per 50 --page 1 --json +transistor episode --id --include show # compound doc + parent show ``` -Get show IDs from `transistor-cli shows --json`. Shows season/episode numbers, title, status, duration, and publish date. +`--include show` adds `included[]` (the JSON:API compound document); every +episode item in `--json` output already carries `show_id` resolved from +relationships. `--limit` works as an alias for `--per` for old scripts. -### analytics — Download and play analytics +### episode-create / episode-update — drafts and metadata ```bash -transistor-cli analytics # totals across all shows -transistor-cli analytics --show 12345 # filter by show ID -transistor-cli analytics --json # machine-readable with full totals +transistor episode-create --show --title "Ep 12: Roasting" \ + --season 2 --number 4 --audio-url "https://uploads.example.com/ep12.mp3" +transistor episode-update --id --title "New title" +transistor episode-update --id --audio-url "" # attach audio ``` -Shows total downloads and plays (when available). +`episode-create` ALWAYS produces a draft (`status: "draft"`, +`published_at: null`) — it never publishes. `episode-update` changes +metadata or attaches audio and never touches publishing state. -## Global Flags - -All flags work in any position: +### episode-publish — the lifecycle switch ```bash -transistor-cli --json shows # flag before subcommand -transistor-cli shows --json # flag after subcommand -transistor-cli --dry-run episodes # preview (no API call) -transistor-cli --force episodes --limit 100 # override safety checks -transistor-cli --quiet shows # suppress non-essential output -transistor-cli --verbose episodes # detailed logging +transistor episode-publish --id # publish now +transistor episode-publish --id --status scheduled \ + --published-at "2026-09-03 09:00:00" # schedule +transistor episode-publish --id --status draft # unpublish ``` -Extra flag vs other CLIs: `--force` overrides internal safety checks (e.g. large limits). +Hits `PATCH /v1/episodes//publish` with +`episode[status]=draft|scheduled|published` — the documented dedicated +endpoint. The CLI refuses to publish an episode whose `media_url` is still +empty (an unplayable item would hit every subscriber's feed); pass +`--force` to override. + +### authorize-upload — local audio (max 5GB) + +```bash +transistor authorize-upload --filename ep12.mp3 # plan only +transistor authorize-upload --filename ep12.mp3 --file ./ep12.mp3 +``` + +Returns (and, with `--file`, performs) the signed PUT; the printed +`audio_url` is what you attach with `episode-create`/`episode-update`. The +signed URL expires (~600 s in the docs' example). + +### analytics / episode-analytics — downloads per day + +```bash +transistor analytics --show # last 14 days +transistor analytics --show \ + --start-date 01-08-2026 --end-date 28-08-2026 --json +transistor episode-analytics --id --json +``` + +Dates are dd-mm-yyyy and must come in pairs. Analytics attributes are +per-day `downloads[]` arrays, not totals; the CLI sums them into +`downloads_total` and keeps the raw array. + +### subscribers — private podcast audience + +```bash +transistor subscribers --show --json +transistor subscriber-create --show --email "listener@example.com" +transistor subscriber-batch --show --email "a@example.com" --email "b@example.com" +transistor subscriber-delete --show --email "a@example.com" # or --id +``` + +### webhooks — push instead of poll + +```bash +transistor webhooks --show +transistor webhook-create --show --event episode_published \ + --url "https://example.com/hooks/transistor" +transistor webhook-delete --id +``` + +Events: `episode_created`, `episode_published`, `subscriber_created`, +`subscriber_deleted`. Cap: 50 per account. With a 10 req / 10 s limit, +webhooks beat polling for freshness. + +## Global flags + +```bash +transistor --json shows # flags work in any position +transistor --dry-run episodes --show # request plan, zero network +transistor --force episode-publish --id # skip the audio guard +transistor --quiet shows # suppress non-essential output +transistor --verbose episodes # detailed stderr logging +``` + +`--dry-run` emits `{"dry_run": true, "method", "path", "params"}` (write +commands add the exact `body` that would be sent — bracket keys and all), +so you can verify a plan before touching the API. `--help` and `--dry-run` +never require credentials. + +## Pipeline recipes + +### Create, attach audio, publish (the core workflow) + +```bash +export TRANSISTOR_API_KEY="" +SHOW=$(transistor shows --json | jq -r '.shows[0].id') # string id +AUDIO=$(transistor authorize-upload --filename ep12.mp3 --file ./ep12.mp3 --json | jq -r '.audio_url') +EP=$(transistor episode-create --show "$SHOW" --title "Ep 12" \ + --audio-url "$AUDIO" --json | jq -r '.id') # draft id +transistor episode-publish --id "$EP" # dedicated endpoint +transistor episode --id "$EP" --json | jq '{status, media_url, published_at}' +``` + +Each stage's output feeds the next: `shows` → string `id`, +`authorize-upload` → string `audio_url`, `episode-create` → string draft +`id`, `episode-publish` → final `status`. Stage 2 is skippable when the +audio already has a public URL (pass it straight to `episode-create`). + +### Draft triage: what is not out yet? + +```bash +transistor episodes --show --status draft --json \ + | jq -r '.episodes[] | [.id, .title, (if .media_url == "" then "no-audio" else "ready" end)] | @tsv' +# publish the ready ones (rate limit: 10 req / 10 s — add sleep 1 between calls) +``` + +### Weekly downloads report + +```bash +transistor shows --json | jq -r '.shows[].id' | while read -r S; do + transistor analytics --show "$S" --json \ + | jq -r --arg id "$S" '[$id, (.downloads_total|tostring)] | @tsv' + sleep 1 +done +``` + +## JSON and jq + +`--json` keys are stable snake_case wrappers around the JSON:API document: +`shows`/`episodes`/`subscribers`/`webhooks` (arrays with `meta` attached), +flat objects for single resources, `dry_run`/`method`/`path`/`params`/`body` +for plans. Attributes keep Transistor's own names — `status`, `season`, +`number`, `duration` (seconds), `media_url`, `share_url`, `published_at`, +`feed_url` — so jq selectors transfer directly to raw `curl` against +api.transistor.fm. Collection pagination surfaces as +`meta.currentPage`/`meta.totalPages`/`meta.totalCount`. Example: +`transistor episodes --show --json | jq -r '.episodes[] | +[.id, .title, .status] | @tsv'`. For compound documents the CLI resolves +relationships (`show_id`) and prints `included` show summaries in human +mode; with raw curl, match `included[]` by `type` and +`relationships.show.data.id`. ## Known Gotchas -- **API key from Settings → API Keys** — Not from the user profile or account page. Navigate to Settings → API Keys at the bottom of the Transistor.fm dashboard. -- **JSON:API format** — Transistor uses the JSON:API spec. All responses are nested under a `data` key, and attributes are under `data[].attributes`. The CLI unwraps these for display, but raw JSON output shows the full JSON:API structure. -- **Show IDs are required for filtered queries** — Use `transistor-cli shows --json` to get show IDs first, then pass them to `--show` for episodes and analytics. -- **Pagination uses cursor-based pagination** — The `--limit` flag controls the page size. Default is 20 for episodes. The API returns `meta` with pagination info. -- **Analytics are totals only** — The analytics endpoint returns aggregate totals (downloads, plays). Per-episode analytics are not available via this CLI. -- **Transistor API is append-only via this CLI** — The CLI implements GET endpoints for reading. Creating/updating shows or episodes is not covered here. -- **Rate limits** — Transistor.fm has rate limits. The CLI does not auto-retry on 429 responses. +- **Publishing is a separate endpoint, never a side effect** — POST + /episodes and PATCH /episodes/:id cannot change `status`. If an episode + stays draft, the missing step is `PATCH /v1/episodes//publish` with + `episode[status]=published`. (The pre-thickening CLI had no publish path + at all.) +- **The user probe is `GET /v1`** — `/v1/user` and `/v1/authorization` are + 404s, and the user resource has no email attribute (name and time_zone + only). +- **Pagination is `pagination[page]` + `pagination[per]`** (defaults 0 and + 10; docs' examples request page 1). `pagination[limit]` and + `page[number]` are silently ignored — loops using them re-read page 1 + forever. Loop while `meta.currentPage < meta.totalPages`. +- **Show resources carry no counts** — derive episode/subscriber counts + from filtered listings' `meta.totalCount`. +- **Analytics are per-day arrays, not totals** — sum `attributes.downloads` + (the CLI provides `downloads_total`); date bounds are dd-mm-yyyy and + come in pairs; do not parse the row date format (docs' examples are + inconsistent between sections). +- **Show creation is dashboard-only** — there is no POST /v1/shows; + `show-update` is the only show write. +- **Rate limit 10 req / 10 s** — a 429 blocks access for 10 seconds. No + retry headers; back off, batch subscriber imports, cache responses, and + use webhooks for freshness. Transistor explicitly forbids using the API + as a website back end (parse the RSS feed for that). +- **`episode[published_at]` uses the show's time zone** (a show attribute), + not UTC; scheduling and backdating both ride the publish endpoint. +- **Audio processing is asynchronous** — watch `audio_processing` / + `processing_failure` after attaching audio; publishing an unprocessed or + failed file pushes silence to subscribers. +- **Signed upload URLs expire** (~600 s in the docs' example): authorize, + PUT with the returned `content_type`, attach promptly. +- **Error bodies are not formally specified** — the CLI handles JSON:API + `errors[]` arrays and bare `{"message": ...}` objects, flattening either + to one stderr line; 401 (bad key), 403 (role), 404 (bad id/route), 429 + (rate limit) have distinct hints. -## References +## When to use -- [scripts/transistor-cli](scripts/transistor-cli) — The CLI binary. Built following the cli-builder patterns: `--json`, `--dry-run`, `--force`, `--quiet`, `--verbose`, dual-output via `emit()`, lazy auth. -- [Transistor API Docs](https://developers.transistor.fm/) — Official API reference. -- [Transistor.fm Dashboard](https://dashboard.transistor.fm/) — Settings → API Keys for your API key. +Use this skill for anything that reads or drives a Transistor.fm account +through its API: verifying API access, browsing shows/episodes (including +drafts and compound documents), running the episode lifecycle +(create → attach audio → publish/schedule/unpublish), pulling download +analytics windows, importing or revoking private-podcast subscribers, and +registering webhooks. + +## When not to use + +Do not use this skill for other podcast hosts (Buzzsprout, Libsyn, +Megaphone, Spotify for Creators — use their own APIs/tooling); for audio +production or editing (ffmpeg and DAW territory); for generic RSS feed +parsing or website rendering (parse the feed XML directly — Transistor says +the API is not a back-end data source); for creating new shows (the API +cannot — the dashboard does); or for platform-level distribution questions +(Apple/Spotify submission is a dashboard and RSS concern). + +## Reference Files + +| File | Use it for | +| ---- | ---------- | +| [references/auth-and-basics.md](references/auth-and-basics.md) | x-api-key auth, key location and role scoping, the JSON:API envelope (data/attributes/relationships/included[]) with jq patterns, pagination params, error surfaces | +| [references/endpoint-catalog.md](references/endpoint-catalog.md) | Every route's method, path, and parameters (shows, episodes, publish, uploads, analytics, subscribers, webhooks) plus routes that do not exist | +| [references/episode-publish-lifecycle.md](references/episode-publish-lifecycle.md) | The draft/scheduled/published state machine, the exact publish request/response shapes, create→audio→publish recipes in CLI and curl, authorize-upload detour | +| [references/gotchas-and-recipes.md](references/gotchas-and-recipes.md) | Symptom → cause → fix field guide (404 user route, silent pagination, 429 storms...) and multi-step workflows (bulk scheduling, analytics reports, subscriber import, webhooks) | + +## Available Scripts and Prerequisites + +- `scripts/transistor` — the bundled Python CLI (`--json`, `--dry-run`, + `--force`, `--quiet`, `--verbose`, `--help` everywhere). Imports only the + standard library and `requests`; sends write bodies exactly as documented + (bracket-key form fields). +- `scripts/test_transistor.py` — offline test suite (pytest + unittest + compatible); all HTTP mocked with canned JSON:API documents, zero network + egress, no live-call cases (Transistor is a keyed API). +- Requires Python 3.8+, `requests`, and `TRANSISTOR_API_KEY` for live + commands (Account page → API Access). No service is started by this skill. diff --git a/transistor/evals/evals.json b/transistor/evals/evals.json new file mode 100644 index 0000000..0e3abfe --- /dev/null +++ b/transistor/evals/evals.json @@ -0,0 +1,94 @@ +{ + "schema_version": 1, + "skill_name": "transistor", + "evals": [ + { + "id": "browse-shows-and-episodes-json", + "prompt": "List my Transistor shows and then the latest episodes of the first one, as JSON I can pipe to jq.", + "expected_output": "Export TRANSISTOR_API_KEY (Dashboard -> Account -> API Access), run transistor shows --json to get show ids, then transistor episodes --show --json. Collection output is {shows|episodes: [...], meta: {currentPage, totalPages, totalCount}}; page with --page/--per (pagination[page]/pagination[per] on the wire, API default 10 per page) and loop while meta.currentPage < meta.totalPages.", + "assertions": [ + "exports TRANSISTOR_API_KEY and runs transistor shows --json first", + "extracts the show id from the shows output before filtering episodes", + "runs transistor episodes with --show and --json", + "pages with pagination[page]/pagination[per] and meta.currentPage/totalPages, never pagination[limit] or page[number]" + ] + }, + { + "id": "episode-create-then-publish-pipeline", + "prompt": "I have a finished MP3 at https://cdn.example.com/ep12.mp3. Publish it as episode 12 of my Transistor show, season 2, with the title 'Roasting Coffee'.", + "expected_output": "Create the draft: transistor episode-create --show --title 'Roasting Coffee' --season 2 --number 12 --audio-url https://cdn.example.com/ep12.mp3 --json and take .id (creation always yields status draft, published_at null). Then publish on the dedicated endpoint: transistor episode-publish --id , which sends PATCH /v1/episodes//publish with episode[status]=published. Confirm with transistor episode --id --json reading .status and .media_url. Updating episode metadata never publishes; only the /publish endpoint changes status.", + "assertions": [ + "creates the episode as a draft with transistor episode-create including --show, --title, and --audio-url", + "publishes via transistor episode-publish on the dedicated PATCH /v1/episodes/:id/publish endpoint with episode[status]=published", + "does not claim episode-create or episode-update can publish the episode", + "reads the publish result from data.attributes.status / the JSON output .status" + ] + }, + { + "id": "publish-rejected-because-stays-draft", + "prompt": "I keep updating my Transistor episode with PATCH /v1/episodes/:id but it stays in draft and never shows up in the RSS feed. What am I doing wrong?", + "expected_output": "Nothing is broken: metadata updates cannot change publishing state. POST /episodes and PATCH /episodes/:id never publish (the docs say publishing is a separate endpoint). Send PATCH /v1/episodes//publish with episode[status]=published (draft/scheduled/published are the only status values; episode[published_at] in the show's time zone schedules or backdates). The bundled CLI path is transistor episode-publish --id .", + "assertions": [ + "explains that PATCH /episodes/:id can never change publishing state", + "uses the dedicated /publish endpoint with episode[status]=published", + "mentions scheduled/draft states are set on the same publish endpoint", + "does not suggest re-sending episode[audio_url] or metadata fields as the fix" + ] + }, + { + "id": "authorize-upload-attach-audio-workflow", + "prompt": "My episode audio is a local file ep12.mp3 on disk and I don't have a public URL. Walk me through getting it into Transistor and published.", + "expected_output": "Authorize an upload: transistor authorize-upload --filename ep12.mp3 --file ./ep12.mp3 (the CLI GETs /v1/episodes/authorize_upload?filename=..., PUTs the bytes to the signed upload_url with the returned content_type; the URL expires ~600s, max 5GB). Take .audio_url from the output, attach it: transistor episode-update --id --audio-url (or pass it at episode-create), then publish with transistor episode-publish --id . Accepted formats include .mp3, .m4a, .wav.", + "assertions": [ + "starts with transistor authorize-upload and uses the returned audio_url", + "attaches audio via episode-create or episode-update with --audio-url", + "publishes only after attaching audio, via the publish endpoint", + "does not try to PUT the file to api.transistor.fm directly" + ] + }, + { + "id": "rate-limit-429-webhook-guidance", + "prompt": "My script that checks Transistor for new published episodes every few seconds just started failing with 429 errors. Fix it.", + "expected_output": "Transistor rate-limits the API to 10 requests per 10 seconds; a 429 blocks access for 10 seconds and no retry headers are documented. Polling every few seconds will keep tripping it. Fix: slow the loop (sleep between calls), cache responses, and better, register a webhook so Transistor pushes events: transistor webhook-create --show --event episode_published --url https://example.com/hooks (events: episode_created, episode_published, subscriber_created, subscriber_deleted; max 50 per account). Transistor also says the API is not meant as a website back end - parse the RSS feed for display use.", + "assertions": [ + "states the 10 requests per 10 seconds limit and the 10 second 429 block", + "replaces tight polling with a webhook (episode_published) or cached/less frequent calls", + "does not invent retry-after headers or exponential backoff promises from the docs", + "mentions the 50-webhook per account cap or the event names" + ] + }, + { + "id": "private-podcast-subscriber-batch-import", + "prompt": "Import a mailing list of 40 people into my private Transistor podcast without spamming each one manually.", + "expected_output": "Use the batch endpoint: transistor subscriber-batch --show --email --email ... (POST /v1/subscribers/batch with show_id and emails[]), optionally --skip-welcome-email. Verify with transistor subscribers --show --json reading meta.totalCount. Revoke access later with transistor subscriber-delete --show --email or --id. Each subscriber gets a personal feed_url/subscribe_url - never share one person's feed URL.", + "assertions": [ + "uses subscriber-batch (POST /v1/subscribers/batch) instead of 40 single calls", + "lists subscribers and reads meta.totalCount to verify the import", + "revokes with subscriber-delete by email or id when needed", + "does not share or reuse one subscriber's personal feed_url" + ] + }, + { + "id": "create-transistor-show-not-api", + "prompt": "Use the Transistor API to create a brand new podcast called 'Night Shift' on my account.", + "expected_output": "This must not trigger the transistor skill's CLI for creation: show creation is not available via the Transistor API at all (no POST /v1/shows exists; Transistor's support docs say new shows need to be created in the web app). Create the show in the dashboard first; afterwards the skill can manage it (show-update for metadata, episodes, subscribers, analytics).", + "assertions": [ + "must not attempt to create a show through the API", + "states that show creation is dashboard-only because POST /v1/shows does not exist", + "directs the user to create the show in the Transistor dashboard first", + "still offers post-creation management (episode lifecycle, metadata updates) once the show exists" + ] + }, + { + "id": "audio-editing-not-transistor", + "prompt": "Cut the first 30 seconds of silence off my podcast MP3 and normalize the loudness.", + "expected_output": "This must not trigger the transistor skill: audio editing/transcoding is outside a hosting-account API skill (ffmpeg or a DAW does the edit), and Transistor's API manages episodes, subscribers, and analytics - not audio files. After the edited file is hosted somewhere reachable (or via authorize-upload), the Transistor skill can attach and publish it.", + "assertions": [ + "must not trigger transistor for audio editing", + "routes the edit to ffmpeg or a DAW", + "does not invent API endpoints for editing or processing audio", + "may mention re-attaching the edited file afterward as the follow-up step" + ] + } + ] +} diff --git a/transistor/references/auth-and-basics.md b/transistor/references/auth-and-basics.md new file mode 100644 index 0000000..4daa842 --- /dev/null +++ b/transistor/references/auth-and-basics.md @@ -0,0 +1,165 @@ +# Transistor API: Authentication, JSON:API Envelope, and Request Basics + +Everything in this file is from the official API reference +(developers.transistor.fm) and Transistor's own support pages, verified +live at authoring time. Transistor.fm's public API is v1 and speaks +JSON:API on responses; there is exactly one authentication mode. + +## Authentication + +- Every request carries an HTTP header `x-api-key` whose value is the API + key. There is no OAuth, no bearer token, and no signing on the REST API. +- Keys are created, viewed, and reset in the Transistor Dashboard's Account + page, in the section marked **API Access** + (https://dashboard.transistor.fm/account). Transistor's support article + "Does Transistor have an API?" (updated 2026-07) names exactly this + location; the bundled CLI prints it on every auth error. +- A key grants whatever the associated dashboard user can see: access to + podcasts and episodes follows the user's podcast role — **owner**, + **admin**, or **regular team member**. There are no narrower per-key + scopes: a leaked key is as powerful as its user. Treat it like a + password; reset it from the same Account page if it leaks. +- The authorization probe is `GET /v1` — it returns the authenticated + `user` resource and nothing else. There is **no `/v1/user` and no + `/v1/authorization` route**; older tutorials that call `/v1/user` get a + 404. The `user` resource has `name`, `time_zone`, `image_url`, and + timestamps — **it has no email attribute**. + +```sh +curl https://api.transistor.fm/v1 -H "x-api-key: " +``` + +## Rate limits + +- **10 requests per 10 seconds.** Exceeding the limit returns HTTP `429` + and access is blocked for 10 seconds; after that requests flow again. +- No rate-limit headers (`Retry-After` etc.) are documented — don't parse + for them; just back off on 429. +- Transistor explicitly states the API is not meant to be the main data + source for a website or app back end; pull data once, cache it, and parse + the public RSS feed XML when you would otherwise hammer the API. For + push-style updates, webhooks (see the endpoint catalog) exist for + `episode_created`, `episode_published`, `subscriber_created`, and + `subscriber_deleted`. + +## The JSON:API envelope + +Responses are JSON:API documents. Learn four keys and every endpoint is +readable: + +| Key | Shape | Meaning | +| --- | --- | --- | +| `data` | object (single resource) or array (collections) | The primary resource(s) of the response | +| `attributes` | object inside a resource | The resource's fields (title, status, media_url, ...) | +| `relationships` | object of `{"": {"data": {"id", "type"}}}` | Links to related resources by id and type | +| `included` | array (only when requested with `include[]`) | The full related resources — a "compound document" | + +- Resource `type` values: `user`, `show`, `episode`, `subscriber`, + `show_analytics`, `episodes_analytics`, `episode_analytics`, + `audio_upload`, `webhook`. +- Single-resource responses wrap one object: `{"data": {"id": ..., + "type": "episode", "attributes": {...}, "relationships": {...}}}`. +- Collection responses wrap an array plus pagination under `meta`: + `{"data": [...], "meta": {"currentPage", "totalPages", "totalCount"}}`. +- Ids are **strings** even when numeric ("3056098"); analytics ids may be + slugs ("the-caffeine-show"). Keep ids as strings end to end. +- `included[]` appears only when you ask for it. `GET + /v1/episodes/3056098?include[]=show` returns the episode plus the parent + show in `included`, matched via `data.relationships.show.data.id`. + +### jq patterns for the envelope + +```sh +# Single resource: unwrap data.attributes +curl -s https://api.transistor.fm/v1/episodes/ -H "x-api-key: " \ + | jq '.data.attributes | {title, status, published_at}' + +# Collection: titles plus ids, one per line +curl -s 'https://api.transistor.fm/v1/episodes?show_id=' -H "x-api-key: " \ + | jq -r '.data[] | [.id, .attributes.title, .attributes.status] | @tsv' + +# Compound document: pull the parent show's title out of included[] +curl -s 'https://api.transistor.fm/v1/episodes/?include[]=show' -H "x-api-key: " \ + | jq --arg id "$(curl -s ... | jq -r '.data.relationships.show.data.id')" \ + '.included[] | select(.type == "show" and .id == $id) | .attributes.title' + +# Simpler: match included[] by type when only one show was included +... | jq '.included[] | select(.type == "show") | .attributes.title' + +# Pagination loop values live in meta +... | jq '{page: .meta.currentPage, last: .meta.totalPages, total: .meta.totalCount}' +``` + +The bundled CLI does this unwrapping for `--json` output: collections come +back as `{"episodes": [...], "meta": {...}}` with each item flattened to the +fields agents actually need (including `show_id` from relationships), and +single resources as one flat object. + +## Pagination + +- Page-based, two parameters: `pagination[page]` (documented default `0`; + the doc examples explicitly request page `1`) and `pagination[per]` + (default `10`). +- Every collection returns `meta.currentPage`, `meta.totalPages`, and + `meta.totalCount`. Loop while `currentPage < totalPages`, incrementing + the page — do not assume the first page is `0` or `1`, read `meta`. +- There is no cursor, no `page[number]`/`page[size]` JSON:API-style + spelling, and no `pagination[limit]` — unknown params are silently + ignored, which is exactly how scripts that "paginate" with + `pagination[limit]` re-read the first page forever. + +## Sparse fieldsets and compound documents + +Any endpoint accepts JSON:API's standard extras: + +- Sparse fieldsets: `fields[episode][]=title&fields[episode][]=media_url` + returns only those attributes (smaller payloads, faster loops). +- Include related resources: `include[]=show` on an episode, `include[]=show` + on analytics, `include[]=episode` on episode analytics. Combine both: + `include[]=show&fields[show][]=title&fields[show][]=feed_url`. + +## Request bodies: form-encoded bracket keys (documented), JSON accepted + +- The reference intro says endpoints accept **JSON or form-encoded** request + bodies. Every documented mutation example uses form-encoded bracket keys: + `episode[show_id]=...`, `episode[title]=...`, `show[title]=...`, + `subscriber[email]=...`, `episode[status]=published`. +- The docs publish no JSON-body equivalent examples, so the bracket-key + shapes above are the contract to copy. The bundled CLI sends form-encoded + bodies byte-compatible with the documented curl examples. +- Required-vs-optional matters: `episode[show_id]` is the only required + field on episode creation; `episode[status]` is required on the publish + endpoint; `show_id` is required on subscribers/webhooks listings. + +## Error surfaces + +Responses use standard HTTP codes. The reference does not document a formal +error schema, so program defensively: + +- `401` — key missing/invalid → check `x-api-key` and the Account page. +- `403` — key valid, role insufficient (owner/admin needed for some + operations on a shared podcast). +- `404` — id/slug not found (and remember: `/v1/user` is not a route). +- `422` — validation errors (e.g. bad `episode[status]` value). +- `429` — rate limit (10 requests / 10 s window). + +Error bodies seen in practice are JSON; the bundled CLI accepts either a +JSON:API-style `errors[]` array or a bare `{"message": ...}` object and +flattens whichever it gets into one stderr line. + +## Sources + +- https://developers.transistor.fm/ (introduction, JSON:API conformance, + authentication, rate limits, sparse fieldsets/include[] sections; all + endpoint examples) — fetched live 2026-08-29 (HTTP 200) +- https://developers.transistor.fm/#authentication (header name, Account + Area key management, owner/admin/team-member access levels) +- https://developers.transistor.fm/#ratelimits (10 requests / 10 s, 429 + + 10 s block, caching/RSS guidance) +- https://developers.transistor.fm/#get-v1 (GET /v1 user resource example) +- https://developers.transistor.fm/#resources (type list; User resource + fields — no email) +- https://support.transistor.fm/en/article/does-transistor-have-an-api-1b24sjo/ + (API key location: Account page → API Access) — fetched live 2026-08-29 +- https://support.transistor.fm/en/article/what-automations-are-possible-with-transistor-bi27am/ + (supported automations, show-creation limitation) — fetched live 2026-08-29 diff --git a/transistor/references/endpoint-catalog.md b/transistor/references/endpoint-catalog.md new file mode 100644 index 0000000..7d4311e --- /dev/null +++ b/transistor/references/endpoint-catalog.md @@ -0,0 +1,137 @@ +# Transistor API Endpoint Catalog + +Method-by-method reference for Transistor API v1. Every row matches the +official reference at developers.transistor.fm (fetched live 2026-08-29). +Envelope conventions (`data`/`attributes`/`relationships`/`included[]`), +pagination (`pagination[page]`, `pagination[per]`, `meta.currentPage`, +`meta.totalPages`, `meta.totalCount`), and sparse-fieldset/include[] params +apply everywhere — see [auth-and-basics.md](auth-and-basics.md). + +## Root + +| Method | Path | Purpose / params | +| --- | --- | --- | +| GET | `/v1` | Authenticated user probe. No params. Returns one `user` resource (`name`, `time_zone`, `image_url`, timestamps; **no email**). Use as the "does my key work" check. | + +## Shows + +| Method | Path | Purpose / params | +| --- | --- | --- | +| GET | `/v1/shows` | List shows, descending by updated date. Params: `private` (boolean), `query` (title search), `pagination[page]` (default 0), `pagination[per]` (default 10). | +| GET | `/v1/shows/:id` | One show. `:id` accepts the show ID **or slug**. | +| PATCH | `/v1/shows/:id` | Update any of: `show[author]`, `show[category]`, `show[copyright]`, `show[description]`, `show[explicit]`, `show[image_url]`, `show[keywords]`, `show[language]`, `show[owner_email]`, `show[secondary_category]`, `show[show_type]` (`episodic`/`serial`), `show[title]`, `show[time_zone]`, `show[website]`. Category/language/time-zone values are large closed enums — fetch the dashboard values or reuse what GET returns. | + +- Show attributes include `title`, `slug`, `description`, `author`, + `private`, `show_type`, `feed_url`, `time_zone`, `category`, + `secondary_category`, `language`, `owner_email`, `website`, `explicit`, + `keywords`, plus per-directory URLs (`apple_podcasts`, `spotify`, + `overcast`, ...). +- **There are no `episodes_count` or `subscribers_count` attributes** — + count episodes by listing them (`show_id` filter + `meta.totalCount`). +- **No POST /v1/shows exists**: show creation is not available via the API + (Transistor support, updated 2026-08: "Show creation is not currently + available via our API. New shows need to be created in the web app"). + +## Episodes + +| Method | Path | Purpose / params | +| --- | --- | --- | +| GET | `/v1/episodes` | List episodes, ordered by published date. Params: `show_id` (ID or slug), `query`, `status` (`draft`/`scheduled`/`published`), `order` (`asc`/`desc`, default `desc`), `pagination[page]`, `pagination[per]`. | +| GET | `/v1/episodes/:id` | One episode. `include[]=show` supported. `:id` is the Episode ID (slug support not documented here). | +| POST | `/v1/episodes` | Create an episode. Required: `episode[show_id]`. Optional: `episode[title]`, `episode[summary]`, `episode[description]` (HTML allowed), `episode[audio_url]`, `episode[author]`, `episode[season]`, `episode[number]`, `episode[number]` + `episode[increment_number]` (auto next number in season), `episode[type]` (`full`/`trailer`/`bonus`), `episode[image_url]`, `episode[keywords]`, `episode[explicit]`, `episode[alternate_url]`, `episode[video_url]` (video plan), `episode[youtube_url]`, `episode[transcript_text]`, `episode[email_notifications]`. **Always creates a DRAFT** (`status: "draft"`, `published_at: null`) — publishing is a separate endpoint. | +| PATCH | `/v1/episodes/:id` | Update metadata/audio. Accepts the same `episode[...]` fields as create (except `show_id`). **Never changes publishing state** — the docs say so explicitly ("publishing or unpublishing an episode involves a separate endpoint"). | +| PATCH | `/v1/episodes/:id/publish` | Publish / schedule / unpublish. Required: `episode[status]` ∈ `draft`, `scheduled`, `published`. Optional: `episode[published_at]` (show's time zone) to publish in the past, schedule for the future, or backdate. See the publish-lifecycle file for the full recipe. | +| GET | `/v1/episodes/authorize_upload` | Authorize a local audio/video upload (max **5GB**). Required: `filename`. Returns an `audio_upload` resource: signed `upload_url` (HTTP PUT the bytes, header `Content-Type: `), `content_type` (e.g. `audio/mpeg`), `expires_in` (example: 600 s), and the post-upload `audio_url` to attach via create/update. Skip entirely if you already have a public URL. | + +- Episode attributes: `title`, `status`, `season`, `number`, + `published_at`, `duration` (seconds), `duration_in_mmss`, `media_url` + (trackable MP3), `share_url`, `alternate_url`, `slug`, `summary`, + `description` (+ `formatted_*` variants), `author`, `explicit`, + `keywords`, `image_url`, `video_url`, `youtube_url`, `embed_html`(+dark), + `transcript_url`, `transcripts[]`, `audio_processing`, + `video_processing`, `processing_failure`, `hls_manifest_url`, `type`. +- `audio_processing: true` means Transistor is still processing an upload; + `processing_failure` carries the error string when processing failed. +- Vendor-documented upload formats (mcp.transistor.fm): .mp3, .m4a, .wav, + .aif, .aiff, .aifc, .mp4, .mov. + +## Analytics + +| Method | Path | Purpose / params | +| --- | --- | --- | +| GET | `/v1/analytics/:id` | Show downloads per day. `:id` = Show ID or slug. Default window: last 14 days. | +| GET | `/v1/analytics/:id/episodes` | Per-episode download series for a whole show. `:id` = Show ID or slug. Default window: last 7 days. | +| GET | `/v1/analytics/episodes/:id` | Single episode downloads per day. `:id` = Episode ID or slug. Default window: last 14 days. | + +- Date range params on all three: `start_date` and `end_date`, documented + as **dd-mm-yyyy**; if you supply one you must supply both. +- Analytics resources return a per-day `downloads` **array** + (`[{"date": ..., "downloads": N}, ...]`) — not a totals object. Sum the + array yourself (or let the bundled CLI do it: `downloads_total`). +- Doc-format quirk: example responses echo download-row dates + inconsistently (`15-08-2026` in show analytics vs `08-15-2026` in + episodes analytics). Never parse the row date format; aggregate the + numeric `downloads` values keyed by position in your requested window. +- There is no `/v1/shows/:id/analytics` route — analytics paths live under + `/v1/analytics/...`. Downloads are the only analytics exposed by the API + (no countries/apps/video stats). + +## Subscribers (private podcasts) + +| Method | Path | Purpose / params | +| --- | --- | --- | +| GET | `/v1/subscribers` | List a private show's subscribers. Required: `show_id`. Optional: `query`, `activated` (boolean), pagination. | +| GET | `/v1/subscribers/:id` | One subscriber with `email`, `status` (`default`/`subscribed`/`unsubscribed`), per-subscriber `feed_url` and `subscribe_url`, `has_downloads`, `last_notified_at`. | +| POST | `/v1/subscribers` | Add one subscriber. Required: `show_id`, `email`. Optional: `skip_welcome_email` (default false). | +| POST | `/v1/subscribers/batch` | Add many. Required: `show_id`, `emails[]` (repeat the key). Optional: `skip_welcome_email`. Response: array of subscriber resources. | +| PATCH | `/v1/subscribers/:id` | Update. Required: `subscriber[email]`. | +| DELETE | `/v1/subscribers` | Revoke by address. Required: `show_id`, `email`. | +| DELETE | `/v1/subscribers/:id` | Revoke by subscriber ID. | + +Subscriber routes are top-level (`/v1/subscribers...`), not nested under +`/v1/shows/:id/`. Each subscriber gets a unique personal feed URL — that is +how Transistor tracks private-listener downloads. + +## Webhooks + +| Method | Path | Purpose / params | +| --- | --- | --- | +| GET | `/v1/webhooks` | List a show's webhooks. Required: `show_id`. | +| POST | `/v1/webhooks` | Subscribe. Required: `event_name`, `show_id`, `url`. `event_name` ∈ `episode_created`, `episode_published`, `subscriber_created`, `subscriber_deleted`. | +| DELETE | `/v1/webhooks/:id` | Unsubscribe by webhook ID. | + +Maximum **50 webhooks per user account** (Webhook resource doc). Webhooks +are the sanctioned alternative to polling given the 10 req / 10 s rate +limit: register `episode_published` and react, instead of re-reading +episode lists. + +## Routes that do NOT exist (common wrong guesses) + +- `GET /v1/user`, `GET /v1/authorization` — the user probe is `GET /v1`. +- `POST /v1/shows` — show creation is dashboard-only. +- `/v1/shows/:id/analytics`, `/v1/episodes/:id/analytics` (nested) — + analytics lives at `/v1/analytics/...` paths. +- `/v1/shows/:id/subscribers` — subscribers is top-level with `show_id`. +- Any `pagination[limit]`-style param — per-page is `pagination[per]`. + +## Sources + +- https://developers.transistor.fm/ — fetched live 2026-08-29 (HTTP 200); + all endpoint tables above correspond to the reference sections: + #get-v1, #get-v1-analytics-id, #get-v1-analytics-id-episodes, + #get-v1-analytics-episodes-id, #get-v1-shows, #get-v1-shows-id, + #patch-v1-shows-id, #get-v1-episodes, #get-v1-episodes-id, + #get-v1-episodes-authorize_upload, #post-v1-episodes, + #patch-v1-episodes-id, #patch-v1-episodes-id-publish, + #get-v1-subscribers, #get-v1-subscribers-id, #post-v1-subscribers, + #post-v1-subscribers-batch, #patch-v1-subscribers-id, + #delete-v1-subscribers, #delete-v1-subscribers-id, #get-v1-webhooks, + #post-v1-webhooks, #delete-v1-webhooks-id, #Show, #Episode, + #Subscriber, #ShowAnalytics, #EpisodesAnalytics, #EpisodeAnalytics, + #AudioUpload, #Webhook +- https://support.transistor.fm/en/article/what-automations-are-possible-with-transistor-bi27am/ + (show-creation limitation, supported automations) — fetched live 2026-08-29 +- https://mcp.transistor.fm/ (accepted upload formats; draft-then-publish + semantics as implemented by Transistor's own tooling) — fetched live 2026-08-29 +- https://pkg.go.dev/gitlab.com/flimzy/transistor (independent SDK route + inventory corroborating the catalog) — fetched live 2026-08-29 diff --git a/transistor/references/episode-publish-lifecycle.md b/transistor/references/episode-publish-lifecycle.md new file mode 100644 index 0000000..79d10f9 --- /dev/null +++ b/transistor/references/episode-publish-lifecycle.md @@ -0,0 +1,201 @@ +# The Episode Publish Lifecycle (draft → audio → publish) + +The single most important behavioral fact of the Transistor API: +**creating an episode never publishes it, and updating an episode never +publishes it.** Publishing, scheduling, and unpublishing travel on their +own dedicated endpoint. Everything below is from the official reference +(developers.transistor.fm, fetched live 2026-08-29). + +## States + +`attributes.status` is exactly one of: + +| Status | Meaning | +| --- | --- | +| `draft` | Not in the RSS feed. New episodes start here (`published_at: null`). | +| `scheduled` | Will publish at `episode[published_at]` (show's time zone). | +| `published` | Live in the RSS feed; `published_at` records the publish time. | + +Transistor's own tooling describes the same model ("Episodes are always +created as drafts, and publishing is a separate tool call" — the vendor MCP +server), and the REST docs describe the publish endpoint's purpose as +"Publish a single episode now or in the past, schedule for the future, or +revert to a draft." All three states go through the same endpoint: it is a +setter, not a one-way transition — you can pull a published episode back to +draft, or re-publish a draft later. + +## The publish request (exact documented shape) + +The endpoint is `PATCH /v1/episodes/:id/publish` — a metadata PATCH to +`/v1/episodes/:id` will **not** publish (the docs say so on the update +endpoint's own page). The episode ID is the URL path parameter; the body +carries the status: + +```sh +curl https://api.transistor.fm/v1/episodes//publish -X PATCH \ + -H "x-api-key: " \ + -d "episode[status]=published" \ + -d "fields[episode][]=status" +``` + +- Required: `episode[status]` ∈ {`draft`, `scheduled`, `published`}. +- Optional: `episode[published_at]` — the publish date/time **in the + show's time zone**. Combine it with `episode[status]=scheduled` to + schedule for the future, or with `published` to backdate an episode + (e.g. importing an archive with historical dates). +- The documented request body is form-encoded bracket keys. The API intro + says JSON bodies are accepted generally, but the reference publishes no + JSON equivalent for this action — copy the documented shape above. +- Note what the body does **not** contain: no `id`, no `type`, no JSON:API + `data` wrapper. This is not a JSON:API resource-identifier request body; + the resource identity lives in the URL. (The *response*, by contrast, is + a standard JSON:API document — see below.) + +Documented scheduling example (same endpoint): + +```sh +curl https://api.transistor.fm/v1/episodes//publish -X PATCH \ + -H "x-api-key: " \ + -d "episode[status]=scheduled" \ + -d "episode[published_at]=2026-09-03 09:00:00" +``` + +## The publish response + +With `fields[episode][]=status` the documented response is: + +```json +{ + "data": { + "id": "", + "type": "episode", + "attributes": {"status": "published"}, + "relationships": {} + } +} +``` + +Read success off the response: `data.id` matches the episode you patched, +`data.type` is `"episode"`, and `data.attributes.status` carries the new +state. Without the fieldset you also get `published_at`, `media_url`, +`share_url`, `duration`, and the rest of the episode resource. + +## Worked recipe: create → attach audio → publish (CLI) + +```bash +export TRANSISTOR_API_KEY="" + +# 1. Create the episode (always a draft). Audio can ride along now: +transistor episode-create --show \ + --title "Episode 12: Roasting" \ + --summary "A primer on roasting coffee" \ + --season 2 --number 4 \ + --audio-url "https://uploads.example.com/ep12.mp3" +# -> {"id": "", "status": "draft", ...} + +# (skip to 3 if you attached audio above) +# 2. Attach audio later via the metadata PATCH — this does NOT publish: +transistor episode-update --id \ + --audio-url "https://uploads.example.com/ep12.mp3" + +# 3. Publish on the dedicated endpoint: +transistor episode-publish --id +# PATCH /v1/episodes//publish with episode[status]=published +``` + +The bundled CLI guards step 3: if `attributes.media_url` is still empty it +refuses to publish (an audio-less item would go out to every feed reader) +and prints the attach-audio recipe; `--force` overrides. + +## Worked recipe: raw curl + +```bash +# 1. Create a draft +curl https://api.transistor.fm/v1/episodes -X POST \ + -H "x-api-key: " \ + -d "episode[show_id]=" \ + -d "episode[title]=Example episode" \ + -d "episode[audio_url]=https://example.com/audio/episode.mp3" +# -> {"data": {"id": "", "attributes": {"status": "draft", "published_at": null, ...}}} + +# 2. (only if audio was omitted) attach through the ordinary metadata PATCH +curl https://api.transistor.fm/v1/episodes/ -X PATCH \ + -H "x-api-key: " \ + -d "episode[audio_url]=https://example.com/audio/episode.mp3" + +# 3. Publish through the dedicated endpoint +curl https://api.transistor.fm/v1/episodes//publish -X PATCH \ + -H "x-api-key: " \ + -d "episode[status]=published" +# -> {"data": {"id": "", "type": "episode", +# "attributes": {"status": "published"}, "relationships": {}}} +``` + +## Local files: the authorize-upload detour + +If the audio exists only on disk (no public URL), insert this before step 1 +or 2: + +```bash +# 1. Authorize: get a signed upload URL (max 5GB) +transistor authorize-upload --filename Episode1.mp3 +# -> {"audio_url": "https://uploads.example.com/ep1.mp3", +# "upload_url": "https://...r2.cloudflarestorage.com/...", +# "content_type": "audio/mpeg", "expires_in": 600} + +# 2. PUT the file bytes to attributes.upload_url with the returned +# Content-Type (the CLI does this when you pass --file): +curl -X PUT -H "Content-Type: audio/mpeg" -T /path/to/Episode1.mp3 "" + +# 3. Attach attributes.audio_url via episode-create/episode-update, then publish. +``` + +The signed URL expires (`expires_in`, example 600 s) — upload promptly and +only then attach. Accepted formats (vendor-documented): .mp3, .m4a, .wav, +.aif, .aiff, .aifc, .mp4, .mov. + +## Gotchas in the lifecycle + +- **Publishing is never a side effect.** POST /episodes and PATCH + /episodes/:id both return `status: "draft"` (or leave it untouched) no + matter what fields you send. If your episode stays stubbornly draft, + you are missing the `/publish` endpoint call — that is the bug, not a + permissions problem. +- **`published_at` vs status.** Setting `episode[published_at]` alone on + the metadata PATCH does nothing to feed visibility; the publish + endpoint's `episode[status]` field is what changes state, and + `published_at` only qualifies *when*. +- **Time zone.** `episode[published_at]` is interpreted in the show's + configured `time_zone` (a show attribute), not in UTC and not in your + machine's zone. Check `transistor show --id --json`. +- **Watch processing.** After attaching audio, `audio_processing` is true + until Transistor finishes; `processing_failure` explains failures. + Publishing with an unprocessed/failed file pushes silence to subscribers. +- **Unpublish = status draft.** Same endpoint, `episode[status]=draft`; + the episode drops out of the RSS feed but keeps its id, audio, and + metadata. +- **Rate budget.** The 10 req / 10 s limit applies to the whole lifecycle; + a create + audio-attach + publish burst is fine, a loop re-publishing + 50 episodes needs sleeps or webhooks. + +## Sources + +- https://developers.transistor.fm/#patch-v1-episodes-id-publish + ("Publish, schedule, or unpublish an episode": required `episode[status]` + ∈ draft/scheduled/published, optional `episode[published_at]`, publish + request + response examples) — fetched live 2026-08-29 +- https://developers.transistor.fm/#post-v1-episodes ("Create a new draft + episode... publishing an episode involves a separate endpoint"; response + with `status: "draft"`, `published_at: null`) — fetched live 2026-08-29 +- https://developers.transistor.fm/#patch-v1-episodes-id ("publishing or + unpublishing an episode involves a separate endpoint") — fetched live 2026-08-29 +- https://developers.transistor.fm/#get-v1-episodes-authorize_upload + (authorize_upload flow, upload_url/content_type/expires_in/audio_url, + 5GB max, PUT requirement) — fetched live 2026-08-29 +- https://developers.transistor.fm/#Episode (status values, audio/video + processing attributes) — fetched live 2026-08-29 +- https://mcp.transistor.fm/ ("Episodes are always created as drafts, and + publishing is a separate tool call"; accepted upload formats) — fetched live 2026-08-29 +- https://pkg.go.dev/gitlab.com/flimzy/transistor (PublishEpisode against + PATCH /v1/episodes/:id/publish — independent implementation corroborating + the dedicated endpoint) — fetched live 2026-08-29 diff --git a/transistor/references/gotchas-and-recipes.md b/transistor/references/gotchas-and-recipes.md new file mode 100644 index 0000000..d95285e --- /dev/null +++ b/transistor/references/gotchas-and-recipes.md @@ -0,0 +1,256 @@ +# Gotchas Field Guide and Worked Recipes + +Symptom-first troubleshooting for the Transistor API, followed by end-to-end +recipes. Everything traces to developers.transistor.fm or Transistor's +support pages (fetched live 2026-08-29); independent-implementation +corroboration (the flimzy/transistor Go SDK) is cited where noted. + +## Symptom → cause → fix + +### `404` on the "current user" call + +- **Symptom:** `GET /v1/user` (or `/v1/authorization`) returns 404. +- **Cause:** those routes do not exist. The authenticated-user probe is + `GET /v1` — root, no suffix. +- **Fix:** `transistor user` (sends `GET /v1`). Older tutorials showing + `/v1/user` predate the current API surface. + +### Writes "succeed" but the episode never appears in the feed + +- **Symptom:** episode exists via `GET /v1/episodes/:id`, `status` stays + `"draft"` even after updates. +- **Cause:** POST /episodes and PATCH /episodes/:id never publish; only + `PATCH /v1/episodes/:id/publish` changes status. (The docs state this on + both the create and update pages.) +- **Fix:** `transistor episode-publish --id `, or the raw call: + `curl .../v1/episodes//publish -X PATCH -d "episode[status]=published"` + with `x-api-key`. + +### Pagination loop re-reads page 1 forever + +- **Symptom:** every page of your loop returns the same items. +- **Cause:** wrong param name. There is no `pagination[limit]`, no + `page[number]`, no `page` — unknown params are ignored. Per-page is + `pagination[per]` (default 10) and the page number is `pagination[page]` + (docs' default 0, examples request 1). +- **Fix:** loop on `meta.currentPage < meta.totalPages`, sending + `pagination[page]=N`; verify with `meta.totalCount` that you captured + everything. In the bundled CLI: `--page N --per M`. + +### `meta.totalCount` disagrees with the number of items + +- **Symptom:** `totalCount: 25` but only 10 objects in `data`. +- **Cause:** nothing is wrong — `per` defaults to 10 and the rest are on + later pages. +- **Fix:** raise `pagination[per]` or walk pages. Compare your accumulated + item count to `meta.totalCount`, not to `len(data)` of one page. + +### Show "counts" fields are missing + +- **Symptom:** your script reads `attributes.episodes_count` / + `subscribers_count` and gets `null`. +- **Cause:** show resources do not carry those fields (an older wrapper's + display invented them). +- **Fix:** list episodes with `show_id` and read `meta.totalCount`; + subscribers likewise (`GET /v1/subscribers?show_id=...`). + +### The user object has no email + +- **Symptom:** you expected `data.attributes.email` from the user probe. +- **Cause:** the `user` resource has `name`, `time_zone`, `image_url`, + timestamps — no email. +- **Fix:** use `name`/`time_zone`; identify accounts by the dashboard, not + the API. + +### Analytics numbers look "empty" + +- **Symptom:** you expected `attributes.totals.downloads.total`; you got an + array. +- **Cause:** analytics resources return per-day arrays: + `attributes.downloads = [{"date": ..., "downloads": N}, ...]`. +- **Fix:** sum the array. The bundled CLI exposes `downloads_total` and + keeps the raw `downloads` array in `--json`. +- **Related:** don't parse the row `date` format — the docs' examples echo + dates inconsistently (`15-08-2026` vs `08-15-2026`); your requested + `start_date`/`end_date` (dd-mm-yyyy) define the window, and both are + required if either is given. + +### `429` mid-loop + +- **Symptom:** bulk operations fail after ~10 quick calls. +- **Cause:** rate limit is 10 requests / 10 seconds, and the 429 blocks + access for 10 seconds. No retry headers are documented. +- **Fix:** sleep ≥10 s on 429 and retry; batch what you can + (`/v1/subscribers/batch` for imports); prefer webhooks + (`episode_published`) over polling; cache — Transistor explicitly says + the API is not a website back end. + +### `403` on something you can see in the dashboard + +- **Symptom:** the key is valid (other calls pass) but one resource 403s. +- **Cause:** role scoping. Keys inherit the user's per-podcast role + (owner/admin/team member); some operations require owner/admin. +- **Fix:** have a podcast owner/admin run it, or adjust roles in the + dashboard. + +### Signed upload URL suddenly 403s + +- **Symptom:** your PUT to the `upload_url` worked in testing, fails now. +- **Cause:** `expires_in` (example: 600 s) elapsed. +- **Fix:** re-run `authorize-upload`, PUT promptly, then attach. The PUT + must carry `Content-Type` equal to the returned `content_type`. + +### Audio attached but `duration` is null / `media_url` empty in feeds + +- **Symptom:** episode created with `episode[audio_url]` but processing + fields look stuck. +- **Cause:** `audio_processing` is true while Transistor processes; + `processing_failure` carries an error string on failure. +- **Fix:** poll `transistor episode --id --json` until + `audio_processing` is false (respecting the rate limit) before + publishing. + +## Recipe: full publish pipeline (CLI) + +```bash +export TRANSISTOR_API_KEY="" + +# 1. Verify the key and find the show +transistor shows --json | jq -r '.shows[] | [.id, .title, .slug] | @tsv' + +# 2. Local audio? authorize + upload (skippable if you have a URL) +transistor authorize-upload --filename ep12.mp3 --file ./ep12.mp3 --json | jq -r '.audio_url' + +# 3. Create the draft with audio attached +EP=$(transistor episode-create --show --title "Ep 12" \ + --audio-url "$(cat /tmp/audio_url)" --json | jq -r '.id') +echo "$EP" # draft id, type string + +# 4. Publish when ready +transistor episode-publish --id "$EP" + +# 5. Confirm state and the trackable media URL +transistor episode --id "$EP" --json | jq '{status, media_url, published_at}' +``` + +Every stage's JSON output feeds the next: `shows` → string `id`; +`authorize-upload` → string `audio_url`; `episode-create` → string `id`; +`episode-publish` → new `status`. Same pipeline raw: + +```bash +AUDIO=$(curl -s https://api.transistor.fm/v1/episodes/authorize_upload?filename=ep12.mp3 \ + -H "x-api-key: " | jq -r '.data.attributes.audio_url') +EP=$(curl -s https://api.transistor.fm/v1/episodes -X POST \ + -H "x-api-key: " -d "episode[show_id]=" \ + -d "episode[title]=Ep 12" -d "episode[audio_url]=$AUDIO" | jq -r '.data.id') +curl -s "https://api.transistor.fm/v1/episodes/$EP/publish" -X PATCH \ + -H "x-api-key: " -d "episode[status]=published" | jq '.data.attributes.status' +``` + +## Recipe: schedule a season in bulk (respect the rate limit) + +```bash +# Renumber + schedule episodes for weekly drops; 1 write call each, +# ≥1 s spacing keeps you far under 10 req / 10 s. +i=0 +for AUDIO in /media/season3/*.mp3; do + i=$((i+1)) + URL=$(transistor authorize-upload --filename "$(basename "$AUDIO")" \ + --file "$AUDIO" --json | jq -r '.audio_url') + EP=$(transistor episode-create --show \ + --title "S3E$i" --season 3 --number "$i" --audio-url "$URL" \ + --json | jq -r '.id') + transistor episode-publish --id "$EP" --status scheduled \ + --published-at "2026-09-$((7*i)) 09:00:00" + sleep 1 +done +``` + +`--published-at` is interpreted in the show's time zone; backdating +(publishing "in the past") uses the same fields with status `published`. + +## Recipe: weekly downloads report (analytics) + +```bash +# Per-show totals for a window (dd-mm-yyyy, both bounds required) +for S in $(transistor shows --json | jq -r '.shows[].id'); do + transistor analytics --show "$S" \ + --start-date 01-08-2026 --end-date 28-08-2026 --json \ + | jq -r --arg id "$S" '[$id, (.downloads_total|tostring)] | @tsv' + sleep 1 +done + +# Per-episode series for a show's recent drops +transistor episodes --show --status published --per 5 --json \ + | jq -r '.episodes[].id' \ + | while read -r EP; do + transistor episode-analytics --id "$EP" --json \ + | jq -r '[(.episode_id|tostring), (.downloads_total|tostring)] | @tsv' + sleep 1 + done +``` + +## Recipe: private-podcast subscriber import + +```bash +# Batch import (single call), then verify with a filtered listing +transistor subscriber-batch --show \ + --email "one@example.com" --email "two@example.com" \ + --skip-welcome-email --json | jq '.subscribers' + +transistor subscribers --show --json | jq -r '.meta.totalCount' +# Revoke someone: by email... +transistor subscriber-delete --show --email "two@example.com" +# ...or by id +transistor subscriber-delete --id +``` + +Private subscribers each get personal `feed_url`/`subscribe_url` values — +never share one subscriber's feed URL; it identifies them. + +## Recipe: webhook instead of polling + +```bash +transistor webhook-create --show \ + --event episode_published --url "https://example.com/hooks/transistor" +transistor webhooks --show --json | jq '.webhooks' +transistor webhook-delete --id +``` + +Events: `episode_created`, `episode_published`, `subscriber_created`, +`subscriber_deleted`. Account-wide cap: 50 webhooks. This is the sanctioned +way to stay current without spending the 10 req / 10 s budget on polling. + +## Automation boundaries + +- Show creation is not available via the API (dashboard-only). Everything + else in this guide is API-land: show updates, episode lifecycle, + subscribers, webhooks, analytics. +- The API is not meant to power a website's back end: pull once, cache, + and parse the public RSS feed for display pages. + +## Sources + +- https://developers.transistor.fm/ (authentication, rate limits, all + endpoint examples) — fetched live 2026-08-29 +- https://developers.transistor.fm/#ratelimits (10 req / 10 s, 429 + 10 s + block, caching/RSS guidance) — fetched live 2026-08-29 +- https://developers.transistor.fm/#patch-v1-episodes-id-publish, + #post-v1-episodes, #patch-v1-episodes-id (lifecycle facts: draft on + create, publish via separate endpoint, episode[status] enum, + episode[published_at]) — fetched live 2026-08-29 +- https://developers.transistor.fm/#get-v1-episodes-authorize_upload + (signed upload flow, expires_in example 600, 5GB max) — fetched live 2026-08-29 +- https://developers.transistor.fm/#get-v1-analytics-id, + #get-v1-analytics-id-episodes, #get-v1-analytics-episodes-id + (dd-mm-yyyy date pair rule; per-day downloads arrays) — fetched live 2026-08-29 +- https://developers.transistor.fm/#Show, #Episode, #Subscriber (attribute + inventory: no counts on shows, no email on users, per-subscriber feed + URLs) — fetched live 2026-08-29 +- https://developers.transistor.fm/#Webhook (event names, 50-webhook cap) — + fetched live 2026-08-29 +- https://support.transistor.fm/en/article/what-automations-are-possible-with-transistor-bi27am/ + (show creation limitation; automation guidance) — fetched live 2026-08-29 +- https://mcp.transistor.fm/ (accepted upload formats) — fetched live 2026-08-29 +- https://pkg.go.dev/gitlab.com/flimzy/transistor (route/param + corroboration) — fetched live 2026-08-29 diff --git a/transistor/scripts/test_transistor.py b/transistor/scripts/test_transistor.py new file mode 100644 index 0000000..c643e33 --- /dev/null +++ b/transistor/scripts/test_transistor.py @@ -0,0 +1,912 @@ +"""Offline test suite for the bundled transistor CLI. + +All HTTP is mocked at the client seam (TransistorClient._request is replaced +by a FakeTransport that records method/path/params/body and returns canned +JSON:API documents) — the suite is fully offline and passes the proxy-trap +rerun. Transistor is a keyed API, so there are deliberately NO live-call test +cases (the AGENTS.md network policy is mock-everything for keyed APIs). + +Covers the four contract behavior classes: --help output, argument-error +paths, --dry-run plans, and mocked parsing of canned JSON:API compound +documents (data/attributes/relationships/included[]), plus the documented +multi-step pipelines (each stage's output fields AND JSON types feed the +next: shows -> episodes, episode-create -> episode-update(audio) -> +episode-publish, analytics -> summed downloads). +""" + +import contextlib +import importlib.machinery +import importlib.util +import io +import json +import os +import pathlib +import stat +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +SCRIPT = pathlib.Path(__file__).resolve().parent / "transistor" +LOADER = importlib.machinery.SourceFileLoader("transistor_cli", str(SCRIPT)) +SPEC = importlib.util.spec_from_loader(LOADER.name, LOADER) +ts = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = ts # so unittest.mock.patch("transistor_cli....") resolves +LOADER.exec_module(ts) + +SHOW_ID = "132543" +EPISODE_ID = "3056098" +DRAFT_ID = "3056099" + +# Canned JSON:API compound documents mirroring the documented response shapes +# (developers.transistor.fm): top-level `data`, resource objects with +# `attributes` + `relationships`, compound documents with `included[]`, and +# collection pagination under `meta` (currentPage/totalPages/totalCount). +SHOW_RESOURCE = { + "id": SHOW_ID, + "type": "show", + "attributes": { + "title": "The Caffeine Show", + "slug": "the-caffeine-show", + "description": "A podcast covering all things coffee and caffeine", + "show_type": "episodic", + "private": False, + "feed_url": "https://feeds.transistor.fm/the-caffeine-show", + "time_zone": "UTC", + "author": "Jimmy Podcaster", + "website": "https://example.com/caffeine", + }, + "relationships": {"episodes": {"data": []}}, +} + +USER_DOC = { + "data": { + "id": "173455", + "type": "user", + "attributes": {"name": "Jimmy Podcaster", "time_zone": "UTC", "image_url": None}, + } +} + +SHOWS_DOC = {"data": [SHOW_RESOURCE], "meta": {"currentPage": 0, "totalPages": 1, "totalCount": 1}} +SHOW_DOC = {"data": SHOW_RESOURCE} + +EPISODE_RESOURCE = { + "id": EPISODE_ID, + "type": "episode", + "attributes": { + "title": "How To Roast Coffee", + "number": 1, + "season": 1, + "status": "published", + "published_at": "2020-07-01 00:00:00 UTC", + "duration": 568, + "duration_in_mmss": "09:28", + "media_url": "https://media.transistor.fm/ba1d5241/c1ae0a3a.mp3", + "share_url": "https://share.transistor.fm/s/ba1d5241", + "audio_processing": False, + "processing_failure": None, + }, + "relationships": {"show": {"data": {"id": SHOW_ID, "type": "show"}}}, +} + +DRAFT_RESOURCE = dict( + EPISODE_RESOURCE, + id=DRAFT_ID, + attributes=dict( + EPISODE_RESOURCE["attributes"], + title="Unfinished Episode", + number=2, + status="draft", + published_at=None, + media_url="", + ), +) + +# Compound document: episode collection with the parent show in included[]. +EPISODES_DOC = { + "data": [EPISODE_RESOURCE, DRAFT_RESOURCE], + "included": [SHOW_RESOURCE], + "meta": {"currentPage": 1, "totalPages": 3, "totalCount": 25}, +} +EPISODE_DOC = {"data": EPISODE_RESOURCE} +DRAFT_DOC = {"data": DRAFT_RESOURCE} +NO_AUDIO_DOC = { + "data": dict(EPISODE_RESOURCE, attributes=dict(EPISODE_RESOURCE["attributes"], media_url="")) +} + +CREATED_DRAFT_RESOURCE = dict( + DRAFT_RESOURCE, + id="3056100", + attributes=dict(DRAFT_RESOURCE["attributes"], title="Fresh Draft", number=None, season=2), +) +CREATE_DOC = {"data": CREATED_DRAFT_RESOURCE} + +PUBLISH_DOC = { + "data": { + "id": EPISODE_ID, + "type": "episode", + "attributes": { + "status": "published", + "published_at": "2026-08-29 12:00:00 UTC", + "media_url": EPISODE_RESOURCE["attributes"]["media_url"], + }, + "relationships": {}, + } +} + +SHOW_ANALYTICS_DOC = { + "data": { + "id": "the-caffeine-show", + "type": "show_analytics", + "attributes": { + "downloads": [ + {"date": "15-08-2026", "downloads": 4}, + {"date": "16-08-2026", "downloads": 6}, + ], + "start_date": "08-15-2026", + "end_date": "08-16-2026", + }, + "relationships": {"show": {"data": {"id": SHOW_ID, "type": "show"}}}, + }, + "included": [dict(SHOW_RESOURCE, attributes={"title": "The Caffeine Show"})], +} + +EPISODE_ANALYTICS_DOC = { + "data": dict( + SHOW_ANALYTICS_DOC["data"], + id=EPISODE_ID, + type="episode_analytics", + relationships={"episode": {"data": {"id": EPISODE_ID, "type": "episode"}}}, + ) +} + +AUDIO_UPLOAD_DOC = { + "data": { + "id": "upload-1", + "type": "audio_upload", + "attributes": { + "upload_url": "https://storage.example.com/uploads/episode1.mp3?sig=stub", + "content_type": "audio/mpeg", + "expires_in": 600, + "audio_url": "https://uploads.example.com/episode1.mp3", + }, + } +} + +SUBSCRIBER_RESOURCE = { + "id": "709423", + "type": "subscriber", + "attributes": { + "email": "arthur@example.com", + "status": "default", + "feed_url": "https://subscribers.example.com/a52a98c03f28eb", + "subscribe_url": "https://subscribe.example.com/a52a98c03f28eb", + "has_downloads": False, + }, + "relationships": {"show": {"data": {"id": SHOW_ID, "type": "show"}}}, +} +SUBSCRIBERS_DOC = { + "data": [SUBSCRIBER_RESOURCE], + "meta": {"currentPage": 0, "totalPages": 1, "totalCount": 1}, +} +SUBSCRIBER_BATCH_DOC = { + "data": [ + SUBSCRIBER_RESOURCE, + dict( + SUBSCRIBER_RESOURCE, + id="709424", + attributes=dict(SUBSCRIBER_RESOURCE["attributes"], email="beatrice@example.com"), + ), + ] +} + +WEBHOOK_RESOURCE = { + "id": "104325", + "type": "webhook", + "attributes": {"event_name": "episode_published", "url": "https://example.com/hook"}, + "relationships": {"show": {"data": {"id": SHOW_ID, "type": "show"}}}, +} +WEBHOOKS_DOC = {"data": [WEBHOOK_RESOURCE]} + + +class FakeResponse: + def __init__(self, status_code=200, json_body=None, text=""): + self.status_code = status_code + self._json = json_body + self.text = text if text else (json.dumps(json_body) if json_body is not None else "") + + def json(self): + if self._json is None: + raise ValueError("no json body") + return self._json + + +class FakeTransport: + """Stands in for TransistorClient._request: records every call and serves + canned JSON:API documents by (method, path).""" + + def __init__(self, routes=None): + self.routes = routes or {} + self.calls = [] + + def __call__(self, method, path, params=None, body=None): + self.calls.append({"method": method, "path": path, "params": params, "body": body}) + for (m, p), doc in self.routes.items(): + if m == method and path == p: + return doc + for (m, p), doc in self.routes.items(): + if m == method and path.startswith(p): + return doc + raise AssertionError(f"unexpected request: {method} {path} {params} {body}") + + +def make_client(routes=None): + client = ts.TransistorClient(key="test-key", dry_run=False) + client._request = FakeTransport(routes) + return client + + +def run_handler(client, handler, *argv): + out = io.StringIO() + with contextlib.redirect_stdout(out): + handler(client, list(argv)) + return out.getvalue() + + +def run_cli(*args): + env = os.environ.copy() + env.pop("TRANSISTOR_API_KEY", None) + return subprocess.run( + [sys.executable, str(SCRIPT), *args], + capture_output=True, + text=True, + env=env, + ) + + +class ModuleStateTestCase(unittest.TestCase): + """Base that restores module globals mutated by in-process tests.""" + + def setUp(self): + self._flags = dict(ts.GLOBAL_FLAGS) + self._quiet = ts.QUIET + ts.GLOBAL_FLAGS = {"json": True, "dry_run": False, "quiet": False, "verbose": False} + + def tearDown(self): + ts.GLOBAL_FLAGS = self._flags + ts.QUIET = self._quiet + + +class HelpOutputTests(unittest.TestCase): + """Class 1: --help output.""" + + def test_help_lists_every_subcommand(self): + result = run_cli("--help") + self.assertEqual(result.returncode, 0, result.stderr) + for noun in ( + "user", + "shows", + "show-update", + "episodes", + "episode-create", + "episode-update", + "episode-publish", + "authorize-upload", + "analytics", + "episode-analytics", + "subscribers", + "subscriber-create", + "subscriber-batch", + "subscriber-delete", + "webhooks", + "webhook-create", + "webhook-delete", + ): + self.assertIn(noun, result.stdout) + + def test_help_names_the_env_var_and_docs_url(self): + result = run_cli("--help") + self.assertIn("TRANSISTOR_API_KEY", result.stdout) + self.assertIn("dashboard.transistor.fm/account", result.stdout) + self.assertIn("developers.transistor.fm", result.stdout) + + def test_leaf_help_carries_examples_and_flags(self): + for leaf in ("episodes", "episode-publish", "authorize-upload", "analytics"): + result = run_cli(leaf, "--help") + with self.subTest(leaf=leaf): + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("--", result.stdout) + + def test_no_command_prints_help_and_exits_one(self): + result = run_cli() + self.assertEqual(result.returncode, 1) + self.assertIn("usage", result.stdout) + + +class ArgumentErrorTests(unittest.TestCase): + """Class 2: argument-error paths fail cleanly before any network call.""" + + def assertCleanError(self, result, needle): + self.assertNotEqual(result.returncode, 0) + self.assertIn(needle, result.stderr) + self.assertNotIn("Traceback", result.stderr) + + def test_episode_requires_id(self): + self.assertCleanError(run_cli("episode"), "--id") + + def test_episode_create_requires_show_and_title(self): + self.assertCleanError(run_cli("episode-create", "--show", SHOW_ID), "--title") + + def test_analytics_requires_show(self): + self.assertCleanError(run_cli("analytics"), "--show") + + def test_analytics_dates_require_each_other(self): + self.assertCleanError( + run_cli("analytics", "--show", SHOW_ID, "--start-date", "01-09-2026"), + "end_date must be used together", + ) + + def test_analytics_dates_reject_wrong_format(self): + self.assertCleanError( + run_cli( + "analytics", + "--show", + SHOW_ID, + "--start-date", + "2026-09-01", + "--end-date", + "2026-09-07", + ), + "dd-mm-yyyy", + ) + + def test_episode_update_with_no_fields_dies(self): + self.assertCleanError(run_cli("episode-update", "--id", EPISODE_ID), "Nothing to update") + + def test_subscriber_delete_requires_arguments(self): + self.assertCleanError(run_cli("subscriber-delete"), "--id") + + def test_missing_api_key_dies_before_network(self): + result = run_cli("shows") + self.assertCleanError(result, "TRANSISTOR_API_KEY") + + +class DryRunPlanTests(ModuleStateTestCase): + """Class 3: --dry-run emits valid JSON plans with zero network activity.""" + + def run_json(self, handler, client, *argv): + out = run_handler(client, handler, *argv) + return json.loads(out) + + def test_plan_shape_covers_method_path_params_body(self): + client = ts.TransistorClient(dry_run=True) + plan = self.run_json(ts.cmd_user, client) + self.assertTrue(plan["dry_run"]) + self.assertEqual(plan["method"], "GET") + self.assertEqual(plan["path"], "") + + def test_episodes_plan_carries_documented_params(self): + client = ts.TransistorClient(dry_run=True) + plan = self.run_json( + ts.cmd_episodes, client, "--show", SHOW_ID, "--status", "draft", "--per", "5" + ) + self.assertEqual(plan["method"], "GET") + self.assertEqual(plan["path"], "/episodes") + self.assertEqual(plan["params"]["show_id"], SHOW_ID) + self.assertEqual(plan["params"]["status"], "draft") + self.assertEqual(plan["params"]["pagination[per]"], 5) + # pagination[page] only appears when requested (API default is page 0, + # but the CLI does not send params the user did not ask for). + self.assertNotIn("pagination[page]", plan["params"]) + + def test_limit_alias_feeds_per_param(self): + client = ts.TransistorClient(dry_run=True) + plan = self.run_json(ts.cmd_episodes, client, "--limit", "7") + self.assertEqual(plan["params"]["pagination[per]"], 7) + + def test_create_plan_sends_bracket_keys(self): + client = ts.TransistorClient(dry_run=True) + plan = self.run_json( + ts.cmd_episode_create, client, "--show", SHOW_ID, "--title", "Fresh Draft" + ) + self.assertEqual(plan["method"], "POST") + self.assertEqual(plan["path"], "/episodes") + self.assertEqual(plan["body"]["episode[show_id]"], SHOW_ID) + self.assertEqual(plan["body"]["episode[title]"], "Fresh Draft") + + def test_publish_plan_is_the_dedicated_publish_endpoint(self): + client = ts.TransistorClient(dry_run=True) + plan = self.run_json(ts.cmd_episode_publish, client, "--id", EPISODE_ID) + self.assertEqual(plan["method"], "PATCH") + self.assertEqual(plan["path"], f"/episodes/{EPISODE_ID}/publish") + self.assertEqual(plan["body"], {"episode[status]": "published"}) + + def test_schedule_plan_carries_published_at(self): + client = ts.TransistorClient(dry_run=True) + plan = self.run_json( + ts.cmd_episode_publish, + client, + "--id", + EPISODE_ID, + "--status", + "scheduled", + "--published-at", + "2026-09-03 09:00:00", + ) + self.assertEqual(plan["body"]["episode[status]"], "scheduled") + self.assertEqual(plan["body"]["episode[published_at]"], "2026-09-03 09:00:00") + + def test_update_plan_attaches_audio_without_publishing(self): + client = ts.TransistorClient(dry_run=True) + plan = self.run_json( + ts.cmd_episode_update, + client, + "--id", + EPISODE_ID, + "--audio-url", + "https://uploads.example.com/episode1.mp3", + ) + self.assertEqual(plan["method"], "PATCH") + self.assertEqual(plan["path"], f"/episodes/{EPISODE_ID}") + self.assertEqual( + plan["body"], {"episode[audio_url]": "https://uploads.example.com/episode1.mp3"} + ) + + def test_authorize_upload_plan_does_not_leak_urls(self): + client = ts.TransistorClient(dry_run=True) + out = run_handler(client, ts.cmd_authorize_upload, "--filename", "Episode1.mp3") + plan = json.loads(out) + self.assertEqual(plan["method"], "GET") + self.assertEqual(plan["params"], {"filename": "Episode1.mp3"}) + self.assertIn("then_put", plan) + self.assertIn("HTTP PUT", plan["then_put"]["how"]) + + def test_batch_plan_sends_email_array(self): + client = ts.TransistorClient(dry_run=True) + plan = self.run_json( + ts.cmd_subscriber_batch, + client, + "--show", + SHOW_ID, + "--email", + "one@example.com", + "--email", + "two@example.com", + ) + self.assertEqual(plan["path"], "/subscribers/batch") + self.assertEqual(plan["body"]["emails[]"], ["one@example.com", "two@example.com"]) + self.assertEqual(plan["body"]["show_id"], SHOW_ID) + + def test_cli_json_dry_run_subprocess_is_valid_json(self): + result = run_cli( + "--json", "--dry-run", "episodes", "--show", SHOW_ID, "--status", "published" + ) + self.assertEqual(result.returncode, 0, result.stderr) + plan = json.loads(result.stdout) + self.assertTrue(plan["dry_run"]) + self.assertEqual(plan["path"], "/episodes") + self.assertEqual(plan["params"]["status"], "published") + + +class JSONAPIDocumentTests(ModuleStateTestCase): + """Class 4: mocked parsing of canned JSON:API compound documents.""" + + def test_user_parses_data_attributes(self): + client = make_client({("GET", ""): USER_DOC}) + out = run_handler(client, ts.cmd_user) + payload = json.loads(out) + self.assertEqual(payload["id"], "173455") + self.assertEqual(payload["name"], "Jimmy Podcaster") + self.assertEqual(payload["time_zone"], "UTC") + + def test_shows_parses_collection_and_meta(self): + client = make_client({("GET", "/shows"): SHOWS_DOC}) + payload = json.loads(run_handler(client, ts.cmd_shows)) + self.assertEqual(payload["meta"]["totalPages"], 1) + show = payload["shows"][0] + self.assertEqual(show["id"], SHOW_ID) + self.assertEqual(show["title"], "The Caffeine Show") + self.assertEqual(show["feed_url"], "https://feeds.transistor.fm/the-caffeine-show") + + def test_episodes_compound_document_includes_show(self): + client = make_client({("GET", "/episodes"): EPISODES_DOC}) + payload = json.loads(run_handler(client, ts.cmd_episodes, "--include", "show")) + self.assertEqual(payload["meta"]["totalCount"], 25) + first = payload["episodes"][0] + self.assertIsInstance(first["id"], str) + self.assertEqual(first["title"], "How To Roast Coffee") + self.assertEqual(first["status"], "published") + self.assertIsInstance(first["season"], int) + self.assertIsInstance(first["duration"], int) + self.assertEqual(first["show_id"], SHOW_ID) + draft = payload["episodes"][1] + self.assertEqual(draft["status"], "draft") + self.assertEqual(draft["published_at"], "") + # Human mode also prints the included[] show summary via log(). + ts.GLOBAL_FLAGS = {"json": False, "dry_run": False, "quiet": False, "verbose": False} + human = run_handler(client, ts.cmd_episodes, "--include", "show") + ts.GLOBAL_FLAGS = {"json": True, "dry_run": False, "quiet": False, "verbose": False} + self.assertIn("included show: The Caffeine Show", human) + + def test_episode_relationships_expose_show_id(self): + client = make_client({("GET", f"/episodes/{EPISODE_ID}"): EPISODE_DOC}) + payload = json.loads( + run_handler(client, ts.cmd_episode, "--id", EPISODE_ID, "--include", "show") + ) + self.assertEqual(payload["show_id"], SHOW_ID) + self.assertEqual(payload["media_url"], EPISODE_RESOURCE["attributes"]["media_url"]) + + def test_analytics_sums_downloads_array(self): + client = make_client({("GET", f"/analytics/{SHOW_ID}"): SHOW_ANALYTICS_DOC}) + payload = json.loads(run_handler(client, ts.cmd_analytics, "--show", SHOW_ID)) + self.assertEqual(payload["downloads_total"], 10) + self.assertEqual(payload["days"], 2) + self.assertIsInstance(payload["downloads"], list) + row = payload["downloads"][0] + self.assertIsInstance(row["downloads"], int) + + def test_subscribers_list_parses_envelope(self): + client = make_client({("GET", "/subscribers"): SUBSCRIBERS_DOC}) + payload = json.loads(run_handler(client, ts.cmd_subscribers, "--show", SHOW_ID)) + sub = payload["subscribers"][0] + self.assertEqual(sub["email"], "arthur@example.com") + self.assertEqual(sub["subscribe_url"], SUBSCRIBER_RESOURCE["attributes"]["subscribe_url"]) + + def test_webhooks_list_parses_event_names(self): + client = make_client({("GET", "/webhooks"): WEBHOOKS_DOC}) + payload = json.loads(run_handler(client, ts.cmd_webhooks, "--show", SHOW_ID)) + self.assertEqual(payload["webhooks"][0]["event_name"], "episode_published") + + +class WritePathTests(ModuleStateTestCase): + """Mocked write commands must send the documented bracket-key bodies and + the dedicated publish endpoint.""" + + def test_episode_create_posts_show_id_and_title(self): + client = make_client({("POST", "/episodes"): CREATE_DOC}) + payload = json.loads( + run_handler( + client, + ts.cmd_episode_create, + "--show", + SHOW_ID, + "--title", + "Fresh Draft", + "--season", + "2", + "--audio-url", + "https://uploads.example.com/x.mp3", + ) + ) + call = client._request.calls[0] + self.assertEqual(call["method"], "POST") + self.assertEqual(call["body"]["episode[show_id]"], SHOW_ID) + self.assertEqual(call["body"]["episode[title]"], "Fresh Draft") + self.assertEqual(call["body"]["episode[season]"], 2) + self.assertEqual(call["body"]["episode[audio_url]"], "https://uploads.example.com/x.mp3") + self.assertEqual(payload["status"], "draft") + + def test_created_draft_points_at_publish_recipe(self): + client = make_client({("POST", "/episodes"): CREATE_DOC}) + ts.GLOBAL_FLAGS = {"json": False, "dry_run": False, "quiet": False, "verbose": False} + out = run_handler( + client, ts.cmd_episode_create, "--show", SHOW_ID, "--title", "Fresh Draft" + ) + self.assertIn(f"episode-publish --id {CREATED_DRAFT_RESOURCE['id']}", out) + + def test_publish_sends_status_to_publish_endpoint(self): + client = make_client( + { + ("GET", f"/episodes/{EPISODE_ID}"): EPISODE_DOC, + ("PATCH", f"/episodes/{EPISODE_ID}/publish"): PUBLISH_DOC, + } + ) + payload = json.loads(run_handler(client, ts.cmd_episode_publish, "--id", EPISODE_ID)) + call = client._request.calls[-1] + self.assertEqual(call["method"], "PATCH") + self.assertEqual(call["path"], f"/episodes/{EPISODE_ID}/publish") + self.assertEqual(call["body"], {"episode[status]": "published"}) + self.assertEqual(payload["status"], "published") + + def test_unpublish_sends_draft_status(self): + client = make_client( + { + ("GET", f"/episodes/{EPISODE_ID}"): EPISODE_DOC, + ("PATCH", f"/episodes/{EPISODE_ID}/publish"): PUBLISH_DOC, + } + ) + run_handler(client, ts.cmd_episode_publish, "--id", EPISODE_ID, "--status", "draft") + self.assertEqual(client._request.calls[-1]["body"], {"episode[status]": "draft"}) + + def test_show_update_sends_show_bracket_keys(self): + client = make_client({("PATCH", f"/shows/{SHOW_ID}"): SHOW_DOC}) + run_handler( + client, + ts.cmd_show_update, + "--id", + SHOW_ID, + "--title", + "New Title", + "--author", + "New Author", + ) + call = client._request.calls[0] + self.assertEqual(call["method"], "PATCH") + self.assertEqual(call["body"], {"show[title]": "New Title", "show[author]": "New Author"}) + + +class PipelineChainTests(ModuleStateTestCase): + """Documented multi-step recipes must execute stage by stage, each stage's + output field names AND JSON types consumable by the next.""" + + def test_shows_then_filtered_episodes_pipeline(self): + client = make_client({("GET", "/shows"): SHOWS_DOC, ("GET", "/episodes"): EPISODES_DOC}) + first = json.loads(run_handler(client, ts.cmd_shows)) + consumed_show_id = first["shows"][0]["id"] + self.assertIsInstance(consumed_show_id, str) + second = json.loads(run_handler(client, ts.cmd_episodes, "--show", consumed_show_id)) + self.assertEqual(client._request.calls[1]["params"]["show_id"], consumed_show_id) + self.assertEqual(second["episodes"][0]["show_id"], consumed_show_id) + + def test_analytics_date_pair_pipeline(self): + client = make_client({("GET", f"/analytics/{SHOW_ID}"): SHOW_ANALYTICS_DOC}) + payload = json.loads( + run_handler( + client, + ts.cmd_analytics, + "--show", + SHOW_ID, + "--start-date", + "15-08-2026", + "--end-date", + "16-08-2026", + ) + ) + call = client._request.calls[0] + self.assertEqual(call["params"], {"start_date": "15-08-2026", "end_date": "16-08-2026"}) + self.assertEqual(payload["downloads_total"], 10) + + def test_create_then_attach_audio_then_publish_pipeline(self): + client = make_client( + { + ("POST", "/episodes"): CREATE_DOC, + ("PATCH", f"/episodes/{CREATED_DRAFT_RESOURCE['id']}"): { + "data": CREATED_DRAFT_RESOURCE + }, + ("GET", f"/episodes/{CREATED_DRAFT_RESOURCE['id']}"): { + "data": dict( + CREATED_DRAFT_RESOURCE, + attributes=dict( + CREATED_DRAFT_RESOURCE["attributes"], + media_url="https://uploads.example.com/final.mp3", + ), + ) + }, + ("PATCH", f"/episodes/{CREATED_DRAFT_RESOURCE['id']}/publish"): { + "data": dict( + CREATED_DRAFT_RESOURCE, + attributes=dict( + CREATED_DRAFT_RESOURCE["attributes"], + status="published", + published_at="2026-08-29 12:00:00 UTC", + ), + ) + }, + } + ) + # Stage 1: create -> returns the draft id (str) consumed downstream. + created = json.loads( + run_handler(client, ts.cmd_episode_create, "--show", SHOW_ID, "--title", "Fresh Draft") + ) + self.assertEqual(created["status"], "draft") + self.assertIsInstance(created["id"], str) + draft_id = created["id"] + # Stage 2: attach audio via the metadata PATCH (id + audio_url feed in). + run_handler( + client, + ts.cmd_episode_update, + "--id", + draft_id, + "--audio-url", + "https://uploads.example.com/final.mp3", + ) + attach_call = client._request.calls[1] + self.assertEqual(attach_call["path"], f"/episodes/{draft_id}") + self.assertEqual( + attach_call["body"], {"episode[audio_url]": "https://uploads.example.com/final.mp3"} + ) + # Stage 3: publish on the dedicated endpoint reuses the same id (str). + published = json.loads(run_handler(client, ts.cmd_episode_publish, "--id", draft_id)) + publish_call = client._request.calls[-1] + self.assertEqual(publish_call["path"], f"/episodes/{draft_id}/publish") + self.assertEqual(publish_call["body"], {"episode[status]": "published"}) + self.assertEqual(published["status"], "published") + self.assertIsInstance(published["published_at"], str) + + def test_episodes_then_episode_analytics_pipeline(self): + client = make_client( + { + ("GET", "/episodes"): EPISODES_DOC, + ("GET", f"/analytics/episodes/{EPISODE_ID}"): EPISODE_ANALYTICS_DOC, + } + ) + listing = json.loads( + run_handler(client, ts.cmd_episodes, "--show", SHOW_ID, "--status", "published") + ) + consumed_episode_id = listing["episodes"][0]["id"] + self.assertIsInstance(consumed_episode_id, str) + stats = json.loads( + run_handler(client, ts.cmd_episode_analytics, "--id", consumed_episode_id) + ) + self.assertEqual( + client._request.calls[1]["path"], f"/analytics/episodes/{consumed_episode_id}" + ) + self.assertEqual(stats["episode_id"], consumed_episode_id) + self.assertEqual(stats["downloads_total"], 10) + + def test_authorize_upload_then_attach_then_publish(self): + client = make_client( + { + ("GET", "/episodes/authorize_upload"): AUDIO_UPLOAD_DOC, + } + ) + with ( + patch("transistor_cli.requests.request") as req_mock, + patch("transistor_cli.requests.put") as put_mock, + tempfile.TemporaryDirectory(prefix="ts-upload-") as tmpdir, + ): + fake_audio = pathlib.Path(tmpdir) / "episode1.mp3" + fake_audio.write_bytes(b"ID3") + req_mock.return_value = FakeResponse(200, AUDIO_UPLOAD_DOC) + put_mock.return_value = FakeResponse(200, {}) + authorized = json.loads( + run_handler( + client, + ts.cmd_authorize_upload, + "--filename", + "Episode1.mp3", + "--file", + str(fake_audio), + ) + ) + self.assertEqual(authorized["content_type"], "audio/mpeg") + self.assertIsInstance(authorized["expires_in"], int) + audio_url = authorized["audio_url"] + self.assertIsInstance(audio_url, str) + # The documented flow: attach audio via episode[audio_url], then publish. + client2 = make_client( + { + ("PATCH", f"/episodes/{EPISODE_ID}"): EPISODE_DOC, + ("GET", f"/episodes/{EPISODE_ID}"): EPISODE_DOC, + ("PATCH", f"/episodes/{EPISODE_ID}/publish"): PUBLISH_DOC, + } + ) + json.loads( + run_handler( + client2, ts.cmd_episode_update, "--id", EPISODE_ID, "--audio-url", audio_url + ) + ) + self.assertEqual(client2._request.calls[0]["body"], {"episode[audio_url]": audio_url}) + json.loads(run_handler(client2, ts.cmd_episode_publish, "--id", EPISODE_ID)) + self.assertEqual(client2._request.calls[-1]["path"], f"/episodes/{EPISODE_ID}/publish") + + +class PublishGuardTests(ModuleStateTestCase): + """The publish guard: refuse to publish episodes with no audio attached + (unless --force), since publishing pushes an unplayable item to feeds.""" + + def test_publish_without_audio_dies_with_recipe(self): + client = make_client({("GET", f"/episodes/{EPISODE_ID}"): NO_AUDIO_DOC}) + stderr = io.StringIO() + with contextlib.redirect_stderr(stderr), self.assertRaises(SystemExit) as ctx: + run_handler(client, ts.cmd_episode_publish, "--id", EPISODE_ID) + self.assertEqual(ctx.exception.code, 1) + self.assertIn("no audio yet", stderr.getvalue()) + self.assertIn("episode-update", stderr.getvalue()) + + def test_force_publishes_without_audio(self): + client = make_client( + { + ("GET", f"/episodes/{EPISODE_ID}"): NO_AUDIO_DOC, + ("PATCH", f"/episodes/{EPISODE_ID}/publish"): PUBLISH_DOC, + } + ) + payload = json.loads( + run_handler(client, ts.cmd_episode_publish, "--id", EPISODE_ID, "--force") + ) + self.assertEqual(payload["status"], "published") + + def test_publish_with_audio_skips_guard(self): + client = make_client( + { + ("GET", f"/episodes/{EPISODE_ID}"): EPISODE_DOC, + ("PATCH", f"/episodes/{EPISODE_ID}/publish"): PUBLISH_DOC, + } + ) + payload = json.loads(run_handler(client, ts.cmd_episode_publish, "--id", EPISODE_ID)) + self.assertEqual(payload["status"], "published") + + +class ErrorSignatureTests(unittest.TestCase): + """HTTP error signatures: 401/403/404/429 and JSON:API errors[] bodies.""" + + def run_status(self, status_code, body=None): + client = ts.TransistorClient(key="test-key", dry_run=False) + stderr = io.StringIO() + with patch("transistor_cli.requests.request") as req_mock: + req_mock.return_value = FakeResponse(status_code, body) + with contextlib.redirect_stderr(stderr), self.assertRaises(SystemExit) as ctx: + client.list_shows() + return ctx.exception.code, stderr.getvalue() + + def test_401_names_the_env_var(self): + code, err = self.run_status(401, {"message": "Unauthorized"}) + self.assertEqual(code, 1) + self.assertIn("401", err) + self.assertIn("TRANSISTOR_API_KEY", err) + + def test_403_explains_role_access(self): + _, err = self.run_status(403, {"message": "Forbidden"}) + self.assertIn("403", err) + self.assertIn("role", err) + + def test_404_suggests_id_or_slug(self): + _, err = self.run_status(404, {"message": "Not Found"}) + self.assertIn("404", err) + self.assertIn("slug", err) + + def test_429_states_the_rate_limit_window(self): + _, err = self.run_status(429, {"message": "Too Many Requests"}) + self.assertIn("429", err) + self.assertIn("10 requests per 10 seconds", err) + + def test_errors_envelope_is_flattened(self): + body = { + "errors": [{"title": "Unprocessable Entity", "detail": "Status can't be published"}] + } + _, err = self.run_status(422, body) + self.assertIn("422", err) + self.assertIn("Unprocessable Entity", err) + self.assertIn("Status can't be published", err) + + +class ScriptConventionsTests(unittest.TestCase): + """SCRIPT-GATES-adjacent invariants: imports whitelist, no tech-debt + markers, executable bit, stdlib+requests only.""" + + def test_executable_bit(self): + mode = stat.S_IMODE(os.stat(SCRIPT).st_mode) + self.assertTrue(mode & stat.S_IXUSR, "scripts/transistor must stay executable") + + def test_imports_are_stdlib_plus_requests(self): + text = SCRIPT.read_text() + for line in text.splitlines(): + stripped = line.strip() + if stripped.startswith("import ") or stripped.startswith("from "): + module = stripped.split()[1].split(".")[0].rstrip(",") + self.assertIn( + module, + { + "argparse", + "json", + "os", + "re", + "sys", + "warnings", + "typing", + "requests", + }, + f"unexpected import: {stripped}", + ) + + def test_no_tech_debt_markers(self): + for i, line in enumerate(SCRIPT.read_text().splitlines(), start=1): + if "#" in line: + comment = line.split("#", 1)[1] + for marker in ("TODO", "FIXME", "HACK", "XXX"): + self.assertNotIn(marker, comment, f"line {i}: {marker} marker") + + +if __name__ == "__main__": + unittest.main() diff --git a/transistor/scripts/transistor b/transistor/scripts/transistor new file mode 100755 index 0000000..f6e4aa3 --- /dev/null +++ b/transistor/scripts/transistor @@ -0,0 +1,757 @@ +#!/usr/bin/env python3 +"""transistor — Transistor.fm podcast hosting from the terminal. + +Manage shows, episodes, private-podcast subscribers, webhooks, and download +analytics on Transistor.fm. Read commands: user, shows, show, episodes, +episode, analytics, episode-analytics, subscribers, webhooks. Write commands: +show-update, episode-create, episode-update, episode-publish, +authorize-upload, subscriber-create, subscriber-batch, subscriber-delete, +webhook-create, webhook-delete. Show creation is dashboard-only (the API +cannot create shows). + +The API is JSON:API on responses (data/attributes/relationships/included); +write bodies are form-encoded with the documented bracket keys +(episode[title]=..., show[title]=..., subscriber[email]=...). Publishing +travels on the dedicated PATCH /v1/episodes/:id/publish endpoint with +episode[status]=draft|scheduled|published. + +API key from the environment variable TRANSISTOR_API_KEY (Account page -> +API Access: https://dashboard.transistor.fm/account). --help and --dry-run +never need it. +""" + +import argparse, json, os, re, sys, warnings +from typing import Any, Dict +warnings.simplefilter("ignore") +import requests + +ENV_KEY = "TRANSISTOR_API_KEY" +API_BASE = "https://api.transistor.fm/v1" +EPISODE_STATUSES = ("draft", "scheduled", "published") +WEBHOOK_EVENTS = ("episode_created", "episode_published", "subscriber_created", "subscriber_deleted") +DATE_RE = re.compile(r"^\d{2}-\d{2}-\d{4}$") + +QUIET = False +GLOBAL_FLAGS: Dict[str, Any] = {"json": False, "dry_run": False, "quiet": False, "verbose": False} + +def log(m): global QUIET; (not QUIET and not GLOBAL_FLAGS.get("json")) and print(m) +def warn(m): print(f"Warning: {m}", file=sys.stderr) +def die(m, c=1): print(f"Error: {m}", file=sys.stderr); sys.exit(c) +def emit(h, d): + if GLOBAL_FLAGS.get("json"): print(json.dumps(d, default=str)) + else: print(h) + +def _preparse(argv): + """Hoist global boolean flags so they work in any position.""" + BOOLS = {"--json","--dry-run","--force","--quiet","--verbose"} + f, fl = {}, [argv[0]] + i = 1 + while i < len(argv): + a = argv[i] + if a in BOOLS: f[a.lstrip("-").replace("-","_")] = True; i += 1 + elif a in ("--help","-h"): return f, argv + elif a == "--": fl.extend(argv[i:]); break + else: fl.append(a); i += 1 + return f, fl + +def _pagination(page, per): + params = {} + if page is not None: params["pagination[page]"] = page + if per is not None: params["pagination[per]"] = per + return params + +class TransistorClient: + def __init__(self, key="", dry_run=False): + self.key = key or os.getenv(ENV_KEY, ""); self.dry_run = dry_run + def _headers(self): + return {"x-api-key": self.key, "Accept": "application/json"} + def _error(self, r): + try: d = r.json() + except ValueError: die(f"Transistor API error ({r.status_code}): {r.text[:200]}") + errs = d.get("errors") if isinstance(d, dict) else None + if isinstance(errs, list) and errs: + parts = [] + for e in errs: + if isinstance(e, dict): + bits = [str(e[k]) for k in ("title", "detail", "code") if e.get(k)] + parts.append(": ".join(bits) if bits else json.dumps(e)) + else: + parts.append(str(e)) + die(f"Transistor API error ({r.status_code}): " + "; ".join(parts)) + if isinstance(d, dict) and d.get("message"): + die(f"Transistor API error ({r.status_code}): {d['message']}") + die(f"Transistor API error ({r.status_code}): {json.dumps(d, default=str)[:200]}") + def _request(self, method, path, params=None, body=None): + url = f"{API_BASE}{path}" + if self.dry_run: + plan = {"dry_run": True, "method": method.upper(), "path": path, "params": params or {}} + if body is not None: plan["body"] = body + return plan + if not self.key: + die(f"{ENV_KEY} not set. Find your API key under API Access on the Account page: " + "https://dashboard.transistor.fm/account") + try: + r = requests.request(method, url, params=params, data=body, headers=self._headers(), timeout=30) + except requests.RequestException as e: + die(f"Cannot reach api.transistor.fm: {e.__class__.__name__}: {e}") + if r.status_code == 401: + die(f"Transistor API rejected the API key (401 Unauthorized). Check {ENV_KEY} at https://dashboard.transistor.fm/account.") + if r.status_code == 403: + die("Transistor API denied access (403 Forbidden). The key is valid but your dashboard role " + "(owner/admin/team member) lacks access to this resource.") + if r.status_code == 404: + die(f"Transistor API resource not found (404): {path}. Verify the id or slug — " + "shows accept id or slug, episode ids come from `transistor episodes --json`.") + if r.status_code == 429: + die("Transistor API rate limit reached (429). The limit is 10 requests per 10 seconds and access " + "blocks for 10 seconds; wait and retry, cache responses, or batch your calls.") + if r.status_code >= 400: + self._error(r) + return r.json() + # ---- reads ---- + def get_user(self): + """Authorization check: GET /v1 returns the authenticated user resource.""" + return self._request("GET", "") + def list_shows(self, private=None, query="", page=None, per=None): + params = _pagination(page, per) + if private is not None: params["private"] = "true" if private else "false" + if query: params["query"] = query + return self._request("GET", "/shows", params) + def get_show(self, show_id): + return self._request("GET", f"/shows/{show_id}") + def list_episodes(self, show_id="", query="", status="", order="", page=None, per=None, include=""): + params = _pagination(page, per) + if show_id: params["show_id"] = show_id + if query: params["query"] = query + if status: params["status"] = status + if order: params["order"] = order + if include: params["include[]"] = include + return self._request("GET", "/episodes", params) + def get_episode(self, episode_id, include=""): + params = {"include[]": include} if include else None + return self._request("GET", f"/episodes/{episode_id}", params) + def show_analytics(self, show_id, start_date="", end_date=""): + params = {} + if start_date: params["start_date"] = start_date + if end_date: params["end_date"] = end_date + return self._request("GET", f"/analytics/{show_id}", params) + def episode_analytics(self, episode_id, start_date="", end_date=""): + params = {} + if start_date: params["start_date"] = start_date + if end_date: params["end_date"] = end_date + return self._request("GET", f"/analytics/episodes/{episode_id}", params) + def list_subscribers(self, show_id, query="", activated=None, page=None, per=None): + params = _pagination(page, per) + params["show_id"] = show_id + if query: params["query"] = query + if activated is not None: params["activated"] = "true" if activated else "false" + return self._request("GET", "/subscribers", params) + def list_webhooks(self, show_id): + return self._request("GET", "/webhooks", {"show_id": show_id}) + # ---- writes: bracket-key form bodies, exactly the documented curl shapes ---- + def update_show(self, show_id, fields): + """PATCH /shows/:id with show[...] keys (id may be a show id or slug).""" + body = {f"show[{k}]": v for k, v in fields.items()} + return self._request("PATCH", f"/shows/{show_id}", body=body) + def create_episode(self, show_id, title, **kw): + body = {"episode[show_id]": show_id} + if title: body["episode[title]"] = title + for k, v in kw.items(): + if v is not None: body[f"episode[{k}]"] = v + return self._request("POST", "/episodes", body=body) + def update_episode(self, episode_id, fields): + """PATCH /episodes/:id updates metadata or attaches audio. It never + changes publishing state; publishing has its own endpoint.""" + body = {f"episode[{k}]": v for k, v in fields.items()} + return self._request("PATCH", f"/episodes/{episode_id}", body=body) + def publish_episode(self, episode_id, status, published_at=""): + """PATCH /episodes/:id/publish — publish, schedule, or revert to draft.""" + body = {"episode[status]": status} + if published_at: body["episode[published_at]"] = published_at + return self._request("PATCH", f"/episodes/{episode_id}/publish", body=body) + def authorize_upload(self, filename): + return self._request("GET", "/episodes/authorize_upload", {"filename": filename}) + def upload_audio(self, upload_url, content_type, path): + with open(path, "rb") as fh: + r = requests.put(upload_url, data=fh, headers={"Content-Type": content_type}, timeout=600) + if r.status_code >= 400: + die(f"Audio upload failed ({r.status_code}): {r.text[:200]}") + return r + def create_subscriber(self, show_id, email, skip_welcome=False): + body = {"show_id": show_id, "email": email, "skip_welcome_email": "true" if skip_welcome else "false"} + return self._request("POST", "/subscribers", body=body) + def create_subscribers_batch(self, show_id, emails, skip_welcome=False): + body = {"show_id": show_id, "emails[]": list(emails), "skip_welcome_email": "true" if skip_welcome else "false"} + return self._request("POST", "/subscribers/batch", body=body) + def delete_subscriber(self, subscriber_id="", show_id="", email=""): + if subscriber_id: + return self._request("DELETE", f"/subscribers/{subscriber_id}") + return self._request("DELETE", "/subscribers", body={"show_id": show_id, "email": email}) + def create_webhook(self, show_id, event_name, url): + body = {"show_id": show_id, "event_name": event_name, "url": url} + return self._request("POST", "/webhooks", body=body) + def delete_webhook(self, webhook_id): + return self._request("DELETE", f"/webhooks/{webhook_id}") + +# ---- JSON:API unwrapping helpers (envelope: data / attributes / included) ---- +def _single(doc): + if not isinstance(doc, dict): return {} + data = doc.get("data", {}) + return data if isinstance(data, dict) else {} +def _items(doc): + if not isinstance(doc, dict): return [] + data = doc.get("data", []) + return data if isinstance(data, list) else [] +def _meta(doc): + if not isinstance(doc, dict): return {} + meta = doc.get("meta", {}) + return meta if isinstance(meta, dict) else {} +def _included_of_type(doc, rtype): + if not isinstance(doc, dict): return [] + inc = doc.get("included", []) + if not isinstance(inc, list): return [] + return [i for i in inc if isinstance(i, dict) and i.get("type") == rtype] +def _attrs(data_obj): + a = data_obj.get("attributes", {}) if isinstance(data_obj, dict) else {} + return a if isinstance(a, dict) else {} +def _sum_downloads(analytics_attrs): + rows = analytics_attrs.get("downloads") + total, days = 0, 0 + if isinstance(rows, list): + for row in rows: + if isinstance(row, dict): + days += 1 + try: total += int(row.get("downloads", 0)) + except (TypeError, ValueError): pass + return total, days + +# ---- single-sourced flag definitions ---- +# Handlers own their flags via these adders; main() builds the parser from the +# same functions and each handler parses its own argv slice with a local +# ArgumentParser (prog=) so tests can call handlers with raw argv lists. +def _add_shows_flags(p): + p.add_argument("--private", action="store_true", help="Only private shows") + p.add_argument("--query", help="Search shows by title") + p.add_argument("--page", type=int, default=None) + p.add_argument("--per", type=int, default=None, help="Page size (API default 10)") + +def _add_episodes_flags(p): + p.add_argument("--show", help="Show ID or slug filter") + p.add_argument("--status", choices=list(EPISODE_STATUSES), default=None) + p.add_argument("--query", help="Search episodes") + p.add_argument("--order", choices=["asc", "desc"], default=None, help="Default: desc (newest first)") + p.add_argument("--page", type=int, default=None) + p.add_argument("--per", type=int, default=None, help="Page size (API default 10)") + p.add_argument("--limit", dest="per", type=int, default=None, + help="Alias for --per (the old CLI's --limit)") + p.add_argument("--include", default=None, help="Compound document, e.g. 'show'") + +def _add_episode_id_flags(p, with_include=False): + p.add_argument("--id", required=True, help="Episode ID") + if with_include: + p.add_argument("--include", default=None, help="e.g. 'show' adds the parent show to included[]") + +def _add_episode_metadata_flags(p, create=False): + p.add_argument("--title", required=create, default=None) + p.add_argument("--season", type=int, default=None) + p.add_argument("--number", type=int, default=None) + p.add_argument("--summary", default=None, help="Short summary") + p.add_argument("--description", default=None, help="Long description (HTML allowed)") + p.add_argument("--audio-url", dest="audio_url", default=None, + help="http(s) URL of finished audio; attaches audio at creation") + p.add_argument("--author", default=None) + if create: + p.add_argument("--type", dest="episode_type", choices=["full", "trailer", "bonus"], default=None) + p.add_argument("--increment-number", dest="increment_number", action="store_true", + help="Auto-set number to the next episode of the current season") + +def _add_analytics_flags(p): + p.add_argument("--start-date", dest="start_date", default=None, help="dd-mm-yyyy (requires --end-date)") + p.add_argument("--end-date", dest="end_date", default=None, help="dd-mm-yyyy (requires --start-date)") + +def _add_subscriber_list_flags(p): + p.add_argument("--query", help="Search subscribers") + p.add_argument("--activated", dest="activated", action="store_true", + help="Only subscribers who activated") + p.add_argument("--page", type=int, default=None) + p.add_argument("--per", type=int, default=None) + p.add_argument("--limit", dest="per", type=int, default=None, + help="Alias for --per (the old CLI's --limit)") + +def cmd_user(client, raw_args): + p = argparse.ArgumentParser(prog="transistor user", description="Who am I? (GET /v1 authorization check)") + args = p.parse_args(raw_args) + if client.dry_run: return emit("[dry-run] GET /v1 (authorization check)", client.get_user()) + d = client.get_user() or {} + data = _single(d) + u = _attrs(data) + emit(f"{u.get('name', '?')} (time zone: {u.get('time_zone', '?')})", + {"id": data.get("id"), "type": data.get("type", "user"), + "name": u.get("name"), "time_zone": u.get("time_zone"), "image_url": u.get("image_url")}) + +def cmd_shows(client, raw_args): + p = argparse.ArgumentParser(prog="transistor shows", description="List your shows (newest-updated first)") + _add_shows_flags(p) + args = p.parse_args(raw_args) + if client.dry_run: return emit("[dry-run] GET /shows", client.list_shows( + private=args.private, query=args.query or "", page=args.page, per=args.per)) + d = client.list_shows(private=args.private, query=args.query or "", page=args.page, per=args.per) or {} + shows = _items(d) + if not shows: return emit("No shows.", {"shows": [], "meta": _meta(d)}) + lines, out = [], [] + for s in shows: + a = _attrs(s) + t = a.get("title", "?") + lines.append(f" {t} slug={a.get('slug', '?')} id={s.get('id', '?')}" + + (" [private]" if a.get("private") else "")) + out.append({"id": s.get("id"), "type": s.get("type", "show"), "title": t, "slug": a.get("slug", ""), + "private": a.get("private"), "show_type": a.get("show_type", ""), + "feed_url": a.get("feed_url", "")}) + emit(f"{len(shows)} show(s):\n" + "\n".join(lines), {"shows": out, "meta": _meta(d)}) + +def cmd_show(client, raw_args): + p = argparse.ArgumentParser(prog="transistor show", description="One show's full attributes") + p.add_argument("--id", required=True, help="Show ID or slug") + args = p.parse_args(raw_args) + if client.dry_run: return emit(f"[dry-run] GET /shows/{args.id}", client.get_show(args.id)) + d = client.get_show(args.id) or {} + data = _single(d) + a = _attrs(data) + emit(f"{a.get('title', '?')} (id={data.get('id', '?')}, slug={a.get('slug', '?')})", + {"id": data.get("id"), "type": data.get("type", "show"), "title": a.get("title"), + "slug": a.get("slug", ""), "description": a.get("description", ""), + "show_type": a.get("show_type", ""), "private": a.get("private"), + "feed_url": a.get("feed_url", ""), "time_zone": a.get("time_zone", ""), + "author": a.get("author", ""), "website": a.get("website", "")}) + +_SHOW_UPDATE_FIELDS = (("--title", "title", None), ("--description", "description", None), + ("--author", "author", None), ("--website", "website", None), + ("--keywords", "keywords", None), ("--copyright", "copyright", None), + ("--owner-email", "owner_email", None), ("--time-zone", "time_zone", None), + ("--show-type", "show_type", ("episodic", "serial"))) + +def cmd_show_update(client, raw_args): + p = argparse.ArgumentParser(prog="transistor show-update", description="Update show metadata (PATCH /shows/:id)") + p.add_argument("--id", required=True, help="Show ID or slug") + for flag, dest, choices in _SHOW_UPDATE_FIELDS: + p.add_argument(flag, dest=dest, choices=choices, default=None) + args = p.parse_args(raw_args) + fields = {} + for flag, dest, choices in _SHOW_UPDATE_FIELDS: + v = getattr(args, dest, None) + if v is not None: fields[dest] = v + if not fields: + die("Nothing to update: pass at least one of --title, --description, --author, --website, " + "--keywords, --copyright, --owner-email, --time-zone, --show-type.") + if client.dry_run: return emit(f"[dry-run] PATCH /shows/{args.id}", client.update_show(args.id, fields)) + d = client.update_show(args.id, fields) or {} + a = _attrs(_single(d)) + emit(f"Updated show {args.id}.", {"id": args.id, "title": a.get("title"), + "updated_fields": sorted(fields.keys())}) + +def cmd_episodes(client, raw_args): + p = argparse.ArgumentParser(prog="transistor episodes", description="List episodes (ordered by publish date)") + _add_episodes_flags(p) + args = p.parse_args(raw_args) + if client.dry_run: return emit("[dry-run] GET /episodes", client.list_episodes( + show_id=args.show or "", query=args.query or "", status=args.status or "", + order=args.order or "", page=args.page, per=args.per, include=args.include or "")) + d = client.list_episodes(show_id=args.show or "", query=args.query or "", status=args.status or "", + order=args.order or "", page=args.page, per=args.per, + include=args.include or "") or {} + eps = _items(d) + if not eps: return emit("No episodes.", {"episodes": [], "meta": _meta(d)}) + lines, out = [], [] + for e in eps: + a = _attrs(e) + eid = e.get("id", "?") + t = a.get("title", "?") + status = a.get("status", "?") + pub = a.get("published_at") or "" + seas = a.get("season", "") + num = a.get("number", "") + lines.append(f" S{seas}E{num} [{status}] {t} (id={eid})") + out.append({"id": eid, "type": e.get("type", "episode"), "title": t, "status": status, + "season": seas, "number": num, "duration": a.get("duration"), + "published_at": pub, "media_url": a.get("media_url", ""), + "share_url": a.get("share_url", ""), + "show_id": ((e.get("relationships", {}) or {}).get("show", {}) or {}).get("data", {}).get("id", "")}) + emit(f"{len(eps)} episode(s):\n" + "\n".join(lines), {"episodes": out, "meta": _meta(d)}) + if args.include and "show" in args.include: + for s in _included_of_type(d, "show"): + log(f" included show: {_attrs(s).get('title', '?')} (id={s.get('id', '?')})") + +def cmd_episode(client, raw_args): + p = argparse.ArgumentParser(prog="transistor episode", description="One episode's full attributes") + _add_episode_id_flags(p, with_include=True) + args = p.parse_args(raw_args) + if client.dry_run: return emit(f"[dry-run] GET /episodes/{args.id}", client.get_episode(args.id, include=args.include or "")) + d = client.get_episode(args.id, include=args.include or "") or {} + data = _single(d) + a = _attrs(data) + media = a.get("media_url") or "" + emit(f"{a.get('title', '?')} [{a.get('status', '?')}] (id={data.get('id', '?')})" + + (f" media: {media}" if media else " (no audio attached yet)"), + {"id": data.get("id"), "type": data.get("type", "episode"), "title": a.get("title"), + "status": a.get("status"), "season": a.get("season"), "number": a.get("number"), + "duration": a.get("duration"), "duration_in_mmss": a.get("duration_in_mmss", ""), + "media_url": media, "share_url": a.get("share_url", ""), + "published_at": a.get("published_at"), "audio_processing": a.get("audio_processing"), + "processing_failure": a.get("processing_failure"), + "show_id": ((data.get("relationships", {}) or {}).get("show", {}) or {}).get("data", {}).get("id", "")}) + if args.include and "show" in args.include: + for s in _included_of_type(d, "show"): + log(f" included show: {_attrs(s).get('title', '?')} (id={s.get('id', '?')})") + +def cmd_episode_create(client, raw_args): + p = argparse.ArgumentParser(prog="transistor episode-create", + description="Create an episode (always created as DRAFT)") + p.add_argument("--show", required=True, help="Show ID or slug to attach the episode to") + _add_episode_metadata_flags(p, create=True) + args = p.parse_args(raw_args) + fields: Dict[str, Any] = {} + if args.season is not None: fields["season"] = args.season + if args.number is not None: fields["number"] = args.number + if args.summary: fields["summary"] = args.summary + if args.description: fields["description"] = args.description + if args.audio_url: fields["audio_url"] = args.audio_url + if args.author: fields["author"] = args.author + if args.episode_type: fields["type"] = args.episode_type + if args.increment_number: fields["increment_number"] = "true" + if client.dry_run: + plan = client.create_episode(args.show, args.title, **fields) + return emit("[dry-run] POST /episodes (creates a DRAFT; publishing is a separate endpoint)", plan) + d = client.create_episode(args.show, args.title, **fields) or {} + data = _single(d) + a = _attrs(data) + eid = data.get("id", "?") + hint = "" if a.get("status") == "published" else f" Publish it with: transistor episode-publish --id {eid}" + emit(f"Created episode '{a.get('title', '?')}' (id={eid}, status={a.get('status', '?')}).{hint}", + {"id": eid, "title": a.get("title"), "status": a.get("status"), + "media_url": a.get("media_url", ""), "published_at": a.get("published_at")}) + +def cmd_episode_update(client, raw_args): + p = argparse.ArgumentParser(prog="transistor episode-update", + description="Update episode metadata or attach audio (never publishes)") + _add_episode_id_flags(p) + _add_episode_metadata_flags(p, create=False) + args = p.parse_args(raw_args) + fields = {} + if args.title is not None: fields["title"] = args.title + if args.summary is not None: fields["summary"] = args.summary + if args.description is not None: fields["description"] = args.description + if args.season is not None: fields["season"] = args.season + if args.number is not None: fields["number"] = args.number + if args.audio_url: fields["audio_url"] = args.audio_url + if args.author is not None: fields["author"] = args.author + if not fields: + die("Nothing to update: pass at least one of --title, --summary, --description, --season, " + "--number, --audio-url, --author. (Publishing state is changed by episode-publish, not here.)") + if client.dry_run: return emit(f"[dry-run] PATCH /episodes/{args.id}", client.update_episode(args.id, fields)) + d = client.update_episode(args.id, fields) or {} + a = _attrs(_single(d)) + emit(f"Updated episode {args.id} ({', '.join(sorted(fields.keys()))}).", + {"id": args.id, "title": a.get("title"), "status": a.get("status"), + "media_url": a.get("media_url", ""), "published_at": a.get("published_at")}) + +def cmd_episode_publish(client, raw_args): + p = argparse.ArgumentParser(prog="transistor episode-publish", + description="Publish, schedule, or unpublish (PATCH /episodes/:id/publish)") + _add_episode_id_flags(p) + p.add_argument("--status", choices=list(EPISODE_STATUSES), default="published", + help="Target state (default: published; scheduled needs --published-at; draft unpublishes)") + p.add_argument("--published-at", dest="published_at", default=None, + help="Publish datetime in the show's time zone (with --status published or scheduled)") + p.add_argument("--force", action="store_true", help="Publish even if no audio is attached") + args = p.parse_args(raw_args) + if not client.dry_run and args.status == "published" and not args.force: + current = client.get_episode(args.id) + a = _attrs(_single(current)) + if not (a.get("media_url") or "").strip(): + die(f"Episode {args.id} has no audio yet (attributes.media_url is empty). Attach audio first: " + f"transistor episode-update --id {args.id} --audio-url (or episode-create --audio-url). " + "Pass --force to publish anyway.") + if client.dry_run: + plan = client.publish_episode(args.id, args.status, args.published_at or "") + return emit(f"[dry-run] PATCH /episodes/{args.id}/publish (episode[status]={args.status})", plan) + d = client.publish_episode(args.id, args.status, args.published_at or "") or {} + data = _single(d) + a = _attrs(data) + verb = {"published": "published", "scheduled": "scheduled", "draft": "reverted to draft"}[args.status] + emit(f"Episode {data.get('id', args.id)} {verb}.", + {"id": data.get("id", args.id), "type": data.get("type", "episode"), + "status": a.get("status"), "published_at": a.get("published_at"), + "media_url": a.get("media_url", "")}) + +def cmd_authorize_upload(client, raw_args): + p = argparse.ArgumentParser(prog="transistor authorize-upload", + description="Authorize a local audio/video upload (max 5GB)") + p.add_argument("--filename", required=True, help="Filename of the audio file") + p.add_argument("--file", default=None, help="Local path to PUT now (otherwise you upload it yourself)") + args = p.parse_args(raw_args) + if client.dry_run: + plan = client.authorize_upload(args.filename) + plan["then_put"] = {"file": args.file or "(none — upload yourself)", + "how": "HTTP PUT the file bytes to attributes.upload_url with " + "Content-Type: attributes.content_type; then attach attributes.audio_url"} + return emit("[dry-run] GET /episodes/authorize_upload", plan) + d = client.authorize_upload(args.filename) or {} + a = _attrs(_single(d)) + if args.file: + client.upload_audio(a.get("upload_url", ""), a.get("content_type", ""), args.file) + log(f"Uploaded {args.file} to the authorized URL.") + emit(f"Authorized upload for {args.filename} (expires in {a.get('expires_in', '?')}s).\n" + f" audio_url to attach: {a.get('audio_url', '?')}", + {"audio_url": a.get("audio_url"), "upload_url": a.get("upload_url"), + "content_type": a.get("content_type"), "expires_in": a.get("expires_in"), + "uploaded": bool(args.file)}) + +def cmd_analytics(client, raw_args): + p = argparse.ArgumentParser(prog="transistor analytics", + description="Show downloads per day (default: last 14 days)") + p.add_argument("--show", required=True, help="Show ID or slug") + _add_analytics_flags(p) + args = p.parse_args(raw_args) + if client.dry_run: return emit(f"[dry-run] GET /analytics/{args.show}", client.show_analytics( + args.show, args.start_date or "", args.end_date or "")) + d = client.show_analytics(args.show, args.start_date or "", args.end_date or "") or {} + a = _attrs(_single(d)) + total, days = _sum_downloads(a) + emit(f"Show {args.show} — {total} downloads over {days} day(s)" + f" ({a.get('start_date', '?')} .. {a.get('end_date', '?')})", + {"show_id": args.show, "downloads_total": total, "days": days, + "start_date": a.get("start_date"), "end_date": a.get("end_date"), + "downloads": a.get("downloads", [])}) + +def cmd_episode_analytics(client, raw_args): + p = argparse.ArgumentParser(prog="transistor episode-analytics", + description="One episode's downloads per day (default: last 14 days)") + p.add_argument("--id", required=True, help="Episode ID or slug") + _add_analytics_flags(p) + args = p.parse_args(raw_args) + if client.dry_run: return emit(f"[dry-run] GET /analytics/episodes/{args.id}", client.episode_analytics( + args.id, args.start_date or "", args.end_date or "")) + d = client.episode_analytics(args.id, args.start_date or "", args.end_date or "") or {} + a = _attrs(_single(d)) + total, days = _sum_downloads(a) + emit(f"Episode {args.id} — {total} downloads over {days} day(s)" + f" ({a.get('start_date', '?')} .. {a.get('end_date', '?')})", + {"episode_id": args.id, "downloads_total": total, "days": days, + "start_date": a.get("start_date"), "end_date": a.get("end_date"), + "downloads": a.get("downloads", [])}) + +def cmd_subscribers(client, raw_args): + p = argparse.ArgumentParser(prog="transistor subscribers", + description="List a private podcast's subscribers") + p.add_argument("--show", required=True, help="Show ID or slug") + _add_subscriber_list_flags(p) + args = p.parse_args(raw_args) + if client.dry_run: return emit(f"[dry-run] GET /subscribers?show_id={args.show}", client.list_subscribers( + args.show, query=args.query or "", activated=args.activated, page=args.page, per=args.per)) + d = client.list_subscribers(args.show, query=args.query or "", activated=args.activated, + page=args.page, per=args.per) or {} + subs = _items(d) + if not subs: return emit("No subscribers.", {"subscribers": [], "meta": _meta(d)}) + lines, out = [], [] + for s in subs: + a = _attrs(s) + email = a.get("email", "?") + lines.append(f" {email} id={s.get('id', '?')}") + out.append({"id": s.get("id"), "type": s.get("type", "subscriber"), "email": email, + "status": a.get("status", ""), "feed_url": a.get("feed_url", ""), + "subscribe_url": a.get("subscribe_url", ""), "has_downloads": a.get("has_downloads")}) + emit(f"{len(subs)} subscriber(s):\n" + "\n".join(lines), {"subscribers": out, "meta": _meta(d)}) + +def cmd_subscriber_create(client, raw_args): + p = argparse.ArgumentParser(prog="transistor subscriber-create", + description="Add one private-podcast subscriber") + p.add_argument("--show", required=True, help="Show ID or slug") + p.add_argument("--email", required=True) + p.add_argument("--skip-welcome-email", dest="skip_welcome_email", action="store_true") + args = p.parse_args(raw_args) + if client.dry_run: + return emit(f"[dry-run] POST /subscribers (add {args.email} to show {args.show})", + client.create_subscriber(args.show, args.email, skip_welcome=args.skip_welcome_email)) + d = client.create_subscriber(args.show, args.email, skip_welcome=args.skip_welcome_email) or {} + data = _single(d) + a = _attrs(data) + emit(f"Subscribed {a.get('email', args.email)} to show {args.show}.", + {"id": data.get("id"), "email": a.get("email"), "status": a.get("status", ""), + "subscribe_url": a.get("subscribe_url", "")}) + +def cmd_subscriber_batch(client, raw_args): + p = argparse.ArgumentParser(prog="transistor subscriber-batch", + description="Add several subscribers (repeat --email)") + p.add_argument("--show", required=True, help="Show ID or slug") + p.add_argument("--email", action="append", required=True, help="Repeat for each address") + p.add_argument("--skip-welcome-email", dest="skip_welcome_email", action="store_true") + args = p.parse_args(raw_args) + if client.dry_run: + return emit(f"[dry-run] POST /subscribers/batch (add {len(args.email)} subscriber(s) to show {args.show})", + client.create_subscribers_batch(args.show, args.email, skip_welcome=args.skip_welcome_email)) + d = client.create_subscribers_batch(args.show, args.email, skip_welcome=args.skip_welcome_email) or {} + out = [{"id": s.get("id"), "email": _attrs(s).get("email")} for s in _items(d)] + emit(f"Added {len(out)} subscriber(s) to show {args.show}.", {"subscribers": out}) + +def cmd_subscriber_delete(client, raw_args): + p = argparse.ArgumentParser(prog="transistor subscriber-delete", + description="Revoke a subscriber's private-feed access") + p.add_argument("--id", default=None, help="Subscriber ID (or use --show + --email)") + p.add_argument("--show", default=None, help="Show ID or slug (with --email)") + p.add_argument("--email", default=None, help="Email address (with --show)") + args = p.parse_args(raw_args) + if not args.id and not (args.show and args.email): + die("Specify either --id, or both --show and --email (delete by email address).") + if client.dry_run: + plan = (client.delete_subscriber(subscriber_id=args.id) if args.id + else client.delete_subscriber(show_id=args.show, email=args.email)) + return emit("[dry-run] DELETE subscriber (revokes private-feed access)", plan) + d = (client.delete_subscriber(subscriber_id=args.id) if args.id + else client.delete_subscriber(show_id=args.show, email=args.email)) or {} + a = _attrs(_single(d)) + emit(f"Revoked private-feed access for {a.get('email', args.email or args.id)}.", + {"id": _single(d).get("id"), "email": a.get("email")}) + +def cmd_webhooks(client, raw_args): + p = argparse.ArgumentParser(prog="transistor webhooks", description="List a show's webhooks") + p.add_argument("--show", required=True, help="Show ID or slug") + args = p.parse_args(raw_args) + if client.dry_run: return emit(f"[dry-run] GET /webhooks?show_id={args.show}", client.list_webhooks(args.show)) + d = client.list_webhooks(args.show) or {} + hooks = _items(d) + if not hooks: return emit("No webhooks.", {"webhooks": []}) + lines, out = [], [] + for w in hooks: + a = _attrs(w) + lines.append(f" {a.get('event_name', '?')} -> {a.get('url', '?')} (id={w.get('id', '?')})") + out.append({"id": w.get("id"), "event_name": a.get("event_name"), "url": a.get("url")}) + emit(f"{len(hooks)} webhook(s):\n" + "\n".join(lines), {"webhooks": out}) + +def cmd_webhook_create(client, raw_args): + p = argparse.ArgumentParser(prog="transistor webhook-create", + description="Subscribe a webhook (max 50 per account)") + p.add_argument("--show", required=True, help="Show ID or slug") + p.add_argument("--event", required=True, choices=list(WEBHOOK_EVENTS)) + p.add_argument("--url", required=True, help="Delivery target URL") + args = p.parse_args(raw_args) + if client.dry_run: + return emit(f"[dry-run] POST /webhooks ({args.event} on show {args.show})", + client.create_webhook(args.show, args.event, args.url)) + d = client.create_webhook(args.show, args.event, args.url) or {} + data = _single(d) + a = _attrs(data) + emit(f"Webhook {data.get('id', '?')} created ({a.get('event_name', args.event)}).", + {"id": data.get("id"), "event_name": a.get("event_name"), "url": a.get("url")}) + +def cmd_webhook_delete(client, raw_args): + p = argparse.ArgumentParser(prog="transistor webhook-delete", description="Unsubscribe a webhook") + p.add_argument("--id", required=True, help="Webhook ID") + args = p.parse_args(raw_args) + if client.dry_run: return emit(f"[dry-run] DELETE /webhooks/{args.id}", client.delete_webhook(args.id)) + client.delete_webhook(args.id) + emit(f"Webhook {args.id} deleted.", {"id": args.id, "deleted": True}) + +def _check_dates(args): + for v in (args.start_date, args.end_date): + if v and not DATE_RE.match(v): + die(f"Invalid date '{v}': analytics dates use dd-mm-yyyy (e.g. 01-09-2026).") + if bool(args.start_date) != bool(args.end_date): + die("start_date and end_date must be used together (or omit both for the default window).") + +def main(): + global GLOBAL_FLAGS, QUIET + GLOBAL_FLAGS, filtered_argv = _preparse(sys.argv) + if GLOBAL_FLAGS.get("quiet"): QUIET = True + parser = argparse.ArgumentParser( + prog="transistor", + description="Transistor.fm podcast hosting from the terminal.", + epilog=f"Set {ENV_KEY} — find your key under API Access on the Account page " + "(https://dashboard.transistor.fm/account). --help and --dry-run never need it. " + "API docs: https://developers.transistor.fm/") + sub = parser.add_subparsers(dest="command") + + sub.add_parser("user", help="Who am I? (GET /v1 authorization check)") + + p_shows = sub.add_parser("shows", help="List your shows (newest-updated first)") + _add_shows_flags(p_shows) + p_show = sub.add_parser("show", help="One show's full attributes") + p_show.add_argument("--id", required=True, help="Show ID or slug") + p_showu = sub.add_parser("show-update", help="Update show metadata (PATCH /shows/:id)") + p_showu.add_argument("--id", required=True, help="Show ID or slug") + for flag, dest, choices in _SHOW_UPDATE_FIELDS: + p_showu.add_argument(flag, dest=dest, choices=choices, default=None) + + p_eps = sub.add_parser("episodes", help="List episodes (ordered by publish date)") + _add_episodes_flags(p_eps) + p_ep = sub.add_parser("episode", help="One episode's full attributes") + _add_episode_id_flags(p_ep, with_include=True) + p_ec = sub.add_parser("episode-create", help="Create an episode (always created as DRAFT)") + p_ec.add_argument("--show", required=True, help="Show ID or slug to attach the episode to") + _add_episode_metadata_flags(p_ec, create=True) + p_eu = sub.add_parser("episode-update", help="Update episode metadata or attach audio (never publishes)") + _add_episode_id_flags(p_eu) + _add_episode_metadata_flags(p_eu, create=False) + p_epb = sub.add_parser("episode-publish", help="Publish, schedule, or unpublish (PATCH /episodes/:id/publish)") + _add_episode_id_flags(p_epb) + p_epb.add_argument("--status", choices=list(EPISODE_STATUSES), default="published", + help="Target state (default: published; scheduled needs --published-at; draft unpublishes)") + p_epb.add_argument("--published-at", dest="published_at", default=None, + help="Publish datetime in the show's time zone (with --status published or scheduled)") + p_epb.add_argument("--force", action="store_true", help="Publish even if no audio is attached") + + p_au = sub.add_parser("authorize-upload", help="Authorize a local audio/video upload (max 5GB)") + p_au.add_argument("--filename", required=True, help="Filename of the audio file") + p_au.add_argument("--file", default=None, help="Local path to PUT now (otherwise you upload it yourself)") + + p_ana = sub.add_parser("analytics", help="Show downloads per day (default: last 14 days)") + p_ana.add_argument("--show", required=True, help="Show ID or slug") + _add_analytics_flags(p_ana) + p_eana = sub.add_parser("episode-analytics", help="One episode's downloads per day (default: last 14 days)") + p_eana.add_argument("--id", required=True, help="Episode ID or slug") + _add_analytics_flags(p_eana) + + p_subs = sub.add_parser("subscribers", help="List a private podcast's subscribers") + p_subs.add_argument("--show", required=True, help="Show ID or slug") + _add_subscriber_list_flags(p_subs) + p_subc = sub.add_parser("subscriber-create", help="Add one private-podcast subscriber") + p_subc.add_argument("--show", required=True, help="Show ID or slug") + p_subc.add_argument("--email", required=True) + p_subc.add_argument("--skip-welcome-email", dest="skip_welcome_email", action="store_true") + p_subb = sub.add_parser("subscriber-batch", help="Add several subscribers (repeat --email)") + p_subb.add_argument("--show", required=True, help="Show ID or slug") + p_subb.add_argument("--email", action="append", required=True, help="Repeat for each address") + p_subb.add_argument("--skip-welcome-email", dest="skip_welcome_email", action="store_true") + p_subd = sub.add_parser("subscriber-delete", help="Revoke a subscriber's private-feed access") + p_subd.add_argument("--id", default=None, help="Subscriber ID (or use --show + --email)") + p_subd.add_argument("--show", default=None, help="Show ID or slug (with --email)") + p_subd.add_argument("--email", default=None, help="Email address (with --show)") + + p_wh = sub.add_parser("webhooks", help="List a show's webhooks") + p_wh.add_argument("--show", required=True, help="Show ID or slug") + p_whc = sub.add_parser("webhook-create", help="Subscribe a webhook (max 50 per account)") + p_whc.add_argument("--show", required=True, help="Show ID or slug") + p_whc.add_argument("--event", required=True, choices=list(WEBHOOK_EVENTS)) + p_whc.add_argument("--url", required=True, help="Delivery target URL") + p_whd = sub.add_parser("webhook-delete", help="Unsubscribe a webhook") + p_whd.add_argument("--id", required=True, help="Webhook ID") + + args = parser.parse_args(filtered_argv[1:]) + if not args.command: parser.print_help(); sys.exit(1) + if getattr(args, "start_date", None) or getattr(args, "end_date", None): + _check_dates(args) + client = TransistorClient(dry_run=GLOBAL_FLAGS.get("dry_run", False)) + handlers = {"user": cmd_user, "shows": cmd_shows, "show": cmd_show, "show-update": cmd_show_update, + "episodes": cmd_episodes, "episode": cmd_episode, "episode-create": cmd_episode_create, + "episode-update": cmd_episode_update, "episode-publish": cmd_episode_publish, + "authorize-upload": cmd_authorize_upload, "analytics": cmd_analytics, + "episode-analytics": cmd_episode_analytics, "subscribers": cmd_subscribers, + "subscriber-create": cmd_subscriber_create, "subscriber-batch": cmd_subscriber_batch, + "subscriber-delete": cmd_subscriber_delete, "webhooks": cmd_webhooks, + "webhook-create": cmd_webhook_create, "webhook-delete": cmd_webhook_delete} + # Hoisted flags were removed from filtered_argv, so the first occurrence of + # the command token is the dispatch point; hand the rest to the handler's + # own parser (it re-declares the same single-sourced flags). + start = filtered_argv.index(args.command) + 1 + handlers[args.command](client, filtered_argv[start:]) + +if __name__ == "__main__": main() diff --git a/transistor/scripts/transistor-cli b/transistor/scripts/transistor-cli deleted file mode 100755 index 5d3d3f3..0000000 --- a/transistor/scripts/transistor-cli +++ /dev/null @@ -1,160 +0,0 @@ -#!/usr/bin/env python3 -"""transistor-cli — Transistor.fm podcast hosting from the terminal. - -Manage shows, episodes, subscribers, and analytics on Transistor.fm. -API key from transistor.fm → Settings → API Keys. -""" - -import argparse, json, os, sys, warnings -from typing import Any, Dict, List, Optional, Tuple -warnings.simplefilter("ignore") -import requests - -ENV_KEY = os.getenv("TRANSISTOR_API_KEY", "") -API_BASE = "https://api.transistor.fm/v1" - -QUIET = False -GLOBAL_FLAGS: Dict[str, Any] = {"json": False, "dry_run": False, "quiet": False, "verbose": False} -def log(m): global QUIET; (not QUIET and not GLOBAL_FLAGS.get("json")) and print(m) -def warn(m): print(f"Warning: {m}", file=sys.stderr) -def die(m, c=1): print(f"Error: {m}", file=sys.stderr); sys.exit(c) -def emit(h, d): - if GLOBAL_FLAGS.get("json"): print(json.dumps(d, default=str)) - else: print(h) - -def _preparse(argv): - BOOLS = {"--json","--dry-run","--force","--quiet","--verbose"} - f, fl = {}, [argv[0]] - i = 1 - while i < len(argv): - a = argv[i] - if a in BOOLS: f[a.lstrip("-").replace("-","_")] = True; i += 1 - elif a in ("--help","-h"): return f, argv - elif a == "--": fl.extend(argv[i:]); break - else: fl.append(a); i += 1 - return f, fl - -class TransistorClient: - def __init__(self, key="", dry_run=False): - self.key = key or ENV_KEY; self.dry_run = dry_run - def _headers(self): - return {"x-api-key": self.key, "Accept": "application/json"} - def _get(self, path, params=None): - url = f"{API_BASE}{path}" - if self.dry_run: return {"data":[], "dry_run":True, "url":url, "params":params} - if not self.key: die("TRANSISTOR_API_KEY not set. Get one from transistor.fm → Settings → API Keys.") - try: - r = requests.get(url, params=params, headers=self._headers(), timeout=30) - except ConnectionError as e: die(f"Cannot connect: {e}") - if r.status_code in (401,403): die(f"Auth failed ({r.status_code}). Check TRANSISTOR_API_KEY.") - if r.status_code >= 400: - try: d = r.json(); die(f"API error: {d.get('errors', d)}") - except: die(f"API error ({r.status_code}): {r.text[:200]}") - return r.json() - def _post(self, path, data=None): - url = f"{API_BASE}{path}" - if self.dry_run: return {"dry_run":True, "url":url, "data":data} - if not self.key: die("TRANSISTOR_API_KEY not set.") - r = requests.post(url, json=data, headers={**self._headers(), "Content-Type":"application/json"}, timeout=30) - if r.status_code >= 400: - try: d = r.json(); die(f"API error: {d.get('errors', d)}") - except: die(f"API error ({r.status_code}): {r.text[:200]}") - return r.json() - def get_user(self): return self._get("/user") - def list_shows(self): return self._get("/shows") - def get_show(self, sid): return self._get(f"/shows/{sid}") - def update_show(self, sid, **kw): return self._get(f"/shows/{sid}", kw) - def list_episodes(self, show_id="", limit=50): - p = {"pagination[limit]": limit} - if show_id: p["filter[show_id]"] = show_id - return self._get("/episodes", p) - def get_episode(self, eid): return self._get(f"/episodes/{eid}") - def create_episode(self, show_id, title, **kw): - data = {"episode": {"show_id": show_id, "title": title, **kw}} - return self._post("/episodes", data) - def show_analytics(self, show_id=""): - p = {} - if show_id: p["filter[show_id]"] = show_id - return self._get("/analytics/show", p) - def list_subscribers(self, show_id="", page=1): - p = {"page": page} - if show_id: p["filter[show_id]"] = show_id - return self._get("/subscribers", p) - -def cmd_user(client, args): - if client.dry_run: return emit("[dry-run] Get user", {"dry_run":True}) - d = client.get_user() or {} - u = d.get("data",{}).get("attributes",{}) - emit(f"👤 {u.get('email','?')} ({u.get('time_zone','?')})", - {"email":u.get("email"),"timezone":u.get("time_zone")}) - -def cmd_shows(client, args): - if client.dry_run: return emit("[dry-run] List shows", {"dry_run":True}) - data = client.list_shows() or {} - shows = data.get("data",[]) - if not shows: return emit("No shows.", {"shows":[]}) - lines, out = [], [] - for s in shows: - a = s.get("attributes",{}); sid = s.get("id","?") - t = a.get("title","?"); es = a.get("episodes_count",0); subs = a.get("subscribers_count",0) - lines.append(f" {t:40} {es} episodes {subs} subscribers id={sid}") - out.append({"id":sid,"title":t,"episodes":es,"subscribers":subs}) - emit(f"{len(shows)} show(s):\n"+"\n".join(lines), {"shows":out}) - -def cmd_episodes(client, args): - p = argparse.ArgumentParser(prog="transistor episodes") - p.add_argument("--show", help="Show ID filter") - p.add_argument("--limit", type=int, default=20) - parsed, _ = p.parse_known_args(args) - if client.dry_run: return emit("[dry-run] List episodes", {"dry_run":True}) - data = client.list_episodes(show_id=parsed.show or "", limit=parsed.limit) or {} - eps = data.get("data",[]) - if not eps: return emit("No episodes.", {"episodes":[]}) - lines, out = [], [] - for e in eps: - a = e.get("attributes",{}); eid = e.get("id","?") - t = a.get("title","?"); status = a.get("status","?"); dur = a.get("duration","?") - pub = (a.get("published_at") or "")[:10] - seas = a.get("season",0); epnum = a.get("number",0) - lines.append(f" S{seas:02d}E{epnum:02d} {t:45} [{status}] {dur}min pub {pub} id={eid}") - out.append({"id":eid,"title":t,"status":status,"duration":dur,"published":pub,"season":seas,"number":epnum}) - emit(f"{len(eps)} episode(s):\n"+"\n".join(lines), {"episodes":out}) - -def cmd_analytics(client, args): - p = argparse.ArgumentParser(prog="transistor analytics") - p.add_argument("--show", help="Show ID filter") - parsed, _ = p.parse_known_args(args) - if client.dry_run: return emit("[dry-run] Get analytics", {"dry_run":True}) - data = client.show_analytics(show_id=parsed.show or "") or {} - d = data.get("data",{}) - attrs = d.get("attributes",{}) if isinstance(d, dict) else {} - totals = attrs.get("totals",{}) - downloads = totals.get("downloads",{}).get("total","?") - plays = totals.get("plays",{}).get("total","?") if "plays" in totals else "N/A" - emit(f"📊 Downloads: {downloads} Plays: {plays}", - {"downloads":downloads,"plays":plays,"totals":totals}) - -def main(): - global GLOBAL_FLAGS, QUIET - GLOBAL_FLAGS, filtered_argv = _preparse(sys.argv) - if GLOBAL_FLAGS.get("quiet"): QUIET = True - if GLOBAL_FLAGS.get("json"): warnings.simplefilter("ignore") - parser = argparse.ArgumentParser(prog="transistor", description="Transistor.fm podcast hosting.", - epilog="Set TRANSISTOR_API_KEY. Get one from transistor.fm → Settings → API Keys.") - sub = parser.add_subparsers(dest="command") - sub.add_parser("user") - sub.add_parser("shows") - p_eps = sub.add_parser("episodes", help="List episodes") - p_eps.add_argument("--show") - p_eps.add_argument("--limit", type=int, default=20) - sub.add_parser("analytics", help="Show analytics").add_argument("--show") - args = parser.parse_args(filtered_argv[1:]) - if not args.command: parser.print_help(); sys.exit(1) - client = TransistorClient(dry_run=GLOBAL_FLAGS.get("dry_run",False)) - handlers = {"user":cmd_user,"shows":cmd_shows,"episodes":cmd_episodes,"analytics":cmd_analytics} - h = handlers.get(args.command) - if not h: parser.print_help(); sys.exit(1) - r = filtered_argv[filtered_argv.index(args.command)+1:] - h(client, r) - -if __name__ == "__main__": main()