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
Install
npx skills add https://github.com/magnus919/agent-skills/tree/main/transistor
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install magnus919-agent-skills@llmmart
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
- 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>"
- 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 isPATCH /v1/episodes/<ID>/publishwithepisode[status]=published. (The pre-thickening CLI had no publish path at all.) - The user probe is
GET /v1—/v1/userand/v1/authorizationare 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]andpage[number]are silently ignored — loops using them re-read page 1 forever. Loop whilemeta.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 providesdownloads_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-updateis 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_failureafter 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,--helpeverywhere). Imports only the standard library andrequests; 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, andTRANSISTOR_API_KEYfor 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.
Reviews (0)
No reviews yet.
No comments yet.