← Public packages
@kentcdodds/transistor
Authenticated Transistor.fm helpers for shows, episodes, episode metadata, and morning-briefing-ready podcast analytics.
src/index.js
508 lines · 19.3 KB · JavaScriptvar API_BASE = "https://api.transistor.fm/v1";
var SECRET_API_KEY = "{{secret:TRANSISTOR_API_KEY|scope=user}}";
var DEFAULT_TIME_ZONE = "America/Denver";
var DEFAULT_MIN_REQUEST_GAP_MS = 1100;
function requireString(value, name) {
if (typeof value !== "string" || value.trim() === "") {
throw new Error(name + " is required.");
}
return value.trim();
}
function clean(value) {
return String(value || "").replace(/\s+/g, " ").trim();
}
function number(value) {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : 0;
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function formBody(prefix, fields) {
const body = new URLSearchParams();
for (const [key, value] of Object.entries(fields ?? {})) {
if (value === void 0 || value === null) continue;
body.set(prefix ? prefix + "[" + key + "]" : key, String(value));
}
return body;
}
/** Compact authenticated Transistor request: query, secret header, JSON parse, retries. */
export async function request(path, { method = "GET", query, body, headers, maxAttempts = 3 } = {}) {
const url = new URL(path ? path.startsWith("http") ? path : API_BASE + (path.startsWith("/") ? path : "/" + path) : API_BASE);
if (url.origin !== "https://api.transistor.fm" || !url.pathname.startsWith("/v1")) {
throw new Error("Transistor requests must stay on https://api.transistor.fm/v1.");
}
for (const [key, value] of Object.entries(query ?? {})) {
if (value === void 0 || value === null || value === "" || value === false) continue;
if (Array.isArray(value)) for (const item of value) url.searchParams.append(key, String(item));
else url.searchParams.append(key, String(value));
}
const initBody = body && !(body instanceof URLSearchParams) && typeof body === "object" ? new URLSearchParams(body) : body;
let lastError;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const response = await fetch(url, {
method,
headers: {
"x-api-key": SECRET_API_KEY,
accept: "application/json",
...initBody instanceof URLSearchParams ? { "content-type": "application/x-www-form-urlencoded" } : {},
...headers
},
body: initBody
});
const text = await response.text();
const contentType = response.headers.get("content-type") || "";
const parsed = contentType.includes("application/json") ? text ? JSON.parse(text) : null : text;
if (response.ok) return parsed;
lastError = new Error("Transistor " + response.status + " for " + url.pathname + ": " + (typeof parsed === "string" ? parsed : JSON.stringify(parsed ?? "")).slice(0, 500));
if (!(response.status === 429 || response.status >= 500) || attempt >= maxAttempts) throw lastError;
const retryAfter = response.headers.get("retry-after");
const retryMs = retryAfter ? Number.isFinite(Number(retryAfter)) ? Number(retryAfter) * 1e3 : Math.max(0, Date.parse(retryAfter) - Date.now()) : Math.min(5e3, 500 * 2 ** (attempt - 1));
await sleep(retryMs);
}
throw lastError;
}
/** @deprecated Prefer `request`. */
export async function transistorRequest(pathOrInput, options = {}) {
const input = typeof pathOrInput === "string" ? { path: pathOrInput } : pathOrInput ?? {};
return await request(input.path === void 0 ? "" : String(input.path), {
method: options.method ?? input.method ?? "GET",
query: { ...input.query ?? {}, ...options.query ?? {} },
body: options.body ?? input.body,
headers: { ...input.headers ?? {}, ...options.headers ?? {} },
maxAttempts: options.maxAttempts
});
}
function showFromResource(show) {
const attributes = show.attributes ?? show;
return {
id: String(show.id ?? attributes.id ?? ""),
type: show.type ?? "show",
title: attributes.title,
slug: attributes.slug,
description: attributes.description,
status: attributes.status,
feedUrl: attributes.feed_url,
siteUrl: attributes.website ?? attributes.website_url,
private: attributes.private,
author: attributes.author,
keywords: attributes.keywords,
category: attributes.category,
language: attributes.language,
showType: attributes.show_type,
timeZone: attributes.time_zone,
explicit: attributes.explicit,
imageUrl: attributes.image_url,
raw: show
};
}
function episodeFromResource(episode) {
const attributes = episode.attributes ?? episode;
return {
id: String(episode.id ?? attributes.id ?? ""),
type: episode.type ?? "episode",
title: attributes.title,
number: attributes.number,
season: attributes.season,
episodeType: attributes.type,
status: attributes.status,
publishedAt: attributes.published_at,
duration: attributes.duration,
mediaUrl: attributes.media_url,
shareUrl: attributes.share_url,
alternateUrl: attributes.alternate_url,
videoUrl: attributes.video_url,
imageUrl: attributes.image_url,
slug: attributes.slug,
raw: episode
};
}
function sumDownloads(downloads = []) {
return downloads.reduce((sum, row) => sum + number(row.downloads), 0);
}
function episodeAnalyticsFromResource(episode) {
return {
id: String(episode.id ?? ""),
title: episode.title,
publishedAt: episode.published_at,
downloads: episode.downloads ?? [],
downloadCount: sumDownloads(episode.downloads ?? [])
};
}
function datePartsInTimeZone(date, timeZone) {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit"
}).formatToParts(date);
return Object.fromEntries(parts.map((part) => [part.type, part.value]));
}
function dateKeyInTimeZone(date, timeZone = DEFAULT_TIME_ZONE) {
const parts = datePartsInTimeZone(date, timeZone);
return parts.year + "-" + parts.month + "-" + parts.day;
}
function addDaysToDateKey(dateKey, days) {
const date = /* @__PURE__ */ new Date(dateKey + "T12:00:00Z");
date.setUTCDate(date.getUTCDate() + days);
return date.toISOString().slice(0, 10);
}
function formatTransistorDate(dateOrDateKey, timeZone = DEFAULT_TIME_ZONE) {
const dateKey = typeof dateOrDateKey === "string" && /^\d{4}-\d{2}-\d{2}$/.test(dateOrDateKey) ? dateOrDateKey : dateKeyInTimeZone(new Date(dateOrDateKey), timeZone);
const [year, month, day] = dateKey.split("-");
return day + "-" + month + "-" + year;
}
function displayDateRange(startDateKey, endDateKey) {
return startDateKey === endDateKey ? startDateKey : startDateKey + " to " + endDateKey;
}
function analyticsDateWindow(input = {}) {
const timeZone = typeof input.timeZone === "string" ? input.timeZone : DEFAULT_TIME_ZONE;
if (input.startDate || input.endDate) {
const endSource = input.endDate ?? input.startDate;
const startSource = input.startDate ?? input.endDate;
const startDateKey = typeof startSource === "string" && /^\d{4}-\d{2}-\d{2}$/.test(startSource) ? startSource : dateKeyInTimeZone(new Date(startSource), timeZone);
const endDateKey = typeof endSource === "string" && /^\d{4}-\d{2}-\d{2}$/.test(endSource) ? endSource : dateKeyInTimeZone(new Date(endSource), timeZone);
return { timeZone, startDateKey, endDateKey };
}
const days = Math.max(1, Math.floor(number(input.days) || 1));
const completedDays = input.completedDays !== false;
const todayKey = dateKeyInTimeZone(/* @__PURE__ */ new Date(), timeZone);
const endDateKey = completedDays ? addDaysToDateKey(todayKey, -1) : todayKey;
const startDateKey = addDaysToDateKey(endDateKey, -(days - 1));
return { timeZone, startDateKey, endDateKey };
}
export async function getCurrentUser() {
return await request("");
}
export async function listShows(input = {}) {
const response = await request("/shows", {
query: {
query: input.query,
private: input.private,
"pagination[page]": input.page ?? 1,
"pagination[per]": input.per ?? 100
}
});
return {
shows: (response.data ?? []).map(showFromResource),
meta: response.meta ?? {},
raw: response
};
}
export async function listEpisodes(input = {}) {
const showId = requireString(input.showId ?? input.show_id, "showId");
const response = await request("/episodes", {
query: {
show_id: showId,
query: input.query,
status: input.status,
order: input.order ?? "desc",
"pagination[page]": input.page ?? 1,
"pagination[per]": input.per ?? 100
}
});
return {
episodes: (response.data ?? []).map(episodeFromResource),
meta: response.meta ?? {},
raw: response
};
}
export async function getEpisode(input = {}) {
const episodeId = requireString(input.episodeId ?? input.id, "episodeId");
const response = await request("/episodes/" + encodeURIComponent(episodeId));
return episodeFromResource(response.data);
}
export async function getShow(input = {}) {
const showId = requireString(input.showId ?? input.id ?? input.slug, "showId");
const response = await request("/shows/" + encodeURIComponent(showId));
return showFromResource(response.data);
}
const SHOW_WRITE_FIELDS = [
"title",
"author",
"website",
"description",
"keywords",
"category",
"secondary_category",
"language",
"show_type",
"time_zone",
"explicit",
"copyright",
"owner_email",
"image_url",
"multiple_seasons"
];
function pickShowFields(show) {
const fields = {};
for (const key of SHOW_WRITE_FIELDS) {
if (show[key] !== void 0) fields[key] = show[key];
}
return fields;
}
/** Patch show metadata. Mutating — require confirm: true for safety in agent flows. */
export async function patchShow(input = {}) {
if (input.confirm !== true) {
throw new Error('patchShow requires confirm: true (mutates Transistor show metadata).');
}
const showId = requireString(input.showId ?? input.id ?? input.slug, "showId");
const show = input.show && typeof input.show === "object" ? input.show : input;
const fields = pickShowFields(show);
if (!Object.keys(fields).length) throw new Error("At least one show field is required.");
const response = await request("/shows/" + encodeURIComponent(showId), {
method: "PATCH",
body: formBody("show", fields)
});
return showFromResource(response.data);
}
/**
* Publish, schedule, or draft an episode via Transistor's /publish endpoint.
* Mutating — require confirm: true.
*/
export async function publishEpisode(input = {}) {
if (input.confirm !== true) {
throw new Error('publishEpisode requires confirm: true (mutates Transistor episode status).');
}
const episodeId = requireString(input.episodeId ?? input.id, "episodeId");
const status = requireString(input.status, "status");
if (!["published", "scheduled", "draft"].includes(status)) {
throw new Error('status must be "published", "scheduled", or "draft".');
}
const fields = { status };
if (input.publishedAt ?? input.published_at) {
fields.published_at = input.publishedAt ?? input.published_at;
}
const response = await request(
"/episodes/" + encodeURIComponent(episodeId) + "/publish",
{
method: "PATCH",
body: formBody("episode", fields)
}
);
return episodeFromResource(response.data);
}
/**
* Transistor phased out episode summaries. Put listener-facing copy in
* `description` (show notes). Only allow `summary` when clearing it.
* @see https://support.transistor.fm/en/article/how-should-i-format-my-podcasts-show-notes-mv7kwi/
*/
function assertSummaryPolicy(fields) {
if (fields.summary === void 0 || fields.summary === null) return;
if (String(fields.summary).trim() === "") {
fields.summary = "";
return;
}
throw new Error(
"Transistor episode summaries are phased out. Put copy in description/show notes instead, or pass summary: \"\" to clear a legacy summary. See https://support.transistor.fm/en/article/how-should-i-format-my-podcasts-show-notes-mv7kwi/"
);
}
const EPISODE_WRITE_FIELDS = [
"audio_url",
"transcript_text",
"author",
"description",
"explicit",
"image_url",
"keywords",
"number",
"season",
"summary",
"type",
"title",
"alternate_url",
"video_url",
"email_notifications",
"increment_number"
];
function pickEpisodeFields(episode) {
const fields = {};
for (const key of EPISODE_WRITE_FIELDS) {
if (episode[key] !== void 0) fields[key] = episode[key];
}
assertSummaryPolicy(fields);
return fields;
}
export async function createEpisode(input = {}) {
const episode = input.episode && typeof input.episode === "object" ? input.episode : input;
const showId = requireString(episode.show_id ?? episode.showId ?? input.show_id ?? input.showId, "showId");
const fields = pickEpisodeFields({
...episode,
summary: void 0,
increment_number: episode.increment_number === true || input.increment_number === true ? true : void 0
});
const response = await request("/episodes", {
method: "POST",
body: formBody("episode", { show_id: showId, ...fields })
});
return episodeFromResource(response.data);
}
export async function updateEpisode(input = {}) {
const episodeId = requireString(input.episodeId ?? input.id, "episodeId");
const episode = input.episode && typeof input.episode === "object" ? input.episode : input;
const fields = pickEpisodeFields(episode);
delete fields.increment_number;
if (!Object.keys(fields).length) throw new Error("At least one episode field is required.");
const response = await request("/episodes/" + encodeURIComponent(episodeId), {
method: "PATCH",
body: formBody("episode", fields)
});
return episodeFromResource(response.data);
}
/** Clear a legacy episode summary so feeds rely on description/show notes. */
export async function clearEpisodeSummary(input = {}) {
return await updateEpisode({
episodeId: input.episodeId ?? input.id,
summary: ""
});
}
export async function getShowAnalytics(input = {}) {
const showId = requireString(input.showId ?? input.id, "showId");
return await request("/analytics/" + encodeURIComponent(showId), {
query: {
start_date: input.startDate ? formatTransistorDate(input.startDate, input.timeZone) : void 0,
end_date: input.endDate ? formatTransistorDate(input.endDate, input.timeZone) : void 0
}
});
}
export async function getEpisodesAnalytics(input = {}) {
const showId = requireString(input.showId ?? input.id, "showId");
const response = await request("/analytics/" + encodeURIComponent(showId) + "/episodes", {
query: {
start_date: input.startDate ? formatTransistorDate(input.startDate, input.timeZone) : void 0,
end_date: input.endDate ? formatTransistorDate(input.endDate, input.timeZone) : void 0
}
});
const episodes = (response.data?.attributes?.episodes ?? []).map(episodeAnalyticsFromResource);
return {
id: response.data?.id,
type: response.data?.type,
startDate: response.data?.attributes?.start_date,
endDate: response.data?.attributes?.end_date,
episodes,
totalDownloads: episodes.reduce((sum, episode) => sum + episode.downloadCount, 0),
raw: response
};
}
export async function getMorningBriefingSnapshot(input = {}) {
const { timeZone, startDateKey, endDateKey } = analyticsDateWindow(input);
const { shows } = await listShows({ per: input.showLimit ?? 100 });
const requestedShowIds = Array.isArray(input.showIds) ? new Set(input.showIds.map(String)) : null;
const selectedShows = requestedShowIds ? shows.filter((show) => requestedShowIds.has(String(show.id)) || requestedShowIds.has(String(show.slug))) : shows;
const minLiveRequestGapMs = Math.max(0, number(input.minLiveRequestGapMs) || DEFAULT_MIN_REQUEST_GAP_MS);
const podcasts = [];
for (const show of selectedShows) {
const analytics = await getEpisodesAnalytics({
showId: show.id,
startDate: startDateKey,
endDate: endDateKey,
timeZone
});
const episodesWithDownloads = analytics.episodes.filter((episode) => episode.downloadCount > 0).sort((a, b) => b.downloadCount - a.downloadCount || clean(a.title).localeCompare(clean(b.title)));
podcasts.push({
podcast: show,
downloads: {
total: analytics.totalDownloads,
startDate: analytics.startDate,
endDate: analytics.endDate
},
topEpisodes: episodesWithDownloads,
episodeCount: analytics.episodes.length,
episodeCountWithDownloads: episodesWithDownloads.length
});
if (minLiveRequestGapMs > 0) await sleep(minLiveRequestGapMs);
}
const totalDownloads = podcasts.reduce((sum, podcast) => sum + podcast.downloads.total, 0);
return {
window: {
timeZone,
startDate: startDateKey,
endDate: endDateKey,
label: displayDateRange(startDateKey, endDateKey)
},
podcasts,
totals: {
downloads: totalDownloads,
podcastCount: podcasts.length,
podcastCountWithDownloads: podcasts.filter((podcast) => podcast.downloads.total > 0).length,
episodeCount: podcasts.reduce((sum, podcast) => sum + podcast.episodeCount, 0),
episodeCountWithDownloads: podcasts.reduce((sum, podcast) => sum + podcast.episodeCountWithDownloads, 0)
}
};
}
export async function smokeTest() {
const user = await getCurrentUser();
const shows = await listShows({ per: 10 });
return {
user: {
id: user.data?.id,
name: user.data?.attributes?.name
},
showCount: shows.shows.length,
shows: shows.shows.map((show) => ({ id: show.id, title: show.title, slug: show.slug, feedUrl: show.feedUrl }))
};
}
export function getOverview() {
return {
name: "@kentcdodds/transistor",
description: "Authenticated Transistor.fm helpers for shows, episodes, episode metadata, and podcast analytics.",
commonActions: [
"smokeTest",
"listShows",
"getShow",
"patchShow",
"listEpisodes",
"createEpisode",
"updateEpisode",
"publishEpisode",
"clearEpisodeSummary",
"getEpisodesAnalytics",
"getMorningBriefingSnapshot"
],
notes: [
"Do not set episode summaries; use description/show notes. Clear legacy summaries with clearEpisodeSummary or summary: \"\".",
"patchShow and publishEpisode require confirm: true.",
"No official Transistor OpenAPI spec is published; this package uses a compact authenticated request helper against https://api.transistor.fm/v1."
]
};
}
export default async function transistor(input = {}) {
const action = input.action ?? "overview";
switch (action) {
case "overview":
return getOverview();
case "smokeTest":
return await smokeTest();
case "request": {
const { action: _requestAction, options: requestOptions, ...requestInput } = input;
return await transistorRequest(requestInput, requestOptions ?? {});
}
case "listShows":
return await listShows(input);
case "listEpisodes":
return await listEpisodes(input);
case "getEpisode":
return await getEpisode(input);
case "getShow":
return await getShow(input);
case "patchShow":
return await patchShow(input);
case "createEpisode":
return await createEpisode(input);
case "updateEpisode":
return await updateEpisode(input);
case "publishEpisode":
return await publishEpisode(input);
case "clearEpisodeSummary":
return await clearEpisodeSummary(input);
case "getShowAnalytics":
return await getShowAnalytics(input);
case "getEpisodesAnalytics":
return await getEpisodesAnalytics(input);
case "morningBriefingSnapshot":
case "getMorningBriefingSnapshot":
return await getMorningBriefingSnapshot(input);
default:
throw new Error("Unknown transistor action: " + action);
}
}