Skip to content
← 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 · JavaScript
var 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);
  }
}