REST API
Every HTTP endpoint. The tables below are generated from the server source, so they match the current branch.
Reading the "Access" column
| Value | What to send |
|---|---|
public | nothing |
art token | X-Art-Token or ?token= |
art + audio token | X-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 only | the .env account; helper admins are refused |
Reading the "Privilege" column
Three notations that mean different things:
| Notation | Meaning |
|---|---|
A + B | both privileges required |
A or B | either 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
| Method | Path | Access | Privilege | Description |
|---|---|---|---|---|
GET | /api/admin/admins | super admin only | — | Helper admins and the list of grantable privileges. |
POST | /api/admin/admins | super admin only | — | Creates a helper admin. |
DELETE | /api/admin/admins/:id | super admin only | — | Deletes a helper admin. |
PUT | /api/admin/admins/:id/privileges | super admin only | — | Changes a helper admin's permissions. |
PUT | /api/admin/admins/:id/reset-password | super admin only | — | Resets a helper admin's password to a new temporary one. |
POST | /api/admin/admins/self/activate | admin (JWT) | — | A helper admin activates their own account. |
PUT | /api/admin/admins/self/login | admin (JWT) | — | Changes your own login name. |
PUT | /api/admin/admins/self/password | admin (JWT) | — | Changes your own password. |
GET | /api/admin/artist-arts | admin (JWT) | ARTIST_ARTS | Artists, flagged by whether artwork exists. |
DELETE | /api/admin/artist-arts/:artist | admin (JWT) | ARTIST_ARTS or EDITOR_META | Deletes an artist's artwork. |
GET | /api/admin/artist-arts/file/:artist | admin (JWT) | ARTIST_ARTS | The artwork file for preview in an admin UI. |
POST | /api/admin/artist-arts/upload | admin (JWT) | ARTIST_ARTS | Uploads an artist image. |
GET | /api/admin/audit | admin (JWT) | — | The admin action log. |
GET | /api/admin/background-music | admin (JWT) | JINGLES_UPLOADER | Background tracks, paginated. |
GET | /api/admin/background-music/:id/audio | admin (JWT) | JINGLES_UPLOADER | Background track audio for previewing. |
POST | /api/admin/background-music/:id/used | admin (JWT) | JINGLES_UPLOADER | Enables or disables a track in the selection pool. |
POST | /api/admin/background-music/batch-delete | admin (JWT) | JINGLES_UPLOADER | Deletes several background tracks. |
POST | /api/admin/background-music/batch-move | admin (JWT) | JINGLES_UPLOADER | Moves background tracks between modes. |
GET | /api/admin/background-music/counts | admin (JWT) | — | How many background tracks are available. |
GET | /api/admin/background-music/file | admin (bearer or query) | — | Serves the background music file itself from local storage. |
POST | /api/admin/background-music/upload | admin (JWT) | JINGLES_UPLOADER | Uploads a background track. |
POST | /api/admin/background-music/upload-check-duplicate | admin (JWT) | JINGLES_UPLOADER | Checks the filename before uploading. |
GET | /api/admin/donations/history | admin (JWT) | DONATIONS_MANAGE | Donation history, filterable by time window. |
GET | /api/admin/donations/price-preview | admin (JWT) | DONATIONS_MANAGE | First-tier price for an arbitrary duration — for the settings modal preview. |
GET | /api/admin/donations/settings | admin (JWT) | — | Current donation settings and the active provider. |
POST | /api/admin/donations/settings | admin (JWT) | DONATIONS_MANAGE | Saves donation settings. |
GET | /api/admin/history | admin (JWT) | STATS | The full playback history. |
GET | /api/admin/jingles | admin (JWT) | JINGLES_UPLOADER | Jingles, paginated and searchable. |
GET | /api/admin/jingles/:id/audio | admin (JWT) | JINGLES_UPLOADER | Jingle audio for previewing in an admin UI. |
POST | /api/admin/jingles/:id/used | admin (JWT) | JINGLES_UPLOADER | Enables or disables a jingle in rotation. |
POST | /api/admin/jingles/batch-delete | admin (JWT) | JINGLES_UPLOADER | Deletes several jingles at once. |
POST | /api/admin/jingles/batch-move | admin (JWT) | JINGLES_UPLOADER | Moves several jingles between day and night mode. |
GET | /api/admin/jingles/counts | admin (JWT) | — | How many jingles exist and how many are usable on air. |
GET | /api/admin/jingles/file | admin (bearer or query) | — | Serves the jingle file itself from local storage. |
POST | /api/admin/jingles/upload | admin (JWT) | JINGLES_UPLOADER | Uploads a jingle. |
POST | /api/admin/jingles/upload-check-duplicate | admin (JWT) | JINGLES_UPLOADER | Checks whether a jingle with that filename already exists. |
POST | /api/admin/login | public | — | Admin login. |
POST | /api/admin/logout | public | — | Logout: clears the token cookie. |
GET | /api/admin/lyrics/audio-preview | admin (JWT) | EDITOR_LYRICS or EDITOR_META | A URL for audio playback while aligning timings in the editor. |
DELETE | /api/admin/lyrics/cache | admin (JWT) | EDITOR_LYRICS or EDITOR_META | Removes lyrics from the cache. |
PUT | /api/admin/lyrics/cache | admin (JWT) | EDITOR_LYRICS or EDITOR_META | Saves edited lyrics. |
GET | /api/admin/lyrics/cache-entry | admin (JWT) | EDITOR_LYRICS or EDITOR_META | Lyrics for a single track. |
GET | /api/admin/lyrics/cache-full | admin (JWT) | EDITOR_LYRICS or EDITOR_META | The complete lyrics cache. |
GET | /api/admin/lyrics/cache-index | admin (JWT) | EDITOR_LYRICS or EDITOR_META | A lightweight index of which tracks already have lyrics. |
GET | /api/admin/lyrics/offsets | admin (JWT) | EDITOR_LYRICS or EDITOR_META | All stored lyrics timing offsets. |
GET | /api/admin/lyrics/songs | admin (JWT) | EDITOR_LYRICS or EDITOR_META | Tracks with their lyrics status — the editor's input data. |
GET | /api/admin/phrases | admin (JWT) | JINGLES_UPLOADER | Phrases, paginated and searchable. |
GET | /api/admin/phrases/:id/audio | admin (JWT) | JINGLES_UPLOADER | Phrase audio for previewing in an admin UI. |
POST | /api/admin/phrases/:id/used | admin (JWT) | JINGLES_UPLOADER | Enables or disables a phrase in rotation. |
POST | /api/admin/phrases/batch-delete | admin (JWT) | JINGLES_UPLOADER | Deletes several phrases at once. |
POST | /api/admin/phrases/batch-move | admin (JWT) | JINGLES_UPLOADER | Moves several phrases between day and night mode. |
GET | /api/admin/phrases/counts | admin (JWT) | — | How many phrases exist and how many are usable on air. |
GET | /api/admin/phrases/file | admin (bearer or query) | — | Serves the phrase file itself from local storage. |
POST | /api/admin/phrases/upload | admin (JWT) | JINGLES_UPLOADER | Uploads a phrase. |
POST | /api/admin/phrases/upload-check-duplicate | admin (JWT) | JINGLES_UPLOADER | Checks whether a phrase with that filename already exists. |
GET | /api/admin/settings | admin (JWT) | — | All radio settings. |
POST | /api/admin/settings | admin (JWT) | — | Saves settings; each section under its own privilege. |
DELETE | /api/admin/song-editor | admin (JWT) | EDITOR_META | Deletes a track from the library and from storage. |
POST | /api/admin/song-editor/batch-delete | admin (JWT) | EDITOR_META | Deletes several selected tracks. |
POST | /api/admin/song-editor/batch-move | admin (JWT) | EDITOR_META | Moves several tracks between modes. |
GET | /api/admin/song-editor/download | admin (JWT) | EDITOR_META | Downloads the track's source file. |
POST | /api/admin/song-editor/move-mode | admin (JWT) | EDITOR_META | Moves a track between day and night mode. |
POST | /api/admin/song-editor/save | admin (JWT) | — | Saves metadata, lyrics and lyrics offset for a track. |
GET | /api/admin/song-groups | admin (JWT) | — | All song groups with track counts and a preview. |
POST | /api/admin/song-groups | admin (JWT) | SETTINGS_GROUPS | Creates a song group. |
DELETE | /api/admin/song-groups/:groupId | admin (JWT) | SETTINGS_GROUPS | Deletes a song group. |
PUT | /api/admin/song-groups/:groupId | admin (JWT) | SETTINGS_GROUPS | Updates a group: its name or its tracks. |
POST | /api/admin/song-groups/:groupId/insert | admin (JWT) | QUEUE_MANAGE + SETTINGS_GROUPS | Queues an entire group. |
GET | /api/admin/song-groups/library | admin (JWT) | — | Searches the library to populate a group. |
GET | /api/admin/songs | admin (JWT) | — | The whole library for a mode, with lyrics status. |
GET | /api/admin/stats | admin (JWT) | STATS | Aggregate statistics for the library and the broadcast. |
POST | /api/admin/switch-mode | admin (JWT) | MODE_SWITCH | Switches the radio between day and night mode. |
POST | /api/admin/upload-batch-delete | admin (JWT) | UPLOAD_SONGS | Deletes just-uploaded tracks. |
POST | /api/admin/upload-batch-move | admin (JWT) | UPLOAD_SONGS | Moves just-uploaded tracks between modes. |
POST | /api/admin/upload-check-duplicate | admin (JWT) | UPLOAD_SONGS | Checks whether a track already exists, before uploading the file. |
POST | /api/admin/upload-song-commit | admin (JWT) | UPLOAD_SONGS | Upload step 3: puts the track into the library. |
POST | /api/admin/upload-song-file | admin (JWT) | UPLOAD_SONGS | Upload step 1: stores the MP3 and reads its tags. |
POST | /api/admin/upload-song-lyrics | admin (JWT) | UPLOAD_SONGS or EDITOR_LYRICS (per section) | Looks lyrics up through LRCLIB for uploads or song editor updates. |
POST | /api/admin/upload-song-url | admin (JWT) | UPLOAD_SONGS | Downloads audio from YouTube into storage. |
GET | /api/admin/verify | admin (JWT) | — | Validates the session and returns current privileges. |
POST | /api/admin/youtube-cookies | admin (JWT) | UPLOAD_SONGS | Stores YouTube cookies for age-restricted videos. |
POST | /api/admin/youtube-track-info | admin (JWT) | UPLOAD_SONGS | Reads the track list behind a video or playlist URL. |
GET | /api/admin/ytbdown-status | admin (JWT) | UPLOAD_SONGS | Whether the YouTube download tool is ready. |
Client
| Method | Path | Access | Privilege | Description |
|---|---|---|---|---|
GET | /api/art/url | art token | — | A URL for artist artwork instead of the bytes. |
GET | /api/artist-art/:artist | art token | — | Artist artwork. |
GET | /api/audio-key | art token | — | Exchanges an art token for a short-lived audio token. |
GET | /api/audio/stream | art + audio token | — | Audio bytes for the current or a requested track. |
GET | /api/audio/stream/admin | admin (bearer or query) | — | Track audio for admin interfaces. |
GET | /api/audio/url | art + audio token | — | A URL for the audio, without serving the bytes. |
GET | /api/avatar/:filename | art token | — | A listener avatar. |
GET | /api/history | public | — | The last 10 played songs. |
GET | /api/library | public | — | Tracks available to request in the current mode. |
GET | /api/lyrics | art token | — | Time-synced lyrics for a song. |
POST | /api/lyrics/offset | admin (JWT) | — | Stores the lyrics timing offset for a track. |
GET | /api/public/config | public | — | How the server is configured. The only endpoint with no authentication at all. |
GET | /api/public/donations/:id/status | public | — | Current status of a specific donation. |
POST | /api/public/donations/create | public | — | Places a donation order for a song at the chosen tier. |
GET | /api/public/donations/tiers | public | — | Computed donation-tier prices for a specific song. |
POST | /webhooks/donations/:provider | public | — | Payment confirmation from a donation provider. |
Stream
| Method | Path | Access | Privilege | Description |
|---|---|---|---|---|
GET | /api/stream | art token | — | The shared MP3 radio stream (stream mode only). |
GET | /api/stream/public.mp3 | public | — | 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:
{
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:
{ login: '…', password: '…', privileges: ['queue_manage'] }Response:
{ 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:
{ 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:
{ 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:
{ 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:
{ 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:
{ 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:
raw JPEG bytes, up to 10 MBJPEG 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:
{ 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:
{ ids: ['…'] }POST /api/admin/background-music/batch-move
Moves background tracks between modes.
Request body:
{ 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:
raw MP3 bytes, up to 80 MBOn success a background_music_updated event follows.
POST /api/admin/background-music/upload-check-duplicate
Checks the filename before uploading.
Request body:
{ 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:
{ 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:
{ 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:
{
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:
{ currency, pricingMode, fixedPrice, pricePerSecond, tiersEnabled, tierCeiling, blockDonationsWhileChatting }Response:
{ 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:
{ 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:
{ ids: ['…', '…'] }POST /api/admin/jingles/batch-move
Moves several jingles between day and night mode.
Request body:
{ 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:
{
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:
raw MP3 bytes, not multipart. Limit: 80 MBResponse:
{ 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:
{ filename: 'jingle.mp3' }Jingle filenames are unique globally, not per mode.
POST /api/admin/login
Admin login.
Request body:
{ login: '…', password: '…' }Response:
{ ok: true } // in production
{ ok: true, token: '…' } // in developmentErrors: 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:
{ ok: true }GET /api/admin/lyrics/audio-preview
A URL for audio playback while aligning timings in the editor.
Query: title and artist
Response:
{ 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:
{ 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:
{ title: '…', artist: '…', entry: { synced: true, lines: [{ time, text }] } }Response:
{ 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:
{ 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:
{ 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:
{ ids: ['…', '…'] }POST /api/admin/phrases/batch-move
Moves several phrases between day and night mode.
Request body:
{ 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:
{
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:
raw MP3 bytes, not multipart. Limit: 10 MBResponse:
{ 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:
{ 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:
{
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:
{
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:
{ 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:
{ 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:
the same fields as on creationRequires settings_groups.
POST /api/admin/song-groups/:groupId/insert
Queues an entire group.
Response:
{ 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:
[{
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:
an object summarising modes, groups and durationsRequires 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:
{
targetMode: 'day' | 'night',
scheduledTime: '23:30', // optional, HH:MM in the server's time zone
}Response:
{ 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:
{ trackId: 'day/artist - title.mp3' }Response:
{ 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:
{
metadata: { … }, // from step 1, possibly edited by the user
lyricsEntry: { … }, // from step 2, optional
}Response:
{ 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:
raw MP3 bytes, not multipart. Limit: 80 MBResponse:
{
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:
{ title, artist, album?, duration? }Response:
{
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:
{ 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:
{
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:
{ 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:
{ 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:
{ 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:
{ 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:
XOR-wrapped image bytes; the real MIME type is in the `X-Art-Mime` headerErrors: 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:
{ 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:
{ url: 'https://…', ttl: 900 } // ttl in cloud mode onlyUseful 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:
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:
[{ 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:
[{ 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:
// 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:
{ 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:
{
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:
{
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:
{ songId: 'day/artist - title.mp3', tier?: 2 }Response:
// 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:
{
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:
`200` always (even on rejection) — so providers do not retry needlessly; `404` — unknown/unconfigured providerNot 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.