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:
| Column | Notes |
|---|---|
podcast_id, platform | UNIQUE (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) |
status | CHECK: not_submitted, submitted, listed, rejected |
listing_url, external_id | The public listing link and the platform's own id (iTunes collectionId, PI feed id, Spotify show id) |
detection_source | CHECK: manual, itunes, podcastindex, spotify |
confirmed | Default true. false marks a fuzzy Spotify auto-detection awaiting the user's "yes, that's my show" |
submitted_at, listed_at, last_checked_at, error | Lifecycle 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):
| Action | Writes | Notes |
|---|---|---|
mark_submitted | status: 'submitted', submitted_at (coalesced to the existing stamp) | "I submitted at the portal" |
set_listing_url | status: 'listed', confirmed: true, detection_source: 'manual', listed_at coalesced | URL must be https: and its hostname must contain the platform's listingUrlHint |
confirm | confirmed: true | Accepts a fuzzy Spotify detection |
reject_detection | status: 'submitted', clears listing_url / external_id / detection_source, confirmed: true | "That detected show is not mine"; back to waiting |
reset | status: 'not_submitted', clears everything including both timestamps | Start over |
Ride-along platforms accept only set_listing_url (anything else returns 400 with "This app lists automatically").
Endpoints
| Endpoint | Middleware chain | Purpose |
|---|---|---|
GET /api/distribution/:podcastId | requireAuth() then requirePodcastRoleByResolver('admin', ...) | All listings for the podcast, ordered by platform |
PUT /api/distribution/:podcastId/:platform | requireAuth() then zValidator('json', listingActionSchema) then requirePodcastRoleByResolver('admin', ...) | Apply a listing action |
POST /api/distribution/:podcastId/podcast-index/submit | requireAuth() 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:
- Pre-flight: Podcast Index rejects feeds without artwork or episodes, so missing cover art or zero published episodes returns 422 with actionable copy.
- Submit via
add/byfeedurlwith 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 freshnot_submittedrow when none exists, otherwise only theerrorcolumn is stamped) and returns 502; an existing row is never downgraded over a transient failure. - Look up via
podcasts/byfeedurl. If the feed id resolves, the row lands directly onlisted+confirmedwithlisting_url = https://podcastindex.org/podcast/{id}; if the index lags, the row falls back tosubmittedand 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_urlalways emits;confirmemits only when the existing row was alreadylisted(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 id | Level | Blocks |
|---|---|---|
title, description, category, language, owner_email, cover_art, published_episode, guid | blocking | everything (blocksPlatforms: []) |
episode_titles (no < or > in published titles) | warning | youtube |
mp3_only (all published audio is MP3) | warning | spotify, 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 averification_tokensstring 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 }):
| Platform | Match rule | Confirmed? | Quirks handled |
|---|---|---|---|
| Apple (iTunes Search) | Result whose feedUrl equals ours exactly, tolerating a trailing slash | yes | 1s spacing between calls (ITUNES_SPACING_MS); Apple 429s Cloudflare's shared egress IPs readily, labelled transient |
Podcast Index (podcasts/byfeedurl) | Exact, authoritative | yes | HTTP 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) | never | Client-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):
notify_distribution_listedRPC (migration20260706135417): SECURITY DEFINER, EXECUTE locked to service_role. In-app notification plus one planned email delivery for the podcast's owners/admins, respectingmute_all/muted_podcasts/email_distribution/digest_frequency. Absolute dedupe per (recipient, podcast, platform) via a partial unique index onnotifications (user_id, dedupe_key), so monitor retries are no-ops.distribution.listedevent to theautomation-eventsqueue. The automation-scheduler consumer gates distribution triggers with a stable, platform-scoped idempotency key ({rule_id}::{trigger}::{podcast_id}::platform:{platform}inworkers/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):
| Target | Transport | Auth |
|---|---|---|
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/pubnotify | GET ?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.
Related
- Platform catalog for the per-platform metadata driving all of this
- Smart links for how confirmed listings surface publicly
- RSS Feed worker for feed rendering and cache invalidation