Skip to content

Imports API

Manages the RSS back catalogue import workflow for self-hosted podcasts. The API parses the source feed once, creates parent podcast_imports and child import_items rows, drives the locked-feed ownership handshake, runs a pre-flight preview step, and dispatches one podcast-imports queue message per episode. The podcast-import-executor worker then mirrors audio + assets to R2 and inserts episodes rows.

Base Path: /api/imports

Authentication: All endpoints require a Bearer token. All podcast-scoped endpoints require the admin role on the target podcast (enforced via requirePodcastRole / requirePodcastRoleByResolver).

Source: src/api/routes/imports/index.ts

Scope & constraints

  • Two pipelines share podcast_imports and the podcast-imports queue:
    • Back-catalogue import (this API). Imports into hosting_type = 'podcasterplus' podcasts. Locked feeds require an ownership handshake.
    • Hosting migration (is_hosting_migration = true). Imports into hosting_type = 'external' podcasts, merging audio onto existing episodes by external_guid and creating new episodes for unmatched RSS items; on terminal completion the worker flips hosting_type to podcasterplus. Driven by the External Link API — this back-catalogue API does not handle migration commits.
  • At most one in-flight import per podcast. Status in (preview, pending, running, verification_required) counts as in-flight — a second start request returns 409 with the existing importId. The same uniqueness lock covers hosting migrations via the partial unique index idx_podcast_imports_hosting_migration_active.
  • Locked feeds (<podcast:locked>yes</podcast:locked>) require ownership verification before any episode is dispatched (back-catalogue only — migrations skip this because the podcast already owns the feed in the system).

Endpoints

MethodPathDescription
POST/podcastStart import; parse feed, stage items, branch on lock status.
POST/:id/verify-ownershipValidate the 6-digit magic code emailed to the owner.
POST/:id/recheck-feedRe-parse feed; detect unlock / token / email pivot.
POST/:id/resend-codeIssue and email a fresh 6-digit code.
POST/:id/confirmApply pre-flight selection (limit + detected ep numbers) & dispatch.
GET/:idLoad the import + items for the progress UI.
POST/:id/cancelFlip import to canceled; worker skips pending messages.
POST/:id/retry-failedReset all failed items to pending and re-enqueue.
POST/:id/items/:itemId/retryReset a single failed item and enqueue one message.
POST/:id/resumeRe-dispatch pending items from a canceled import.

All responses follow the project standard:

typescript
// success
{ success: true, data: { /* route-specific */ } }
// failure
{ error: 'Human-readable message', code?: 'MACHINE_CODE' }

Status model

podcast_imports.status values drive the UI and executor behaviour:

StatusMeaning
verification_requiredLocked source feed; awaiting email code or feed recheck.
previewFeed parsed; awaiting user pre-flight confirmation.
pendingDispatched but no item has claimed yet (transient).
runningQueue is draining; executor is actively processing items.
completedAll items reached a terminal state with zero failures.
partialTerminal state — some items failed, some succeeded.
failedTerminal state — every item failed.
canceledUser cancelled; queue skips remaining pending items.

import_items.status is a parallel enum (pending, processing, completed, failed, skipped_duplicate, skipped_oversize, skipped_canceled, skipped_user_limit).

Start an import

POST /api/imports/podcast

Parses the source feed, backfills channel-level verification fields on the podcast row (Apple verify token, AI disclosure, <podcast:txt purpose="verify"> tokens), inserts the parent podcast_imports row and one import_items row per unique GUID. Pre-marks items whose GUID already exists on the target podcast as skipped_duplicate.

Request:

json
{
	"podcast_id": "uuid",
	"feed_url": "https://example.com/feed.rss"
}

Branching on lock status:

Responses:

  • 200 / preview — unlocked feed, ready for preview UI.
    json
    {
    	"success": true,
    	"data": {
    		"importId": "uuid",
    		"status": "preview",
    		"totalItems": 23,
    		"detection": {
    			/* EpisodeNumberDetection */
    		}
    	}
    }
  • 200 / verification_required (email_code) — code emailed, user enters it.
    json
    {
    	"success": true,
    	"data": {
    		"importId": "uuid",
    		"status": "verification_required",
    		"method": "email_code",
    		"totalItems": 23,
    		"ownerEmail": "d***@example.com"
    	}
    }
  • 200 / verification_required (feed_recheck) — no reachable owner email; UI shows the three-path fallback (unlock / email visibility / paste verify tag).
    json
    {
    	"success": true,
    	"data": {
    		"importId": "uuid",
    		"status": "verification_required",
    		"method": "feed_recheck",
    		"totalItems": 23,
    		"verificationToken": "ppv-<uuid>"
    	}
    }
  • 400 — parse failure / empty feed / invalid URL (code from parseRssFeedFull).
  • 409 — an import is already in-flight for this podcast; importId returned for client to redirect.
  • 504 — feed fetch timed out.

Ownership verification

Tier 1 — email magic code

POST /api/imports/:id/verify-ownership
Body: { "code": "123456" }     // exactly 6 digits

Calls validate_import_ownership_code RPC. On success, flips status to preview; on failure returns 400 with code equal to the RPC's error_code (e.g. INVALID, EXPIRED, LOCKED_OUT).

POST /api/imports/:id/resend-code

Reissues a code via create_import_ownership_code. Rate-limiting is enforced inside the RPC; a 429 response includes rate_limit_reset_at.

Tier 2 — feed recheck

POST /api/imports/:id/recheck-feed

Re-fetches the source feed (cache-busted via ?_ppv=<ts>) and auto-detects which of three user actions took effect, in priority order:

  1. Unlocked<podcast:locked> is no longer yes. Clears is_locked_source and transitions to preview.
  2. Token match — the row's verification_token appears in a <podcast:txt purpose="verify"> tag. Transitions to preview.
  3. Email pivot — an owner email has appeared (either on <podcast:locked owner="…"> or <itunes:owner/email>). Persists the address, issues a code, sends the email, and flips method to email_code.
  4. Still stuck — returns 400 STILL_LOCKED with a hint to try another path at the source host.

Status codes: 400 for NOT_APPLICABLE (status already past verification) or STILL_LOCKED; 504 for fetch timeout.

Pre-flight preview & confirm

POST /api/imports/:id/confirm
Body: {
  "applyDetectedEpisodeNumbers": true | false,
  "limit": number | null
}

Called from the preview page once the user reviews the detected episode-number pattern and picks an import limit. Applies the selection via apply_import_preview_selection RPC which atomically:

  • Marks items beyond the limit as skipped_user_limit (held).
  • Patches parsed_payload.episodeNumber on the selected items from the cached detection.perGuid map (only when applyDetectedEpisodeNumbers = true).
  • Bumps skipped_count on the parent row.

Then loads remaining pending items, dispatches one queue message per item (spaced ~0.25s to be polite to the source host, capped at 900s), and transitions to running.

Terminal-on-confirm edge case: when every item was either a pre-existing duplicate or held by the limit, there's nothing for the queue to process. The route transitions directly to completed / partial / failed based on counters, since the worker can't finalise what it never sees.

Failure modes:

  • 400 NOT_IN_PREVIEW — import is not in preview status.
  • 400 INVALID_SELECTIONapplyDetectedEpisodeNumbers=true but no pattern was detected.
  • 500 DISPATCH_FAILED — queue send failed. Status is rolled back to preview so the user can retry without losing the held-item marking.

Success:

json
{
	"success": true,
	"data": { "status": "running", "selectedCount": 18, "heldCount": 5 }
}

Read import state

GET /api/imports/:id

Returns the parent row (with redacted locked_owner_email) plus every import_items row ordered by created_at. Used by the progress page on first render; live updates come from Supabase Realtime subscriptions in the UI.

Cancel, retry, resume

POST /api/imports/:id/cancel

Flips status to canceled from any non-terminal state. Items currently in processing finish naturally (worker doesn't check cancel mid-stream); items still on the queue get ack'd and skipped when the worker re-fetches the parent.

POST /api/imports/:id/retry-failed

Resets every failed item back to pending and re-dispatches. For a previously canceled import, sweeps the full pending set — pre-existing pending items lost their original queue messages to the cancel skip path and need re-dispatch. On dispatch failure, reverts the parent to canceled (preserving the Resume UX).

POST /api/imports/:id/items/:itemId/retry

Single-item retry, atomic via retry_import_item RPC (resets item + decrements failed_count in one transaction to avoid parallel-retry races). Rejects items in skipped_user_limit — that's an "extend import" semantic, not a retry.

POST /api/imports/:id/resume

Only valid on canceled imports. Two sub-cases:

  • started_at IS NULL — canceled from the preview step. Restores status to preview; no queue dispatch (the confirm endpoint will dispatch).
  • started_at set — was partway through running. Loads pending items and re-dispatches.

On dispatch failure, rolls back to canceled via the shared markDispatchFailed helper so the Resume button stays available.

Queue message format

The Hono route is the sole producer for the podcast-imports queue. Message schema:

typescript
interface PodcastImportMessage {
	type: 'import_item';
	import_id: string; // podcast_imports.id
	import_item_id: string; // import_items.id
	podcast_id: string;
}

Consumer configuration in workers/podcast-import-executor/wrangler.toml:

  • max_batch_size = 1 — each message is a long-running stream; batching would block the batch on the slowest download.
  • max_concurrency = 4 — polite to the source host.
  • max_retries = 5 with exponential backoff + jitter (capped at 15 min/attempt).
  • dead_letter_queue = "podcast-imports-dlq".

See the Podcast Import Executor worker doc for per-item pipeline details.

RPC surface

The route delegates several transactional paths to SQL functions (all SECURITY DEFINER):

RPCCalled fromPurpose
create_import_ownership_codePOST /podcast, /recheck-feed, /resend-codeIssue a 6-digit code; enforce rate limits.
validate_import_ownership_codePOST /:id/verify-ownershipValidate, mark consumed, transition row.
apply_import_preview_selectionPOST /:id/confirmMark held items, patch episode numbers, bump counters.
retry_import_itemPOST /:id/items/:itemId/retryReset item + decrement failed_count atomically.

Environment bindings

BindingTypePurpose
PODCAST_IMPORTS_QUEUEQueue<PodcastImportMessage>Per-episode job dispatch.
RESEND_API_KEYstringOwnership code email sending.
RESEND_FROM_EMAILstringEmail sender address.

When PODCAST_IMPORTS_QUEUE is unbound the route throws HTTPException(500, 'Import queue is not configured') — dispatch is never attempted silently.

TypeScript client usage

typescript
import { createApiClient } from '$api/client';
import { createClient } from '$lib/supabase/client';

const supabase = createClient();
const {
	data: { session }
} = await supabase.auth.getSession();
const token = session?.access_token!;
const client = createApiClient(fetch);

// Start
const res = await client.api.imports.podcast.$post(
	{ json: { podcast_id, feed_url } },
	{ headers: { Authorization: `Bearer ${token}` } }
);

// Verify email code
await client.api.imports[':id']['verify-ownership'].$post(
	{ param: { id: importId }, json: { code: '123456' } },
	{ headers: { Authorization: `Bearer ${token}` } }
);

// Re-check feed
await client.api.imports[':id']['recheck-feed'].$post(
	{ param: { id: importId } },
	{ headers: { Authorization: `Bearer ${token}` } }
);

// Confirm pre-flight selection
await client.api.imports[':id'].confirm.$post(
	{ param: { id: importId }, json: { applyDetectedEpisodeNumbers: true, limit: 50 } },
	{ headers: { Authorization: `Bearer ${token}` } }
);

Internal documentation - Not for public distribution