Verbiage

HTTP API Reference

created just now :: modified just now :: 15.4 KB .md

Tools

download raw

HTTP API Reference#

This documents the regular HTTP API served by the radio backend. It excludes the

AT Protocol XRPC endpoints under /xrpc/*; see xrpc.md for those.

All paths are relative to the backend base URL, for example

https://radio.example.test. JSON request and response bodies use camelCase

field names. Timestamps are Unix seconds.

Authentication#

Public read/listen endpoints do not require authentication:

  • GET /api/health
  • GET /api/session
  • GET /api/radio/state
  • GET /api/radio/seek
  • GET /api/radio/ws
  • GET /api/radio/chat/ws
  • GET /api/songs
  • GET /api/songs/{songId}/audio
  • GET /api/songs/{songId}/cover
  • GET /api/songs/{songId}/cover/thumbnail

Admin endpoints require a signed-in admin DID. The backend accepts the session

token either as:

  • Authorization: Bearer <sessionToken>
  • the configured session cookie

Admin failures are returned as JSON:

{ "error": "unauthenticated" }

Common error codes:

StatusErrorMeaning
400request-specific codeInvalid input.
401unauthenticatedMissing or invalid session.
403admin_requiredSession DID is not on the admin allowlist.
404request-specific codeResource missing.
500internal_server_errorBackend failure.

Shared Types#

Song#

interface Song {
  id: string
  title: string
  artist: string
  album: string | null
  genre: string | null
  durationSeconds: number | null
  mimeType: string | null
  hasCover: boolean
  addedByDid: string
  createdAt: number
  loudnessLufs: number | null
  loudnessPeak: number | null
}

QueueItem#

Queue items are joined to song metadata by the backend.

interface QueueItem {
  id: string
  position: number
  queuedByDid: string
  songId: string
  title: string
  artist: string
  album: string | null
  genre: string | null
  durationSeconds: number | null
  mimeType: string | null
  hasCover: boolean
  addedByDid: string
  createdAt: number
  loudnessLufs: number | null
  loudnessPeak: number | null
}

RadioState#

interface RadioState {
  currentSongId: string | null
  status: "playing" | "paused" | "stopped"
  startedAt: number | null
  pausedAt: number | null
  positionSeconds: number
  updatedByDid: string | null
}

positionSeconds is already adjusted for elapsed wall time when status is

playing.

RadioSnapshot#

interface RadioSnapshot {
  state: RadioState
  currentSong: Song | null
  queue: QueueItem[]
}

RadioAlbum#

interface RadioAlbum {
  id: string
  title: string
  position: number
  isEnabled: boolean
  tracks: Song[]
}

ChatMessage#

interface ChatMessage {
  id: string
  senderDid: string
  body: string
  createdAt: number
  kind: "user" | "now_playing"
}

Health And Session#

GET /api/health#

Returns:

{ "ok": true }

GET /api/session#

Reads the bearer token or session cookie and returns the current auth state.

interface SessionResponse {
  authenticated: boolean
  accountDid: string | null
  isAdmin: boolean
}

GET /api/oauth/start?input=<handle-or-did>#

Starts the OAuth sign-in flow and redirects to the authorization server. input

is required.

GET /api/oauth/callback#

OAuth callback route. On success, sets the session cookie and redirects back to

the configured frontend success URL with the session token.

POST /api/logout#

Signs out the current session and clears the session cookie.

Success: 204 No Content.

Radio Read API#

GET /api/radio/state#

Returns the current RadioSnapshot.

The backend lazily advances expired tracks before returning this snapshot, so a

fresh fetch should reflect the real current song and queue.

GET /api/radio/seek#

Returns the current playback position only:

interface RadioSeek {
  positionSeconds: number
}

Use this when a client already has the snapshot and only needs to resync the

audio element position.

Radio Admin API#

All endpoints in this section require an admin session.

POST /api/radio/queue#

Append an existing song to the queue.

Request:

{ "songId": "song-id" }

Response: updated RadioSnapshot.

POST /api/radio/queue/album#

Append several existing songs in order.

Request:

{ "songIds": ["song-1", "song-2"] }

Response: updated RadioSnapshot.

DELETE /api/radio/queue#

Clear every queued item.

Response: updated RadioSnapshot.

DELETE /api/radio/queue/{queueId}#

Remove one queue item.

Response: updated RadioSnapshot.

POST /api/radio/queue/reorder#

Set queue order by queue item id.

Request:

{ "queueIds": ["queue-item-1", "queue-item-2"] }

The list should contain the queue ids in the desired final order.

Response: updated RadioSnapshot.

POST /api/radio/control/{action}#

Controls playback. {action} is one of:

  • play
  • pause
  • stop
  • skip
  • previous

Request:

{ "intent": "explicit_admin_action" }

The explicit intent value is required to avoid accidental control calls.

Response: updated RadioSnapshot.

Songs#

GET /api/songs#

Returns Song[], newest first.

POST /api/songs#

Upload a local audio file with multipart/form-data. Requires admin.

Fields:

FieldRequiredNotes
fileyesAudio file bytes. Playlist files are rejected here.
titleusuallyCan be omitted only when embedded metadata or filename parsing can fill it.
artistusuallyCan be omitted only when embedded metadata or filename parsing can fill it.
albumnoEmpty values are stored as null.
genrenoEmpty values are ignored.
durationSecondsnoPositive integer. If omitted, embedded metadata may fill it.
addToQueuenoString true to queue immediately. Any other value is false.

Response: created or deduplicated Song.

Example:

curl -X POST "$BASE/api/songs" \
  -H "Authorization: Bearer $TOKEN" \
  -F "file=@./track.mp3;type=audio/mpeg" \
  -F "title=Track Title" \
  -F "artist=Artist Name" \
  -F "album=Album Name" \
  -F "addToQueue=true"

POST /api/songs/from-url#

Import audio from an HTTP(S) URL or a supported yt-dlp URL. Requires admin.

Request:

interface UrlSongRequest {
  url: string
  title?: string
  artist?: string
  album?: string
  addToQueue?: boolean
}

For plain HTTP(S) audio and playlist URLs, title and artist are required.

For yt-dlp sources such as YouTube, SoundCloud, Bandcamp, and Vimeo, title and

artist can be inferred from the source metadata.

Response: imported Song. Playlist URLs import up to 100 same-origin entries and

return the first imported song; finished queue/state updates also appear on the

radio websocket.

Common errors:

StatusErrorMeaning
400invalid_urlURL is not HTTP(S).
400missing_titlePlain URL import needs a title.
400missing_artistPlain URL import needs an artist.
400url_fetch_failedBackend could not fetch the URL.
400playlist_requires_batch_importDirect file upload was a playlist. Use URL import.
422source_unavailableyt-dlp says the source is removed/private/unavailable.
502ytdlp_failedyt-dlp failed for another reason.

PUT /api/songs/{songId}#

Update editable metadata. Requires admin.

Request:

interface SongMetadataRequest {
  title: string
  artist: string
  album?: string | null
  genre?: string | null
  durationSeconds?: number | null
}

Response: updated Song.

DELETE /api/songs/{songId}#

Delete a song. Requires admin.

The delete also removes queued entries for that song through database cascades.

Response: updated RadioSnapshot.

PUT /api/songs/{songId}/cover#

Upload or replace cover art with multipart/form-data. Requires admin.

Fields:

FieldRequiredNotes
coveryesImage bytes. The field content type is stored with the image.

Response: updated Song.

Audio And Cover Files#

GET /api/songs/{songId}/audio#

Returns the stored audio bytes for a song. No auth required.

Use the id from currentSong.id, queue[].songId, or GET /api/songs:

<audio src="https://radio.example.test/api/songs/song-id/audio" controls></audio>

The response uses the stored MIME type when available, otherwise

application/octet-stream.

Audio supports byte ranges:

Range: bytes=0-1048575

Range behavior:

RequestResponse
no Range200 OK, full body, Accept-Ranges: bytes, Content-Length
valid single range206 Partial Content, Content-Range, Accept-Ranges: bytes, Content-Length
invalid range416 Range Not Satisfiable, Content-Range: bytes */<total>

Supported range forms:

  • bytes=<start>-<end>
  • bytes=<start>-
  • bytes=-<suffixLength>

Multiple ranges are not supported.

Errors:

StatusErrorMeaning
404song_not_foundNo song row exists.
404audio_not_foundSong row exists but the file cannot be read.

GET /api/songs/{songId}/cover#

Returns original cover art bytes. No auth required.

Response headers include:

  • Content-Type: stored cover MIME type or application/octet-stream
  • Cache-Control: public, max-age=31536000, immutable
  • Content-Length

Error: 404 { "error": "cover_not_found" }.

GET /api/songs/{songId}/cover/thumbnail#

Returns a generated 128 by 128 JPEG thumbnail. No auth required.

Response headers include:

  • Content-Type: image/jpeg
  • Cache-Control: public, max-age=31536000, immutable
  • Content-Length

Error: 404 { "error": "cover_not_found" }.

Album Loops#

Album loops are admin-only fallback playback lists.

GET /api/radio/albums#

Returns RadioAlbum[].

POST /api/radio/albums#

Create an album loop.

Request:

{ "title": "Album Loop", "songIds": ["song-1", "song-2"] }

Response: created RadioAlbum.

POST /api/radio/albums/from-metadata#

Create an album loop from songs matching an album metadata value.

Request:

{ "album": "Album Name" }

Response: created RadioAlbum.

DELETE /api/radio/albums/{albumId}#

Delete an album loop.

Response: remaining RadioAlbum[].

POST /api/radio/albums/{albumId}/songs#

Append songs to an album loop, skipping existing tracks.

Request:

{ "songIds": ["song-1", "song-2"] }

Response: updated RadioAlbum.

PUT /api/radio/albums/{albumId}/enabled#

Enable or disable an album loop.

Request:

{ "enabled": true }

Response: updated RadioAlbum[].

Radio Websocket#

GET /api/radio/ws#

Open this as a websocket. Use wss:// for HTTPS sites and ws:// for HTTP.

Example URL:

wss://radio.example.test/api/radio/ws

On connect, the server sends:

1. snapshotChanged with the current RadioSnapshot.

2. viewerCountChanged with the current listener count and listener DIDs.

Server messages:

type RadioEvent =
  | { type: "snapshotChanged"; snapshot: RadioSnapshot }
  | { type: "viewerCountChanged"; viewerCount: number; listenerDids: string[] }
  | { type: "viewerKeepalive" }

snapshotChanged is broadcast after queue, song, upload, and admin playback

mutations. Natural track-end advancement intentionally does not broadcast every

time; clients should self-advance locally and can resync with

GET /api/radio/state or GET /api/radio/seek.

Viewer presence is opt-in. Send a stable per-tab viewerId after opening:

{ "type": "viewerHello", "viewerId": "tab-uuid", "did": "did:plc:..." }

When the server sends viewerKeepalive, respond with:

{ "type": "viewerKeepalive", "viewerId": "tab-uuid", "did": "did:plc:..." }

did is optional. It is accepted only if it starts with did: and contains

ASCII letters, digits, :, -, _, or .. viewerId must be at most 128

characters and contain only ASCII letters, digits, -, or _.

The server sends keepalives every 60 seconds. If a registered viewer does not

send a keepalive for about 70 seconds, the server closes the socket and removes

that viewer from the count.

Chat Websocket#

GET /api/radio/chat/ws#

Open this as a websocket for chat messages and now-playing breadcrumbs.

On connect, the server sends recent history:

{ type: "history"; messages: ChatMessage[] }

Server messages:

type ChatEvent =
  | { type: "history"; messages: ChatMessage[] }
  | { type: "message"; message: ChatMessage }
  | { type: "messageDeleted"; id: string }
  | { type: "messagesPurged"; senderDid: string }

To send a chat message, send the session token inside the websocket message:

{ "type": "send", "text": "hello", "token": "session-token" }

Chat sends are ignored without an authenticated session. Empty messages,

messages over 1000 characters, and messages from banned DIDs are also ignored.

The server does not send per-message error events for rejected chat sends.

Chat Moderation API#

All endpoints in this section require admin.

DELETE /api/radio/chat/messages/{messageId}#

Delete one chat message.

Success: 204 No Content.

Error: 404 { "error": "chat_message_not_found" }.

GET /api/radio/chat/bans#

Returns:

interface ChatBan {
  did: string
  bannedByDid: string
  reason: string | null
  createdAt: number
}

POST /api/radio/chat/bans#

Ban a DID and purge its messages from live clients.

Request:

{ "did": "did:plc:...", "reason": "optional reason" }

Response: ChatBan.

DELETE /api/radio/chat/bans/{did}#

Remove a ban.

Success: 204 No Content.

Error: 404 { "error": "ban_not_found" }.

Subsonic Import Helpers#

These endpoints are admin-only helpers for importing from an external Subsonic

server. They are separate from the Subsonic-compatible /rest/* shim.

POST /api/subsonic/search#

Request:

interface SubsonicSearchRequest {
  serverUrl: string
  username: string
  password: string
  query: string
}

Response:

interface SubsonicSongResult {
  id: string
  title: string
  artist: string
  album: string | null
  durationSeconds: number | null
  coverArtId: string | null
}

POST /api/songs/from-subsonic#

Request:

interface SubsonicImportRequest {
  serverUrl: string
  username: string
  password: string
  songId: string
  coverArtId?: string | null
  addToQueue?: boolean
}

Response: imported Song.

POST /api/songs/from-subsonic-share#

Request:

interface SubsonicShareImportRequest {
  shareUrl: string
  addToQueue?: boolean
}

Response: imported Song.

Admin Allowlist API#

GET /api/admin/permissions#

Requires admin. Returns:

interface AdminPermissionsResponse {
  whitelistedDids: string[]
  permissions: { key: string; description: string }[]
}

POST /api/admin/dids#

Requires admin. Adds a DID to the admin allowlist.

Request:

{ "did": "did:plc:..." }

Response: AdminPermissionsResponse.

DELETE /api/admin/dids/{did}#

Requires admin. Removes a DID from the admin allowlist.

Response: AdminPermissionsResponse.