Skip to content

Directory Listings & Monitoring

One row per (podcast, platform) in podcast_directory_listings tracks the submission lifecycle of a show.fm-hosted show on an external directory. Writers are podcast admins via the app (RLS) and the distribution-monitor worker over Hyperdrive (table owner, bypasses RLS).

The table

Migration 20260706135415_distribution_directory_listings.sql:

ColumnNotes
podcast_id, platformUNIQUE (podcast_id, platform); platform is a catalog key with no CHECK (validated by zod at the API layer so adding a platform needs no migration)
statusCHECK: not_submitted, submitted, listed, rejected
listing_url, external_idThe public listing link and the platform's own id (iTunes collectionId, PI feed id, Spotify show id)
detection_sourceCHECK: manual, itunes, podcastindex, spotify
confirmedDefault true. false marks a fuzzy Spotify auto-detection awaiting the user's "yes, that's my show"
submitted_at, listed_at, last_checked_at, errorLifecycle timestamps + last check error

RLS: a single FOR ALL policy gated on has_podcast_role(podcast_id, 'admin') keeps the read and write perimeters identical. A partial index over status IN ('not_submitted', 'submitted') keeps the monitor's scan cheap.

rejected is schema-supported but currently has no writer: neither the API actions nor the monitor set it (verified against src/api/routes/distribution/index.ts and workers/distribution-monitor/src/).

State machine

The API is upsert-based (onConflict: 'podcast_id,platform'): actions do not gate on the current status, they write a specific target shape. In practice rows move like this:

Action payloads applied by PUT /api/distribution/:podcastId/:platform (src/api/routes/distribution/index.ts, discriminated union on action):

ActionWritesNotes
mark_submittedstatus: 'submitted', submitted_at (coalesced to the existing stamp)"I submitted at the portal"
set_listing_urlstatus: 'listed', confirmed: true, detection_source: 'manual', listed_at coalescedURL must be https: and its hostname must contain the platform's listingUrlHint
confirmconfirmed: trueAccepts a fuzzy Spotify detection
reject_detectionstatus: 'submitted', clears listing_url / external_id / detection_source, confirmed: true"That detected show is not mine"; back to waiting
resetstatus: 'not_submitted', clears everything including both timestampsStart over

Ride-along platforms accept only set_listing_url (anything else returns 400 with "This app lists automatically").

Endpoints

EndpointMiddleware chainPurpose
GET /api/distribution/:podcastIdrequireAuth() then requirePodcastRoleByResolver('admin', ...)All listings for the podcast, ordered by platform
PUT /api/distribution/:podcastId/:platformrequireAuth() then zValidator('json', listingActionSchema) then requirePodcastRoleByResolver('admin', ...)Apply a listing action
POST /api/distribution/:podcastId/podcast-index/submitrequireAuth() then requirePodcastRoleByResolver('admin', ...)One-click Podcast Index submission

The resolver rejects structurally invalid :podcastId values (non-UUID) as 404 before any query runs, and unknown :platform keys are 400.

One-click Podcast Index submission

POST .../podcast-index/submit:

  1. Pre-flight: Podcast Index rejects feeds without artwork or episodes, so missing cover art or zero published episodes returns 422 with actionable copy.
  2. Submit via add/byfeedurl with the PI auth scheme (X-Auth-Key, X-Auth-Date, Authorization = sha1hex(key + secret + unixSeconds), 10s timeout). A failure is recorded non-destructively (recordPodcastIndexError: a fresh not_submitted row when none exists, otherwise only the error column is stamped) and returns 502; an existing row is never downgraded over a transient failure.
  3. Look up via podcasts/byfeedurl. If the feed id resolves, the row lands directly on listed + confirmed with listing_url = https://podcastindex.org/podcast/{id}; if the index lags, the row falls back to submitted and the monitor catches up later.

distribution.listed emission from the app

Any action that lands a row on status = 'listed' AND confirmed = true emits a distribution.listed automation event as an isolated side effect (individual try/catch, never fails the response):

  • set_listing_url always emits;
  • confirm emits only when the existing row was already listed (completing a fuzzy Spotify detection);
  • the PI submit emits only when the feed id resolved.

App-side emission goes through emitAutomationEvent() (src/lib/automation/events.ts), which matches enabled rules in-process and enqueues execute_rule messages onto the automation-executions queue. Context carries platform, platform_name, and listing_url.

Readiness checks

src/lib/distribution/readiness.ts computes pre-flight checks the settings page shows before submission. computeReadiness() is a pure function over ReadinessInput; loadReadinessInput() assembles the input from the database and fails safe (a query error degrades to "0 published episodes" / "no episode-level problems" so the page still renders; the published_episode blocking check then prevents submitting an unverifiable feed).

Check idLevelBlocks
title, description, category, language, owner_email, cover_art, published_episode, guidblockingeverything (blocksPlatforms: [])
episode_titles (no < or > in published titles)warningyoutube
mp3_only (all published audio is MP3)warningspotify, pandora

ready is true when every blocking check passes; warnings never affect it. language passes when NULL (the feed defaults to en) and fails only on an explicitly empty string. Episode scans cover published episodes with a 500-row cap.

Ownership verification

Two SvelteKit form actions on the settings page (src/routes/(app)/p/[slug]/settings/distribution/+page.server.ts) own the feed-facing ownership data:

  • updateOwnership: owner name/email, apple_verify_token, and a verification_tokens string array (each trimmed, capped at 4000 chars). The RSS worker renders these as <podcast:txt purpose="applepodcastsverify"> and <podcast:txt purpose="verify"> elements (workers/rss-feed/src/rss/channel.ts), so the action invalidates the feed cache after saving.
  • setEmailVisibility: opens or closes the 24-hour owner-email visibility window (email_visible_until). Platforms that verify via email OTP need the address visible in the feed while the user submits; 24 hours matches the longest confirmation-link lifetime in the catalog (Amazon's link expires after 24 hours). Opening a window invalidates the feed immediately AND schedules a second, delayed invalidation 24 hours out, so the KV-cached "visible" feed is rebuilt at expiry instead of lingering for the full cache TTL.

The feed renders the owner email only while email_visible_until is in the future (workers/rss-feed/src/rss/channel.ts); it is hidden by default for privacy.

The distribution-monitor worker

workers/distribution-monitor/ runs an hourly cron at :47 (crons = ["47 * * * *"], offset from the analytics-rollup at :05). Bindings: shared Hyperdrive + the automation-events queue producer. A secret-gated POST /__run (Bearer RUN_TRIGGER_SECRET) triggers a manual run; plain GET is a health check.

Scan selection

getScanBatch() (src/db.ts) selects the 30 least-recently-checked (podcast, platform) pairs (BATCH_LIMIT = 30) across the detectable platforms, including pairs with no listing row yet (implicit not_submitted). Eligibility: active show.fm-hosted podcasts with at least one published episode, and listing status in (not_submitted, submitted). Platforms whose credentials are missing are excluded from the scan itself, otherwise their never-checked pairs would sort first and starve the batch.

Detectors

src/lib/detect.ts; every detector takes an injected fetch, enforces an 8s timeout, and never throws (failures come back as { found: false, error }):

PlatformMatch ruleConfirmed?Quirks handled
Apple (iTunes Search)Result whose feedUrl equals ours exactly, tolerating a trailing slashyes1s spacing between calls (ITUNES_SPACING_MS); Apple 429s Cloudflare's shared egress IPs readily, labelled transient
Podcast Index (podcasts/byfeedurl)Exact, authoritativeyesHTTP 400 means "feed not in the index", a definitive not-yet, not a failure
Spotify (Web API show search)Exact normalized-title match (lowercase, punctuation stripped)neverClient-credentials token cached per run; the Web API exposes no feedUrl, so the match is fuzzy and the app's confirm flow owns the final say

Persistence and the Hyperdrive footgun

Hyperdrive caches identical SELECTs for up to ~60s, so recordDetection() computes the listed transition inside the upsert statement: a locked prior CTE captures the pre-upsert status, the upsert runs, and the RETURNING projection compares the two to produce becameListed. Read-after-write is never trusted. touchChecked() advances last_checked_at (creating the row if needed so row-less pairs join the rotation) and stamps or clears error.

Side effects on a confirmed listed transition

Only when becameListed && confirmed (unconfirmed Spotify detections are recorded but never notify or emit):

  1. notify_distribution_listed RPC (migration 20260706135417): SECURITY DEFINER, EXECUTE locked to service_role. In-app notification plus one planned email delivery for the podcast's owners/admins, respecting mute_all / muted_podcasts / email_distribution / digest_frequency. Absolute dedupe per (recipient, podcast, platform) via a partial unique index on notifications (user_id, dedupe_key), so monitor retries are no-ops.
  2. distribution.listed event to the automation-events queue. The automation-scheduler consumer gates distribution triggers with a stable, platform-scoped idempotency key ({rule_id}::{trigger}::{podcast_id}::platform:{platform} in workers/automation-scheduler/src/index.ts, gateDistributionRule), so each rule fires at most once per (rule, podcast, platform) regardless of re-observations or queue re-deliveries. Events without a resolvable platform fail closed (skipped).

The two side effects are individually isolated: a notify failure does not block the event, and vice versa. Per-pair failures never stop the loop, and last_checked_at always advances so a broken pair still rotates.

Feed announcement (Podping, WebSub, Podcast Index)

src/lib/utils/ping-directories.ts notifies aggregators that a feed has fresh content. All three pings run concurrently, each with a 5s timeout, individually try/caught; the module never throws (a failed ping must never fail a publish):

TargetTransportAuth
Podping (podping.cloud)GET ?url={feedUrl}PODPING_TOKEN required; without it the ping is skipped, not failed
WebSub (pubsubhubbub.appspot.com)POST hub.mode=publish&hub.url={feedUrl}none
Podcast Index hub/pubnotifyGET ?url={feedUrl}none (deliberately unauthenticated, live-verified 2026-07-06)

Call sites: the episode publish actions in src/routes/(app)/p/[slug]/e/[episodeSlug]/+page.server.ts, and the scheduled-publisher worker (workers/scheduled-publisher/src/index.ts), which carries a lockstep local copy of the module because workers cannot import $lib. The feed itself declares the hub via <atom:link href="https://pubsubhubbub.appspot.com/" rel="hub" /> (workers/rss-feed/src/rss/channel.ts), so subscribers can also subscribe for instant pushes.

Internal documentation - Not for public distribution