Skip to content

REST API ​

Every HTTP endpoint. The tables below are generated from the server source, so they match the current branch.

Reading the "Access" column ​

ValueWhat to send
publicnothing
art tokenX-Art-Token or ?token=
art + audio tokenX-Art-Token + X-Audio-Token, or ?artToken=&audioToken=
admin (JWT)Authorization: Bearer <token> from /api/admin/login
admin (bearer or query)same, but the token is also accepted in the query — for <audio src>
super admin onlythe .env account; helper admins are refused

Reading the "Privilege" column ​

Three notations that mean different things:

NotationMeaning
A + Bboth privileges required
A or Beither is enough
… (per section)which privilege applies depends on what the request changes

Privileges apply in addition to the JWT: the account must be either the super admin or hold that privilege.

Error format ​

Errors come back in an error field, and its value is not always a string — many endpoints return an object containing every locale. Read Message format and i18n before writing error handling.

How this page is maintained ​

The tables are generated from the server source in both languages, so the list is always complete and always matches the code. Descriptions are written by hand in reference/annotations/endpoints.mjs and translated in reference/annotations/en/endpoints.mjs; anything untranslated falls back to Ukrainian rather than going missing.

npm run check fails if the generated tables drift from the code, if a description refers to an endpoint that no longer exists, or if a translation key has no counterpart in the base file.

Endpoints: 101. Documented in detail: 101.

Admin ​

MethodPathAccessPrivilegeDescription
GET/api/admin/adminssuper admin only—Helper admins and the list of grantable privileges.
POST/api/admin/adminssuper admin only—Creates a helper admin.
DELETE/api/admin/admins/:idsuper admin only—Deletes a helper admin.
PUT/api/admin/admins/:id/privilegessuper admin only—Changes a helper admin's permissions.
PUT/api/admin/admins/:id/reset-passwordsuper admin only—Resets a helper admin's password to a new temporary one.
POST/api/admin/admins/self/activateadmin (JWT)—A helper admin activates their own account.
PUT/api/admin/admins/self/loginadmin (JWT)—Changes your own login name.
PUT/api/admin/admins/self/passwordadmin (JWT)—Changes your own password.
GET/api/admin/artist-artsadmin (JWT)ARTIST_ARTSArtists, flagged by whether artwork exists.
DELETE/api/admin/artist-arts/:artistadmin (JWT)ARTIST_ARTS or EDITOR_METADeletes an artist's artwork.
GET/api/admin/artist-arts/file/:artistadmin (JWT)ARTIST_ARTSThe artwork file for preview in an admin UI.
POST/api/admin/artist-arts/uploadadmin (JWT)ARTIST_ARTSUploads an artist image.
GET/api/admin/auditadmin (JWT)—The admin action log.
GET/api/admin/background-musicadmin (JWT)JINGLES_UPLOADERBackground tracks, paginated.
GET/api/admin/background-music/:id/audioadmin (JWT)JINGLES_UPLOADERBackground track audio for previewing.
POST/api/admin/background-music/:id/usedadmin (JWT)JINGLES_UPLOADEREnables or disables a track in the selection pool.
POST/api/admin/background-music/batch-deleteadmin (JWT)JINGLES_UPLOADERDeletes several background tracks.
POST/api/admin/background-music/batch-moveadmin (JWT)JINGLES_UPLOADERMoves background tracks between modes.
GET/api/admin/background-music/countsadmin (JWT)—How many background tracks are available.
GET/api/admin/background-music/fileadmin (bearer or query)—Serves the background music file itself from local storage.
POST/api/admin/background-music/uploadadmin (JWT)JINGLES_UPLOADERUploads a background track.
POST/api/admin/background-music/upload-check-duplicateadmin (JWT)JINGLES_UPLOADERChecks the filename before uploading.
GET/api/admin/donations/historyadmin (JWT)DONATIONS_MANAGEDonation history, filterable by time window.
GET/api/admin/donations/price-previewadmin (JWT)DONATIONS_MANAGEFirst-tier price for an arbitrary duration — for the settings modal preview.
GET/api/admin/donations/settingsadmin (JWT)—Current donation settings and the active provider.
POST/api/admin/donations/settingsadmin (JWT)DONATIONS_MANAGESaves donation settings.
GET/api/admin/historyadmin (JWT)STATSThe full playback history.
GET/api/admin/jinglesadmin (JWT)JINGLES_UPLOADERJingles, paginated and searchable.
GET/api/admin/jingles/:id/audioadmin (JWT)JINGLES_UPLOADERJingle audio for previewing in an admin UI.
POST/api/admin/jingles/:id/usedadmin (JWT)JINGLES_UPLOADEREnables or disables a jingle in rotation.
POST/api/admin/jingles/batch-deleteadmin (JWT)JINGLES_UPLOADERDeletes several jingles at once.
POST/api/admin/jingles/batch-moveadmin (JWT)JINGLES_UPLOADERMoves several jingles between day and night mode.
GET/api/admin/jingles/countsadmin (JWT)—How many jingles exist and how many are usable on air.
GET/api/admin/jingles/fileadmin (bearer or query)—Serves the jingle file itself from local storage.
POST/api/admin/jingles/uploadadmin (JWT)JINGLES_UPLOADERUploads a jingle.
POST/api/admin/jingles/upload-check-duplicateadmin (JWT)JINGLES_UPLOADERChecks whether a jingle with that filename already exists.
POST/api/admin/loginpublic—Admin login.
POST/api/admin/logoutpublic—Logout: clears the token cookie.
GET/api/admin/lyrics/audio-previewadmin (JWT)EDITOR_LYRICS or EDITOR_METAA URL for audio playback while aligning timings in the editor.
DELETE/api/admin/lyrics/cacheadmin (JWT)EDITOR_LYRICS or EDITOR_METARemoves lyrics from the cache.
PUT/api/admin/lyrics/cacheadmin (JWT)EDITOR_LYRICS or EDITOR_METASaves edited lyrics.
GET/api/admin/lyrics/cache-entryadmin (JWT)EDITOR_LYRICS or EDITOR_METALyrics for a single track.
GET/api/admin/lyrics/cache-fulladmin (JWT)EDITOR_LYRICS or EDITOR_METAThe complete lyrics cache.
GET/api/admin/lyrics/cache-indexadmin (JWT)EDITOR_LYRICS or EDITOR_METAA lightweight index of which tracks already have lyrics.
GET/api/admin/lyrics/offsetsadmin (JWT)EDITOR_LYRICS or EDITOR_METAAll stored lyrics timing offsets.
GET/api/admin/lyrics/songsadmin (JWT)EDITOR_LYRICS or EDITOR_METATracks with their lyrics status — the editor's input data.
GET/api/admin/phrasesadmin (JWT)JINGLES_UPLOADERPhrases, paginated and searchable.
GET/api/admin/phrases/:id/audioadmin (JWT)JINGLES_UPLOADERPhrase audio for previewing in an admin UI.
POST/api/admin/phrases/:id/usedadmin (JWT)JINGLES_UPLOADEREnables or disables a phrase in rotation.
POST/api/admin/phrases/batch-deleteadmin (JWT)JINGLES_UPLOADERDeletes several phrases at once.
POST/api/admin/phrases/batch-moveadmin (JWT)JINGLES_UPLOADERMoves several phrases between day and night mode.
GET/api/admin/phrases/countsadmin (JWT)—How many phrases exist and how many are usable on air.
GET/api/admin/phrases/fileadmin (bearer or query)—Serves the phrase file itself from local storage.
POST/api/admin/phrases/uploadadmin (JWT)JINGLES_UPLOADERUploads a phrase.
POST/api/admin/phrases/upload-check-duplicateadmin (JWT)JINGLES_UPLOADERChecks whether a phrase with that filename already exists.
GET/api/admin/settingsadmin (JWT)—All radio settings.
POST/api/admin/settingsadmin (JWT)—Saves settings; each section under its own privilege.
DELETE/api/admin/song-editoradmin (JWT)EDITOR_METADeletes a track from the library and from storage.
POST/api/admin/song-editor/batch-deleteadmin (JWT)EDITOR_METADeletes several selected tracks.
POST/api/admin/song-editor/batch-moveadmin (JWT)EDITOR_METAMoves several tracks between modes.
GET/api/admin/song-editor/downloadadmin (JWT)EDITOR_METADownloads the track's source file.
POST/api/admin/song-editor/move-modeadmin (JWT)EDITOR_METAMoves a track between day and night mode.
POST/api/admin/song-editor/saveadmin (JWT)—Saves metadata, lyrics and lyrics offset for a track.
GET/api/admin/song-groupsadmin (JWT)—All song groups with track counts and a preview.
POST/api/admin/song-groupsadmin (JWT)SETTINGS_GROUPSCreates a song group.
DELETE/api/admin/song-groups/:groupIdadmin (JWT)SETTINGS_GROUPSDeletes a song group.
PUT/api/admin/song-groups/:groupIdadmin (JWT)SETTINGS_GROUPSUpdates a group: its name or its tracks.
POST/api/admin/song-groups/:groupId/insertadmin (JWT)QUEUE_MANAGE + SETTINGS_GROUPSQueues an entire group.
GET/api/admin/song-groups/libraryadmin (JWT)—Searches the library to populate a group.
GET/api/admin/songsadmin (JWT)—The whole library for a mode, with lyrics status.
GET/api/admin/statsadmin (JWT)STATSAggregate statistics for the library and the broadcast.
POST/api/admin/switch-modeadmin (JWT)MODE_SWITCHSwitches the radio between day and night mode.
POST/api/admin/upload-batch-deleteadmin (JWT)UPLOAD_SONGSDeletes just-uploaded tracks.
POST/api/admin/upload-batch-moveadmin (JWT)UPLOAD_SONGSMoves just-uploaded tracks between modes.
POST/api/admin/upload-check-duplicateadmin (JWT)UPLOAD_SONGSChecks whether a track already exists, before uploading the file.
POST/api/admin/upload-song-commitadmin (JWT)UPLOAD_SONGSUpload step 3: puts the track into the library.
POST/api/admin/upload-song-fileadmin (JWT)UPLOAD_SONGSUpload step 1: stores the MP3 and reads its tags.
POST/api/admin/upload-song-lyricsadmin (JWT)UPLOAD_SONGS or EDITOR_LYRICS (per section)Looks lyrics up through LRCLIB for uploads or song editor updates.
POST/api/admin/upload-song-urladmin (JWT)UPLOAD_SONGSDownloads audio from YouTube into storage.
GET/api/admin/verifyadmin (JWT)—Validates the session and returns current privileges.
POST/api/admin/youtube-cookiesadmin (JWT)UPLOAD_SONGSStores YouTube cookies for age-restricted videos.
POST/api/admin/youtube-track-infoadmin (JWT)UPLOAD_SONGSReads the track list behind a video or playlist URL.
GET/api/admin/ytbdown-statusadmin (JWT)UPLOAD_SONGSWhether the YouTube download tool is ready.

Client ​

MethodPathAccessPrivilegeDescription
GET/api/art/urlart token—A URL for artist artwork instead of the bytes.
GET/api/artist-art/:artistart token—Artist artwork.
GET/api/audio-keyart token—Exchanges an art token for a short-lived audio token.
GET/api/audio/streamart + audio token—Audio bytes for the current or a requested track.
GET/api/audio/stream/adminadmin (bearer or query)—Track audio for admin interfaces.
GET/api/audio/urlart + audio token—A URL for the audio, without serving the bytes.
GET/api/avatar/:filenameart token—A listener avatar.
GET/api/historypublic—The last 10 played songs.
GET/api/librarypublic—Tracks available to request in the current mode.
GET/api/lyricsart token—Time-synced lyrics for a song.
POST/api/lyrics/offsetadmin (JWT)—Stores the lyrics timing offset for a track.
GET/api/public/configpublic—How the server is configured. The only endpoint with no authentication at all.
GET/api/public/donations/:id/statuspublic—Current status of a specific donation.
POST/api/public/donations/createpublic—Places a donation order for a song at the chosen tier.
GET/api/public/donations/tierspublic—Computed donation-tier prices for a specific song.
POST/webhooks/donations/:providerpublic—Payment confirmation from a donation provider.

Stream ​

MethodPathAccessPrivilegeDescription
GET/api/streamart token—The shared MP3 radio stream (stream mode only).
GET/api/stream/public.mp3public—The same stream without a token — a permanent link for external players.

Details ​

GET /api/admin/admins ​

Helper admins and the list of grantable privileges.

Response:

js
{
  ok: true,
  admins: [{ adminId, login, privileges, authorized }],
  allPrivileges: ['queue_manage', '…'],
}

Password hashes are never included. allPrivileges is convenient for building the permission form.

POST /api/admin/admins ​

Creates a helper admin.

Request body:

js
{ login: '…', password: '…', privileges: ['queue_manage'] }

Response:

js
{ ok: true, admin: { … }, message: { uk: '…', en: '…' } }

The password here is temporary: the account starts with authorized: false and can do nothing until the helper activates it through /admins/self/activate.

DELETE /api/admin/admins/:id ​

Deletes a helper admin.

If they are online, their connection receives force_logout with reason: "admin_deleted" and the session ends at once.

PUT /api/admin/admins/:id/privileges ​

Changes a helper admin's permissions.

Request body:

js
{ privileges: ['queue_manage', 'stats'] }

If the helper is online they immediately receive a privileges_updated event — the change applies without logging in again.

PUT /api/admin/admins/:id/reset-password ​

Resets a helper admin's password to a new temporary one.

Request body:

js
{ newPassword: '…' }

The account becomes unauthorized again and must be re-activated.

POST /api/admin/admins/self/activate ​

A helper admin activates their own account.

Request body:

js
{ tempPassword: '…', newPassword: '…' }

Errors: 400 — not applicable to the super admin, or the temporary password is wrong

The only action available to a helper with authorized: false. On success an admin_authorized event follows.

PUT /api/admin/admins/self/login ​

Changes your own login name.

Request body:

js
{ newLogin: '…', currentPassword: '…' }

Errors: 400 — not applicable to the super admin, or the password is wrong

PUT /api/admin/admins/self/password ​

Changes your own password.

Request body:

js
{ currentPassword: '…', newPassword: '…' }

Errors: 400 — not applicable to the super admin, or the password is wrong

The super admin's password can only be changed via ADMIN_PASS in .env.

GET /api/admin/artist-arts ​

Artists, flagged by whether artwork exists.

Records are created automatically when a daytime track is uploaded.

DELETE /api/admin/artist-arts/:artist ​

Deletes an artist's artwork.

The artist record itself remains; only the image goes away.

GET /api/admin/artist-arts/file/:artist ​

The artwork file for preview in an admin UI.

Unlike the client-facing /api/artist-art/:artist, it is served unwrapped.

POST /api/admin/artist-arts/upload ​

Uploads an artist image.

Headers: Content-Type: image/jpeg

Request body:

js
raw JPEG bytes, up to 10 MB

JPEG only — PNG or WebP are rejected. The image is expected to be already cropped to a portrait format: the client does that before sending.

GET /api/admin/audit ​

The admin action log.

Query: window — 24h and other spans; limit (default 30); offset

Response:

js
{ ok: true, entries: [{ id, adminId, adminLogin, operationType, createdAt, data: { …, details: { version: 1, changes: [{ field, before, after }], info: { … } } } }], total, offset, limit }

data.details is optional; old entries omit it. Field format and new operation types: detailed audit. Served from an in-memory cache, so the query is cheap. Entries are purged automatically after LOG_RETENTION_DAYS days. No special privilege — every admin may read it.

GET /api/admin/background-music ​

Background tracks, paginated.

A host on air sees the same list through the host_get_background_music_list event.

GET /api/admin/background-music/:id/audio ​

Background track audio for previewing.

Works exactly like /api/admin/jingles/:id/audio: a presigned link in the cloud, a link to /api/admin/background-music/file locally.

POST /api/admin/background-music/:id/used ​

Enables or disables a track in the selection pool.

POST /api/admin/background-music/batch-delete ​

Deletes several background tracks.

Request body:

js
{ ids: ['…'] }

POST /api/admin/background-music/batch-move ​

Moves background tracks between modes.

Request body:

js
{ ids: ['…'], targetMode: 'day' | 'night' }

GET /api/admin/background-music/counts ​

How many background tracks are available.

Open to any admin, like the jingle counter.

GET /api/admin/background-music/file ​

Serves the background music file itself from local storage.

Query: mode — day or night; filename — the file name; adminToken — the admin token

The direct counterpart of /api/admin/jingles/file: only needed on local storage, and accepts the token in the header or the query string.

POST /api/admin/background-music/upload ​

Uploads a background track.

Query: mode — day or night

Headers: X-File-Name, Content-Type: audio/mpeg

Request body:

js
raw MP3 bytes, up to 80 MB

On success a background_music_updated event follows.

POST /api/admin/background-music/upload-check-duplicate ​

Checks the filename before uploading.

Request body:

js
{ filename: 'ambient.mp3' }

GET /api/admin/donations/history ​

Donation history, filterable by time window.

Query: window — 24h | 7d | 30d | max (all, bounded by DONATION_RETENTION_DAYS); limit, offset — pagination

Response:

js
{ entries: [{ id, uid, songId, songTitle, songArtist, provider, currency, amount, tier, status, createdAt, paidAt }], total, offset, limit }

Requires donations_manage.

GET /api/admin/donations/price-preview ​

First-tier price for an arbitrary duration — for the settings modal preview.

Query: durationSeconds — optional, defaults to the longest track in the library

Response:

js
{ basePrice: 1 }

Requires donations_manage. Computes against the already-saved settings, not unsaved form values.

GET /api/admin/donations/settings ​

Current donation settings and the active provider.

Response:

js
{
  settings: {
    currency: 'UAH', pricingMode: 'fixed' | 'calculated',
    fixedPrice: 1, pricePerSecond: 0.02,
    tiersEnabled: true, tierCeiling: 5,
    blockDonationsWhileChatting: false,
  },
  radioHostsMode: false,
  provider: null | { id: 'donatello', displayName: 'Donatello', supportedCurrencies: ['UAH'] },
  historyCurrencies: ['UAH'],   // валюти, що реально зустрічались в історії донатів
  donationRetentionDays: 365,
}

Available to any admin without a dedicated privilege — same pattern as GET /api/admin/settings. Writing requires donations_manage.

POST /api/admin/donations/settings ​

Saves donation settings.

Request body:

js
{ currency, pricingMode, fixedPrice, pricePerSecond, tiersEnabled, tierCeiling, blockDonationsWhileChatting }

Response:

js
{ settings: { … }, clamped: false }

If tiersEnabled and the worst case (the longest track in the library for calculated, or fixedPrice for fixed) at the top tier exceeds the provider’s max transaction amount, tierCeiling is automatically lowered and the response is flagged clamped: true.

GET /api/admin/history ​

The full playback history.

Response:

js
{ ok: true, entries: [ … ], total }

Requires the stats privilege. Entries are purged automatically after LOG_RETENTION_DAYS days — the same schedule as the admin action log.

GET /api/admin/jingles ​

Jingles, paginated and searchable.

Served from an in-memory cache, so the query is cheap.

GET /api/admin/jingles/:id/audio ​

Jingle audio for previewing in an admin UI.

Returns { url }. In the cloud that is a presigned link straight to the bucket; on local storage it is an absolute link to /api/admin/jingles/file carrying the token as a query parameter.

POST /api/admin/jingles/:id/used ​

Enables or disables a jingle in rotation.

A disabled jingle stays in storage but never goes on air — handy for parking a seasonal jingle without deleting it.

POST /api/admin/jingles/batch-delete ​

Deletes several jingles at once.

Request body:

js
{ ids: ['…', '…'] }

POST /api/admin/jingles/batch-move ​

Moves several jingles between day and night mode.

Request body:

js
{ ids: ['…'], targetMode: 'day' | 'night' }

Files are moved in storage, so the operation is not instant.

GET /api/admin/jingles/counts ​

How many jingles exist and how many are usable on air.

Response:

js
{
  ok: true,
  day: 12, night: 8,              // total
  dayUsable: 10, nightUsable: 6,  // marked as active
  minRequired: 3,                 // minimum needed for rotation
}

The only endpoint in this section that does not need jingles_uploader, so any admin can see the state. If fewer than minRequired are usable, jingles are not inserted into the broadcast.

GET /api/admin/jingles/file ​

Serves the jingle file itself from local storage.

Query: mode — day or night; filename — the file name; adminToken — the admin token

Only needed on local storage: in the cloud the client follows a presigned link to the bucket instead. The token is accepted in the header or in the query string, because the waveform player fetches the URL with a plain fetch and no headers. Escaping the jingles directory gives 400, a missing file 404.

POST /api/admin/jingles/upload ​

Uploads a jingle.

Query: mode — day or night (default day)

Headers: X-File-Name, Content-Type: audio/mpeg

Request body:

js
raw MP3 bytes, not multipart. Limit: 80 MB

Response:

js
{ ok: true, jingle: { id, filename, mode, duration, used } }

Errors: 400 — empty body, not an MP3, or the configuration does not support jingles; 409 — that filename already exists

As with track uploads, the body is raw bytes. A new jingle is active right away (used: true). If the database write fails the file is removed from storage, so no orphans are left behind. On success a jingles_updated event is broadcast.

POST /api/admin/jingles/upload-check-duplicate ​

Checks whether a jingle with that filename already exists.

Request body:

js
{ filename: 'jingle.mp3' }

Jingle filenames are unique globally, not per mode.

POST /api/admin/login ​

Admin login.

Request body:

js
{ login: '…', password: '…' }

Response:

js
{ ok: true }              // in production
{ ok: true, token: '…' }  // in development

Errors: 400 — login or password missing; 401 — wrong credentials

In production the token is not in the response body — it arrives only in the httpOnly adminToken cookie. Your client must send requests with credentials: "include", and its origin must be listed in CLIENT_ORIGIN. The token lasts 12 hours. Login attempts are rate-limited.

POST /api/admin/logout ​

Logout: clears the token cookie.

Response:

js
{ ok: true }

GET /api/admin/lyrics/audio-preview ​

A URL for audio playback while aligning timings in the editor.

Query: title and artist

Response:

js
{ url: 'https://…' }

In cloud mode a temporary R2 URL; in local mode a URL to /api/audio/stream/admin, which accepts the admin token and needs no art/audio tokens.

DELETE /api/admin/lyrics/cache ​

Removes lyrics from the cache.

Query: title and artist

Response:

js
{ ok: true, existed: true }

After deletion the lyrics are fetched from LRCLIB again on next access.

PUT /api/admin/lyrics/cache ​

Saves edited lyrics.

Request body:

js
{ title: '…', artist: '…', entry: { synced: true, lines: [{ time, text }] } }

Response:

js
{ ok: true }

Keyed by the artist + title pair, not by track identifier.

GET /api/admin/lyrics/cache-entry ​

Lyrics for a single track.

Query: songId, or the title + artist pair

GET /api/admin/lyrics/cache-full ​

The complete lyrics cache.

Returns everything in one go — a heavy response for a large library.

GET /api/admin/lyrics/cache-index ​

A lightweight index of which tracks already have lyrics.

Response:

js
{ items: [ … ] }

Cheaper than /cache-full when you only need the list.

GET /api/admin/lyrics/offsets ​

All stored lyrics timing offsets.

GET /api/admin/lyrics/songs ​

Tracks with their lyrics status — the editor's input data.

Response:

js
{ items: [ … ] }

GET /api/admin/phrases ​

Phrases, paginated and searchable.

Served from an in-memory cache, so the query is cheap.

GET /api/admin/phrases/:id/audio ​

Phrase audio for previewing in an admin UI.

Returns { url }. In the cloud that is a presigned link straight to the bucket; on local storage it is an absolute link to /api/admin/phrases/file carrying the token as a query parameter.

POST /api/admin/phrases/:id/used ​

Enables or disables a phrase in rotation.

POST /api/admin/phrases/batch-delete ​

Deletes several phrases at once.

Request body:

js
{ ids: ['…', '…'] }

POST /api/admin/phrases/batch-move ​

Moves several phrases between day and night mode.

Request body:

js
{ ids: ['…'], targetMode: 'day' | 'night' }

Files are moved in storage, so the operation is not instant.

GET /api/admin/phrases/counts ​

How many phrases exist and how many are usable on air.

Response:

js
{
  ok: true,
  day: 5, night: 3,               // total
  dayUsable: 4, nightUsable: 2,   // marked as active
  minRequired: 1,                 // minimum needed to enable "Phrases on air"
}

The only endpoint in this section that does not need jingles_uploader, so any admin can see the state. Unlike jingles, the "Phrases on air" setting in the admin panel stays disabled (not just warned) until every active mode has at least minRequired usable phrases.

GET /api/admin/phrases/file ​

Serves the phrase file itself from local storage.

Query: mode — day or night; filename — the file name; adminToken — the admin token

The direct counterpart of /api/admin/jingles/file: only needed on local storage, and accepts the token in the header or the query string.

POST /api/admin/phrases/upload ​

Uploads a phrase.

Query: mode — day or night (default day)

Headers: X-File-Name, Content-Type: audio/mpeg

Request body:

js
raw MP3 bytes, not multipart. Limit: 10 MB

Response:

js
{ ok: true, phrase: { id, filename, mode, duration, used } }

Errors: 400 — empty body, not an MP3, longer than ~5 seconds, duration could not be determined, or the configuration does not support phrases; 409 — that filename already exists

Unlike jingles and background music, duration is strictly enforced here: a phrase longer than 5 seconds (with a small tolerance), or one whose duration could not be read at all, is rejected outright. A new phrase is active right away (used: true). If the database write fails the file is removed from storage. On success a phrases_updated event is broadcast.

POST /api/admin/phrases/upload-check-duplicate ​

Checks whether a phrase with that filename already exists.

Request body:

js
{ filename: 'phrase.mp3' }

Phrase filenames are unique globally, not per mode.

GET /api/admin/settings ​

All radio settings.

Available to any admin without a special privilege — the interface needs this data to initialise. Only writing is restricted.

POST /api/admin/settings ​

Saves settings; each section under its own privilege.

Request body:

js
{
  branding:   { telegram_url, byLang: { uk: { dayRadioName, … } } },
  generation: { DAY_ALGORYTM, MAX_DAY_DURATION, GROUP_DEFS, … },
  radioHosts: { guestMaxDurationMinutes, specialGuestMaxDurationMinutes, backgroundMusicMode },
  songGroups: [ … ],   // ignored
}

Errors: 403 — missing the privilege for a section that changes; 400 — validation failed

Authorization is based on what actually changes, not on what was sent: the admin panel always posts every section. branding needs settings_branding, generation needs settings_algorithm, radioHosts needs radio_moderator. songGroups is ignored here: groups are edited through /api/admin/song-groups. See Privileges.

DELETE /api/admin/song-editor ​

Deletes a track from the library and from storage.

Errors: 409 — the track is playing now or is up next

Requires editor_meta. If it was the artist's last daytime track, their artwork record is removed too. Non-super-admins have a daily deletion quota.

POST /api/admin/song-editor/batch-delete ​

Deletes several selected tracks.

Requires editor_meta. Locked tracks are skipped rather than failing the whole operation. The daily quota is 30 deletions for non-super-admins; the response reports the remainder when exceeded.

POST /api/admin/song-editor/batch-move ​

Moves several tracks between modes.

Requires editor_meta. Works on any storage.

GET /api/admin/song-editor/download ​

Downloads the track's source file.

Requires editor_meta. Serves the same MP3 that sits in storage.

POST /api/admin/song-editor/move-mode ​

Moves a track between day and night mode.

Requires editor_meta. The file is physically moved between modes - between bucket prefixes or between folders on disk, depending on the storage. The track identifier changes with the mode, since it contains the mode.

POST /api/admin/song-editor/save ​

Saves metadata, lyrics and lyrics offset for a track.

Request body:

js
{
  songId: 'day/artist - title.mp3',
  metadata: { title, artist, album, year },
  metadataChanged: false,   // the flags decide which privileges are required
  lyricsEntry: { … },  lyricsChanged: false,
  offset: 0,           offsetChanged: false,
  auditContext: { source: 'bulk', force: false }, // optional audit origin hint
}

Errors: 400 — songId or metadata missing; 403 — missing the privilege for what is being changed; 404 — track not found; 409 — the track is locked

Which privilege is needed depends on the flags: editing metadata requires editor_meta, editing lyrics requires editor_lyrics or editor_meta. 409 is an expected answer, not a failure: a track cannot be edited while it is playing or up next, and the reason arrives in the localized field. Changing metadata rewrites the ID3 tags in storage, so the track identifier may change.

GET /api/admin/song-groups ​

All song groups with track counts and a preview.

Response:

js
{ items: [{ id, name, mode, songCount, songsPreview }] }

Any admin may read; changing them requires settings_groups.

POST /api/admin/song-groups ​

Creates a song group.

Request body:

js
{ name: '…', mode: 'day' | 'night', songs: ['day/…'] }

Requires settings_groups.

DELETE /api/admin/song-groups/:groupId ​

Deletes a song group.

Requires settings_groups. Tracks already queued are unaffected.

PUT /api/admin/song-groups/:groupId ​

Updates a group: its name or its tracks.

Request body:

js
the same fields as on creation

Requires settings_groups.

POST /api/admin/song-groups/:groupId/insert ​

Queues an entire group.

Response:

js
{ ok: true, insertedCount: 3, insertedSongIds: ['day/song.mp3', '…'], message: { uk: '…', en: '…' } }

Errors: 400 — the group is empty, does not fit before the mode switch, or a cooldown applies

Requires both queue_manage and settings_groups, because the action touches both the broadcast and the group definitions. Holding only one is not enough. The admin_insert_song_group event does the same thing with the same requirements, so the rule cannot be bypassed over the socket.

GET /api/admin/song-groups/library ​

Searches the library to populate a group.

Query: mode (default day), query, offset (0), limit (5)

GET /api/admin/songs ​

The whole library for a mode, with lyrics status.

Query: mode — day or night; without it, the radio's current mode

Response:

js
[{
  id: 'day/artist - title.mp3',
  title: 'Title', artist: 'Artist',
  filename: 'day/artist - title.mp3',
  lyricsStatus: 'synced' | 'plain' | 'none',
}]

Unlike the public /api/library, this lets you pick either mode and adds lyrics state. No special privilege is needed beyond being an authenticated admin.

GET /api/admin/stats ​

Aggregate statistics for the library and the broadcast.

Response:

js
an object summarising modes, groups and durations

Requires the stats privilege. Computed on the fly from engine state.

POST /api/admin/switch-mode ​

Switches the radio between day and night mode.

Request body:

js
{
  targetMode: 'day' | 'night',
  scheduledTime: '23:30',   // optional, HH:MM in the server's time zone
}

Response:

js
{ ok: true }

Errors: 400 — targetMode is not day/night, or the time is not HH:MM; 409 — switching now is not allowed (a cooldown applies, or a donated track is queued)

Without scheduledTime the switch happens immediately. A time that has already passed today is treated as tomorrow. On 409 the response carries a donated flag explaining the reason.

POST /api/admin/upload-batch-delete ​

Deletes just-uploaded tracks.

Requires upload_songs. Meant for undoing a fresh upload, whereas song-editor/batch-delete works on the whole library and needs a different privilege.

POST /api/admin/upload-batch-move ​

Moves just-uploaded tracks between modes.

Requires upload_songs.

POST /api/admin/upload-check-duplicate ​

Checks whether a track already exists, before uploading the file.

Request body:

js
{ trackId: 'day/artist - title.mp3' }

Response:

js
{ ok: true, exists: false }

A cheap check ahead of a long upload. trackId is built as <mode>/<filename>.

POST /api/admin/upload-song-commit ​

Upload step 3: puts the track into the library.

Request body:

js
{
  metadata: { … },      // from step 1, possibly edited by the user
  lyricsEntry: { … },   // from step 2, optional
}

Response:

js
{ ok: true, track: { … }, lyricsStatus, lyricsFormat, lyricsMessage, message }

Errors: 400 — metadata.filename or metadata.mode missing, or the write failed

Until this step the track takes no part in the broadcast. On success the server broadcasts a library_updated event — refresh your lists from it. For daytime tracks an artist artwork record is created as well.

POST /api/admin/upload-song-file ​

Upload step 1: stores the MP3 and reads its tags.

Query: mode — day or night (default day)

Headers: X-File-Name — percent-encoded filename; Content-Type: audio/mpeg

Request body:

js
raw MP3 bytes, not multipart. Limit: 80 MB

Response:

js
{
  ok: true,
  storageKey: 'day/artist - title.mp3',
  metadata: { artist, title, album, year, duration, mode, filename, storageKey },
}

Errors: 400 — empty body or not an MP3; 409 — a track with that filename exists; 400 — storage write failed

The body is sent as raw bytes (express.raw), not as multipart/form-data — that is the most common cause of a 400 here. Metadata is read from ID3 tags, with fallbacks for missing fields. The track does not enter the library yet.

POST /api/admin/upload-song-lyrics ​

Looks lyrics up through LRCLIB for uploads or song editor updates.

Request body:

js
{ title, artist, album?, duration? }

Response:

js
{
  ok: true,
  lyricsEntry: { synced: true, lines: [{ time, text }] },
  lyricsStatus: 'synced' | 'plain' | 'none',
  lyricsFormat: '…',
  message: { uk: '…', en: '…' },
}

Errors: 400 — title or artist missing; 500 — LRCLIB could not be reached

Requires admin authentication and upload_songs or editor_lyrics; editor_meta alone does not grant access. Search does not persist lyrics: use song-editor/save for updates. LRCLIB is open. Even on 500 the response contains a usable lyricsEntry with notFound: true, so the upload can continue.

POST /api/admin/upload-song-url ​

Downloads audio from YouTube into storage.

Request body:

js
{ url: 'https://www.youtube.com/watch?v=…' }

The longest operation in the whole API: it includes downloading and re-encoding through FFmpeg. The track then goes through the same lyrics and commit steps as a normal file upload.

GET /api/admin/verify ​

Validates the session and returns current privileges.

Response:

js
{
  ok: true,
  role: 'super_admin' | 'admin',
  adminId: '…', login: '…',
  privileges: ['queue_manage', '…'],
  authorized: true,
}

Errors: 401 — invalid session, or the account no longer exists

For helper admins privileges are re-read from the database rather than taken from the token — which is why permission changes take effect without logging in again. Call this when your admin UI starts.

POST /api/admin/youtube-cookies ​

Stores YouTube cookies for age-restricted videos.

Request body:

js
{ cookies: '… # Netscape HTTP Cookie File …' }

Errors: 400 — empty, or the string does not contain youtube.com

Cookies are written to the server's temp directory and live until restart. They are only needed for restricted videos. This is sensitive data: it grants access to the account it came from.

POST /api/admin/youtube-track-info ​

Reads the track list behind a video or playlist URL.

Request body:

js
{ url: 'https://www.youtube.com/…', lang: 'uk' }

Errors: 400 — URL missing or not a YouTube link; 503 — the tool is unavailable; 504 — reading the playlist took too long

Downloads nothing — it only reports what the URL contains so the user can choose.

GET /api/admin/ytbdown-status ​

Whether the YouTube download tool is ready.

Response:

js
{ ok: true, … }

Call this before showing the import form: the tool needs Python and FFmpeg and is unavailable on some deployments.

GET /api/art/url ​

A URL for artist artwork instead of the bytes.

Query: artist — the artist name

Headers: X-Art-Token / ?token=

Response:

js
{ url: 'https://…', ttl: 900 }

In cloud mode this is a temporary R2 URL and the image is not wrapped. In local mode it points back at /api/artist-art/:artist, so the response still has to be decoded.

GET /api/artist-art/:artist ​

Artist artwork.

Headers: X-Art-Token / ?token=

Response:

js
XOR-wrapped image bytes; the real MIME type is in the `X-Art-Mime` header

Errors: 400 — invalid name; 404 — no artwork

The X-Art-Mime header is exposed via Access-Control-Expose-Headers, so browsers can read it. Artist names are normalized to lower case.

GET /api/audio-key ​

Exchanges an art token for a short-lived audio token.

Headers: X-Art-Token and X-Listener-Uid — both required

Response:

js
{ token: '<audioToken>', expiresIn: 900 }

Errors: 400 — X-Listener-Uid missing; 401 — art token invalid or expired

The response carries Cache-Control: no-store. Refresh early — the reference client does so 30 seconds before expiry.

GET /api/audio/stream ​

Audio bytes for the current or a requested track.

Query: track — the identifier from sync or /api/library

Headers: X-Art-Token + X-Audio-Token, or the same values as artToken and audioToken in the query

Errors: 400 — track missing; 401 — invalid tokens; 404 — track not found

In local mode it serves bytes with HTTP Range support. In cloud mode it answers 302 redirecting to a temporary R2 URL — allow redirects if you fetch manually. Audio is not XOR-encrypted: the tokens are only a pass. For <audio src> the tokens have to go in the query, since headers cannot be set there.

GET /api/audio/stream/admin ​

Track audio for admin interfaces.

Query: track — the track identifier

Accepts the admin token in a header or in the query, and needs no art/audio tokens. That is what makes it usable for <audio src> in the editor, where headers cannot be set.

GET /api/audio/url ​

A URL for the audio, without serving the bytes.

Query: track — the track identifier

Headers: same as /api/audio/stream

Response:

js
{ url: 'https://…', ttl: 900 }   // ttl in cloud mode only

Useful when your player wants the URL up front. In local mode it returns a URL back to /api/audio/stream with the tokens already embedded.

GET /api/avatar/:filename ​

A listener avatar.

Headers: X-Art-Token / ?token=

Response:

js
XOR-wrapped image bytes; MIME type in `X-Art-Mime`

Errors: 400 — invalid filename; 404 — file not found

The filename arrives in the usersUpdate event, in the img field.

GET /api/history ​

The last 10 played songs.

Response:

js
[{ id, trackId, title, artist, album, mode: 'day' | 'night', playedAt }]

No tokens needed. Not affected by the current mode (day/night) — just the most recently played tracks in chronological order.

GET /api/library ​

Tracks available to request in the current mode.

Response:

js
[{ id: 'day/artist - title.mp3', title: 'Title', artist: 'Artist' }]

No tokens needed. Returns only tracks of the current mode (day or night), sorted by artist. The id is what you send in suggest_song.

GET /api/lyrics ​

Time-synced lyrics for a song.

Query: title and artist are required, album is optional

Headers: X-Art-Token / ?token=

Response:

js
// after XOR decoding — JSON:
{
  synced: true,
  lines: [{ time: 32.5, text: 'a line' }],
  offset: 0,        // timing correction for this track
  notFound: false,  // true when no lyrics were found
}

Errors: 400 — title or artist missing; 500 — fetch_failed

The response body is XOR-wrapped with the key from the art token, and Content-Type is application/octet-stream. Decode first, then JSON.parse. If the lyrics are not cached, the server tries LRCLIB — that request is noticeably slower.

POST /api/lyrics/offset ​

Stores the lyrics timing offset for a track.

Request body:

js
{ title: '…', artist: '…', offset: 0.5 }

Errors: 400 — title or artist missing

Note the path: it has no /admin prefix even though it requires an admin token. The offset is in seconds and may be negative.

GET /api/public/config ​

How the server is configured. The only endpoint with no authentication at all.

Response:

js
{
  dataProvider:   'json' | 'sql',
  musicSource:    'local' | 'cloud',
  nightMode:      true,     // whether a night mode exists at all
  streamMode:     false,    // false = sync mode, true = shared MP3 stream
  radioHostsMode: false,    // whether live broadcasting is possible
  allPrivileges:  ['queue_manage', '…'],
  timeZone:       'Europe/Kyiv',
  capabilities:   { uploadTracks: true, editSettings: true, … },
}

Every client starts here: streamMode decides how you obtain audio, and musicSource tells you whether to expect a redirect to cloud storage. capabilities toggles individual features on/off based on server configuration (storage, data provider); donationInfo is null when capabilities.donations is off, otherwise it is the active donation configuration, useful for showing pricing/tiers before ever calling /donations/tiers.

GET /api/public/donations/:id/status ​

Current status of a specific donation.

Response:

js
{
  status: 'pending' | 'paid' | 'paid_unqueued' | 'failed' | 'expired',
  tier: 2 | null,
  matchCode: 'F7BQFF' | null,
  expiresAt: 1700000000000 | null,
}

Errors: 404 — no donation with that id, or it belongs to a different uid

Ownership is checked against the uid derived from the request IP — the same one the donation was stamped with on creation. Meant for the payment return page (checkout) or for polling while waiting on a code (matching).

POST /api/public/donations/create ​

Places a donation order for a song at the chosen tier.

Request body:

js
{ songId: 'day/artist - title.mp3', tier?: 2 }

Response:

js
// flowType: 'checkout' (LiqPay, Stripe)
{ donationId: '…', flowType: 'checkout', redirectUrl: 'https://…' }

// flowType: 'matching' (Donatello, Ko-fi)
{
  donationId: '…', flowType: 'matching',
  pageUrl: 'https://…', matchCode: 'F7BQFF',
  amount: 40, currency: 'UAH', expiresAt: 1700000000000,
}

Errors: 400 — donations are disabled or the chosen tier is unavailable; 404 — song not in the library; 409 — the song is currently playing, up next, or donations are paused (RADIO_HOSTS_MODE)

The song is queued only after server-side payment confirmation (a webhook for checkout/Ko-fi, polling for Donatello) — never directly from the client. For matching providers no redirectUrl is returned: show pageUrl and ask the donor to include matchCode in their donation comment; the outcome arrives via the donation_result event or by polling /donations/:id/status.

GET /api/public/donations/tiers ​

Computed donation-tier prices for a specific song.

Query: songId — required, the id from /api/library

Response:

js
{
  currency: 'UAH',
  pricingMode: 'fixed' | 'calculated',
  tiersEnabled: true,
  tiers: [{ tier: 1, price: 1 }, { tier: 2, price: 2 }, …],  // подвоюється з кожним tier
  flowType: 'checkout' | 'matching',
  chattingBlocked: false,
}

Errors: 400 — donations are disabled; 404 — song not in the library; 409 — the song is currently playing or up next

tiers is an empty array when even the first tier exceeds the active provider’s max transaction amount. flowType tells you what to expect from POST /donations/create: checkout returns a redirectUrl right away, matching returns the creator’s page and a confirmation code instead. chattingBlocked only applies under RADIO_HOSTS_MODE — the queue is paused and donations are temporarily refused.

GET /api/stream ​

The shared MP3 radio stream (stream mode only).

Headers: X-Art-Token / ?token=

Errors: 404 — the server runs in sync mode; 503 — the stream is not ready yet

An endless response: the client just plays it as a stream. Position for the UI comes from the stream_get_seek and stream_ping events. Sending Icy-MetaData: 1 turns on ICY metadata (icy-metaint + in-band StreamTitle) for external players like VLC or foobar2000.

GET /api/stream/public.mp3 ​

The same stream without a token — a permanent link for external players.

Errors: 404 — the server runs in sync mode; 503 — the stream is not ready yet

No authorization at all — deliberately a separate path from /api/stream, which stays token-gated for the site’s own web client. Paste this link into VLC, a car radio, or any Shoutcast/Icecast-compatible player once and forget about it. The .mp3 extension in the path is deliberate: some players (VLC, foobar2000, Winamp) pick which module opens a URL based on its extension, and without one they may treat it as a plain file instead of a live ICY stream. The response sends the full icy-* header set (icy-name, icy-genre, icy-br, Server: Icecast) unconditionally, without waiting for Icy-MetaData: 1 - that lets the player recognize it as Icecast-compatible and reconnect on its own to request the embedded metadata (icy-metaint + in-band StreamTitle). The body is a raw, unbounded stream with no Transfer-Encoding: chunked (Connection: close), matching real Icecast/Shoutcast - a player's ICY parser counts bytes directly in the response body, and HTTP chunk framing throws that count off.

POST /webhooks/donations/:provider ​

Payment confirmation from a donation provider.

Response:

js
`200` always (even on rejection) — so providers do not retry needlessly; `404` — unknown/unconfigured provider

Not meant to be called from a client. :provider is one of liqpay, stripe, donatello, kofi. Mounted before express.json(), so the signature is verified against the raw request body: HMAC for LiqPay/Stripe, a verification_token for Ko-fi. Donatello never posts here — it is polled instead of receiving a webhook.