Claude Skill

transistor

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 m

LLM Mart · 0 points · 6 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download magnus919-agent-skills-transistor-d0edebb.zip · 42 KB
Part of magnus919/agent-skills — 145 skills

Install

skills CLI npx skills add https://github.com/magnus919/agent-skills/tree/main/transistor
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install magnus919-agent-skills@llmmart
Git git clone https://github.com/magnus919/agent-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole magnus919/agent-skills collection as a plugin from our marketplace. Git is the plain clone.

README

Transistor.fm — Podcast Hosting 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 operate your Transistor.fm podcast hosting without the dashboard, including the part no other tool gives an agent: the full episode publish lifecycle.

  • 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

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

export TRANSISTOR_API_KEY="<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 <SHOW_ID> --title "Ep 12" \
     --audio-url "https://example.com/ep12.mp3" --json | jq -r '.id')
transistor episode-publish --id "$EP"

--help and --dry-run work without an API key; preview any request with transistor --dry-run episode-publish --id 123.

Triggers

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, 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.

Skill manifest

transistor — Transistor.fm podcast hosting from the terminal

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. Find your API key on the Transistor dashboard Account page → API Access (https://dashboard.transistor.fm/account) and export it:
export TRANSISTOR_API_KEY="<API_KEY>"
  1. Verify the key (GET /v1 — the authorization probe; there is no /v1/user route):
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 — authorization probe

transistor user                # who does this key belong to?
transistor user --json

shows / show — browse podcasts

transistor shows                          # newest-updated first
transistor shows --private --json         # private podcasts only
transistor show --id <SHOW_ID_OR_SLUG>    # full attributes incl. feed_url
transistor shows --page 1 --per 20 --json

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 / episode — browse episodes

transistor episodes                                    # newest first, all shows
transistor episodes --show <SHOW_ID> --status draft    # drafts for one show
transistor episodes --show <SHOW_ID> --per 50 --page 1 --json
transistor episode --id <EPISODE_ID> --include show    # compound doc + parent show

--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.

episode-create / episode-update — drafts and metadata

transistor episode-create --show <SHOW_ID> --title "Ep 12: Roasting" \
  --season 2 --number 4 --audio-url "https://uploads.example.com/ep12.mp3"
transistor episode-update --id <EPISODE_ID> --title "New title"
transistor episode-update --id <EPISODE_ID> --audio-url "<AUDIO_URL>"   # attach audio

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.

episode-publish — the lifecycle switch

transistor episode-publish --id <EPISODE_ID>                       # publish now
transistor episode-publish --id <EPISODE_ID> --status scheduled \
  --published-at "2026-09-03 09:00:00"                             # schedule
transistor episode-publish --id <EPISODE_ID> --status draft        # unpublish

Hits PATCH /v1/episodes/<EPISODE_ID>/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)

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

transistor analytics --show <SHOW_ID>                        # last 14 days
transistor analytics --show <SHOW_ID> \
  --start-date 01-08-2026 --end-date 28-08-2026 --json
transistor episode-analytics --id <EPISODE_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

transistor subscribers --show <SHOW_ID> --json
transistor subscriber-create --show <SHOW_ID> --email "listener@example.com"
transistor subscriber-batch --show <SHOW_ID> --email "a@example.com" --email "b@example.com"
transistor subscriber-delete --show <SHOW_ID> --email "a@example.com"   # or --id

webhooks — push instead of poll

transistor webhooks --show <SHOW_ID>
transistor webhook-create --show <SHOW_ID> --event episode_published \
  --url "https://example.com/hooks/transistor"
transistor webhook-delete --id <WEBHOOK_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

transistor --json shows                        # flags work in any position
transistor --dry-run episodes --show <SHOW_ID> # request plan, zero network
transistor --force episode-publish --id <EPISODE_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)

export TRANSISTOR_API_KEY="<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?

transistor episodes --show <SHOW_ID> --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

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 <SHOW_ID> --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

  • 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/<ID>/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.

When to use

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 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 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 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 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.
Files (agent-skills)
  • evals
    • evals.json 8.3 KB
      {
        "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 <SHOW_ID> --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 <SHOW_ID> --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 <EPISODE_ID>, which sends PATCH /v1/episodes/<EPISODE_ID>/publish with episode[status]=published. Confirm with transistor episode --id <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/<EPISODE_ID>/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 <EPISODE_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 <EPISODE_ID> --audio-url <AUDIO_URL> (or pass it at episode-create), then publish with transistor episode-publish --id <EPISODE_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 <SHOW_ID> --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 <SHOW_ID> --email <EMAIL_1> --email <EMAIL_2> ... (POST /v1/subscribers/batch with show_id and emails[]), optionally --skip-welcome-email. Verify with transistor subscribers --show <SHOW_ID> --json reading meta.totalCount. Revoke access later with transistor subscriber-delete --show <SHOW_ID> --email <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"
            ]
          }
        ]
      }
      
  • references
    • auth-and-basics.md 8.2 KB
      # 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: <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 `{"<name>": {"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/<EPISODE_ID> -H "x-api-key: <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=<SHOW_ID>' -H "x-api-key: <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/<EPISODE_ID>?include[]=show' -H "x-api-key: <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
      
    • endpoint-catalog.md 9.5 KB
      # 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>`), `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
      
    • episode-publish-lifecycle.md 8.9 KB
      # 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/<EPISODE_ID>/publish -X PATCH \
        -H "x-api-key: <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/<EPISODE_ID>/publish -X PATCH \
        -H "x-api-key: <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": "<EPISODE_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="<API_KEY>"
      
      # 1. Create the episode (always a draft). Audio can ride along now:
      transistor episode-create --show <SHOW_ID> \
        --title "Episode 12: Roasting" \
        --summary "A primer on roasting coffee" \
        --season 2 --number 4 \
        --audio-url "https://uploads.example.com/ep12.mp3"
      # -> {"id": "<EPISODE_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 <EPISODE_ID> \
        --audio-url "https://uploads.example.com/ep12.mp3"
      
      # 3. Publish on the dedicated endpoint:
      transistor episode-publish --id <EPISODE_ID>
      # PATCH /v1/episodes/<EPISODE_ID>/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. The guard is scoped
      to `--status published` (the default) — scheduling intentionally precedes
      audio attach, so a `scheduled` publish needs no audio yet — and it performs
      one pre-publish `GET /episodes/:id`, which consumes one of the
      10-requests-per-10-seconds rate-limit slots: worth counting in bulk
      re-publish loops.
      
      ## Worked recipe: raw curl
      
      ```bash
      # 1. Create a draft
      curl https://api.transistor.fm/v1/episodes -X POST \
        -H "x-api-key: <API_KEY>" \
        -d "episode[show_id]=<SHOW_ID>" \
        -d "episode[title]=Example episode" \
        -d "episode[audio_url]=https://example.com/audio/episode.mp3"
      # -> {"data": {"id": "<EPISODE_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/<EPISODE_ID> -X PATCH \
        -H "x-api-key: <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/<EPISODE_ID>/publish -X PATCH \
        -H "x-api-key: <API_KEY>" \
        -d "episode[status]=published"
      # -> {"data": {"id": "<EPISODE_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 "<UPLOAD_URL>"
      
      # 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 <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
      
    • gotchas-and-recipes.md 10.7 KB
      # 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 <EPISODE_ID>`, or the raw call:
        `curl .../v1/episodes/<EPISODE_ID>/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 <ID> --json` until
        `audio_processing` is false (respecting the rate limit) before
        publishing.
      
      ## Recipe: full publish pipeline (CLI)
      
      ```bash
      export TRANSISTOR_API_KEY="<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 <SHOW_ID> --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: <API_KEY>" | jq -r '.data.attributes.audio_url')
      EP=$(curl -s https://api.transistor.fm/v1/episodes -X POST \
        -H "x-api-key: <API_KEY>" -d "episode[show_id]=<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: <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 <SHOW_ID> \
             --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 <SHOW_ID> --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 <SHOW_ID> \
        --email "one@example.com" --email "two@example.com" \
        --skip-welcome-email --json | jq '.subscribers'
      
      transistor subscribers --show <SHOW_ID> --json | jq -r '.meta.totalCount'
      # Revoke someone: by email...
      transistor subscriber-delete --show <SHOW_ID> --email "two@example.com"
      # ...or by id
      transistor subscriber-delete --id <SUBSCRIBER_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 <SHOW_ID> \
        --event episode_published --url "https://example.com/hooks/transistor"
      transistor webhooks --show <SHOW_ID> --json | jq '.webhooks'
      transistor webhook-delete --id <WEBHOOK_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
      
  • scripts
    • test_transistor.py 34.8 KB
      """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()
      
    • transistor 43.3 KB · in bundle
  • README.md 3 KB
    # Transistor.fm — Podcast Hosting 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 **operate your Transistor.fm
    podcast hosting** without the dashboard, including the part no other tool
    gives an agent: the full episode publish lifecycle.
    
    - **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
    
    | 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="<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 <SHOW_ID> --title "Ep 12" \
         --audio-url "https://example.com/ep12.mp3" --json | jq -r '.id')
    transistor episode-publish --id "$EP"
    ```
    
    `--help` and `--dry-run` work without an API key; preview any request with
    `transistor --dry-run episode-publish --id 123`.
    
    ## Triggers
    
    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`, 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.
    
  • SKILL.md 13.9 KB
    ---
    name: transistor
    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 (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, episodes, analytics, api-client
      sources: https://developers.transistor.fm/, https://support.transistor.fm/
    ---
    
    # transistor — Transistor.fm podcast hosting from the terminal
    
    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. 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="<API_KEY>"
    ```
    
    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 — authorization probe
    
    ```bash
    transistor user                # who does this key belong to?
    transistor user --json
    ```
    
    ### shows / show — browse podcasts
    
    ```bash
    transistor shows                          # newest-updated first
    transistor shows --private --json         # private podcasts only
    transistor show --id <SHOW_ID_OR_SLUG>    # full attributes incl. feed_url
    transistor shows --page 1 --per 20 --json
    ```
    
    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 / episode — browse episodes
    
    ```bash
    transistor episodes                                    # newest first, all shows
    transistor episodes --show <SHOW_ID> --status draft    # drafts for one show
    transistor episodes --show <SHOW_ID> --per 50 --page 1 --json
    transistor episode --id <EPISODE_ID> --include show    # compound doc + parent show
    ```
    
    `--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.
    
    ### episode-create / episode-update — drafts and metadata
    
    ```bash
    transistor episode-create --show <SHOW_ID> --title "Ep 12: Roasting" \
      --season 2 --number 4 --audio-url "https://uploads.example.com/ep12.mp3"
    transistor episode-update --id <EPISODE_ID> --title "New title"
    transistor episode-update --id <EPISODE_ID> --audio-url "<AUDIO_URL>"   # attach audio
    ```
    
    `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.
    
    ### episode-publish — the lifecycle switch
    
    ```bash
    transistor episode-publish --id <EPISODE_ID>                       # publish now
    transistor episode-publish --id <EPISODE_ID> --status scheduled \
      --published-at "2026-09-03 09:00:00"                             # schedule
    transistor episode-publish --id <EPISODE_ID> --status draft        # unpublish
    ```
    
    Hits `PATCH /v1/episodes/<EPISODE_ID>/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 <SHOW_ID>                        # last 14 days
    transistor analytics --show <SHOW_ID> \
      --start-date 01-08-2026 --end-date 28-08-2026 --json
    transistor episode-analytics --id <EPISODE_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 <SHOW_ID> --json
    transistor subscriber-create --show <SHOW_ID> --email "listener@example.com"
    transistor subscriber-batch --show <SHOW_ID> --email "a@example.com" --email "b@example.com"
    transistor subscriber-delete --show <SHOW_ID> --email "a@example.com"   # or --id
    ```
    
    ### webhooks — push instead of poll
    
    ```bash
    transistor webhooks --show <SHOW_ID>
    transistor webhook-create --show <SHOW_ID> --event episode_published \
      --url "https://example.com/hooks/transistor"
    transistor webhook-delete --id <WEBHOOK_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 <SHOW_ID> # request plan, zero network
    transistor --force episode-publish --id <EPISODE_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="<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 <SHOW_ID> --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 <SHOW_ID> --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
    
    - **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/<ID>/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.
    
    ## When to use
    
    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.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related