Skip to main content

Podcasts API

This page documents the PodcastModule endpoints under /api/v1/podcasts and the background scheduler service.

Data models​

Podcast​

ColumnTypeNotes
idStringUUID primary key
feedUrlStringUnique — RSS feed URL
titleString
descriptionString?
artworkUrlString?
authorString?
websiteUrlString?
lastRefreshedAtDateTime?When the feed was last successfully fetched
nextRefreshAtDateTime?Computed by the scheduler
refreshIntervalMinutesIntDefault 60; minimum 15, maximum 10080 (1 wk)
downloadPolicyDownloadPolicyALL | LATEST_N | MANUAL
keepLatestNInt?Required when downloadPolicy = LATEST_N
retentionPolicyRetentionPolicyKEEP_ALL | DELETE_AFTER_LISTENED | KEEP_LATEST_N
subscribedBooleanfalse after unsubscribe (soft record)
createdAtDateTime
updatedAtDateTime

PodcastEpisode​

ColumnTypeNotes
idStringUUID primary key
podcastIdStringFK → Podcast, cascade delete
guidStringRSS item GUID
titleString
descriptionString?
publishedAtDateTime?
durationFloat?Seconds, parsed from RSS <itunes:duration>
audioUrlStringOriginal remote URL from the RSS feed
downloadStatusEpisodeDownloadStatusNOT_DOWNLOADED | PENDING | DOWNLOADING | DOWNLOADED | FAILED
downloadPathString?Absolute server path when downloaded
fileSizeBigInt?Bytes

Constraint: @@unique([podcastId, guid]) — prevents duplicate episodes across feed refreshes.

PodcastEpisodeProgress​

ColumnTypeNotes
idStringUUID primary key
userIdStringFK → User, cascade delete
episodeIdStringFK → PodcastEpisode, cascade delete
currentTimeFloatPlayback position in seconds
updatedAtDateTime

Constraint: @@unique([userId, episodeId])

Base path​

/api/v1/podcasts

All endpoints except GET /episodes/:id/stream require a JWT (Authorization: Bearer <token>).

Endpoints​

GET /settings​

Returns whether the podcast feature is enabled. Admin only.

Response:

{ "enabled": true }

PATCH /settings​

Enables or disables the podcast feature globally. Admin only.

Request body:

{ "enabled": true }

POST /​

Subscribe to a podcast by RSS feed URL. Returns 409 if already subscribed, 403 if podcasts are disabled.

Request body:

{ "feedUrl": "https://example.com/feed.rss" }

Response — PodcastDto (201 Created).


GET /​

Returns all subscribed podcasts for the authenticated user. Returns 403 if podcasts are disabled.

Response — array of PodcastDto.


GET /:id​

Returns a single podcast by ID including episodeCount.

Response — PodcastDto. Returns 404 if not found.


PATCH /:id​

Update per-podcast settings (refresh interval, download policy, retention policy).

Request body (UpdatePodcastSettingsDto — all fields optional):

{
"refreshIntervalMinutes": 120,
"downloadPolicy": "LATEST_N",
"keepLatestN": 5,
"retentionPolicy": "KEEP_LATEST_N"
}

Returns 400 if refreshIntervalMinutes is outside [15, 10080].


DELETE /:id​

Unsubscribe from a podcast. The podcast record is soft-deleted (subscribed = false).

Query params:

ParamValuesDescription
deleteFilestrue | falseWhether to delete downloaded episode files

Response: 204 No Content


GET /:id/episodes​

Returns paginated episodes for a podcast, joined with the requesting user's progress.

Query params: page (default 1), pageSize (default 50).

Response:

{
"episodes": [
{
"id": "uuid",
"podcastId": "uuid",
"title": "Episode 42",
"publishedAt": "2026-03-01T00:00:00.000Z",
"duration": 3600,
"downloadStatus": "DOWNLOADED",
"currentTime": 1234.5
}
],
"total": 120
}

currentTime is null when the user has no progress for that episode.


PUT /episodes/:id/progress​

Saves the playback position for an episode. Creates or updates the PodcastEpisodeProgress row. An episode is automatically marked listened when currentTime / duration >= 0.95.

Request body:

{ "currentTime": 1234.5 }

Response: 204 No Content


POST /episodes/:id/download​

Queues an episode for download. Returns immediately; the download runs in a background queue (max 3 concurrent).

Response:

{ "status": "queued" }

Also returns "already_downloaded" or "already_downloading" without re-queuing.


GET /episodes/:id/stream​

Streams the downloaded episode audio file. Uses a short-lived stream token instead of a JWT (compatible with <audio> src and react-native-track-player).

Auth: Stream token via ?streamToken=<token> (issued by POST /stream-token).

Response: Byte-range-capable audio stream (206 Partial Content for range requests).


POST /stream-token​

Issues a short-lived stream token for the authenticated user. Tokens are shared with the audiobook streaming system (AudiobookStreamTokenService).

Response:

{ "token": "abc123", "expiresAt": "2026-05-03T12:05:00.000Z" }

POST /:id/refresh​

Fetches the RSS feed immediately and syncs new episodes. Returns the updated PodcastDto.


POST /scan-storage​

Scans PODCAST_STORAGE_PATH for .mp3/.m4a/.m4b files and links them to existing episode records by filename. Admin only.


POST /import-storage​

Like scan-storage but also creates missing Podcast and PodcastEpisode records for files that have no matching DB entry. Admin only.


Scheduler service​

PodcastSchedulerService runs a cron job every minute and refreshes any podcast whose nextRefreshAt is in the past.

After each successful refresh, nextRefreshAt is set to now + refreshIntervalMinutes. On failure, a shorter retry interval (refreshIntervalMinutes / 4, minimum 15 minutes) is used.

The scheduler creates a Task row (visible in Admin → Tasks) for manual refresh operations triggered via POST /:id/refresh.

Download queue​

PodcastService maintains an in-memory download queue with configurable concurrency (maxConcurrentDownloads = 3). requestDownload pushes an episode ID onto the queue; processQueue dequeues and calls downloadEpisode for each slot.

downloadEpisode streams the remote audioUrl to PODCAST_STORAGE_PATH/<podcastId>/<episodeId>.<ext>, writes the file size to the DB, and updates downloadStatus through DOWNLOADING → DOWNLOADED (or FAILED on error).

After a download completes, applyRetentionPolicy runs to enforce KEEP_LATEST_N or DELETE_AFTER_LISTENED rules for the parent podcast.