API reference¶
Grouped by area. The second column is the function in frontend/js/api.js that calls it, so you can search the UI for its callers. admin marks routes that need an admin, ai marks routes that need the AI permission, and request marks routes that need the request permission (admins have both). Everything else needs a signed-in user when profiles are on. /docs has the full parameter lists.
Library: browsing¶
| Call | api.js | Notes |
|---|---|---|
GET /v1/library/libraries |
libraries() |
Every library with its counts. Loaded at startup for the sidebar. |
GET /v1/library/libraries/{id} |
library(id) |
|
GET /v1/library/libraries/{id}/tracks |
tracks(id, q) |
Paged. search, sort (default, title, album, year, added, plays, last_played, relevance), order (asc: the sort's natural direction, desc: reversed), limit, offset, plays=mine|household. |
GET /v1/library/tracks |
allTracks(q) |
Across libraries. Filter by artist_id, album_id or genre_id. Artist Popular uses plays=household. |
GET /v1/library/libraries/{id}/artists · /albums · /genres |
artists(), albums(), genres() |
Paged, sortable lists (genres aren't paged). |
GET /v1/library/search?q=&library_id= |
search(libraryId, q) |
Header search: the best few artists, albums, tracks, playlists and genres, each {items, total}; every library without library_id. elsewhere: [{ library_id, name, total }] lists the other libraries with matching music, worked out only when this library has none (playlists span libraries, so they don't count). The old /libraries/{id}/search?q= answers the same, for the classic UI and older apps. |
GET /v1/library/artists · /albums · /genres |
allArtists(), allAlbums(), allGenres() |
Every library's (genres count every library's songs). Search's All libraries uses them for one kind's full list. |
GET /v1/library/albums/{id} |
album(id) |
Album with its tracks, description and release info. |
GET /v1/library/artists/{id} · /{id}/albums |
artist(), artistAlbums() |
|
GET /v1/library/tracks/{id} |
track(id) |
Used by the track cache for ids it hasn't seen. |
GET /v1/library/tracks/{id}/stream |
streamUrl(id) |
The raw file. Supports Range (206) and ETag (304). Used as <audio src>. |
GET /v1/library/tracks/{id}/lyrics |
lyrics(id) |
{ source, synced: [{ time, text }], plain, instrumental }, from a .lrc beside the song, its tags, or LRCLIB (stored after the first ask, which can take a few seconds). source null: none. 503 if LRCLIB was needed and unreachable. POST …/lyrics/refresh (refreshLyrics) asks again; admins only. |
POST /v1/library/tracks/{id}/played |
recordPlay(id, playedAt?) |
This device played a track to its end. Counted for the asker and the household. 204. It takes an optional { played_at } (with a time zone) for a play the app reports late. A later play already recorded stays the last one, and a future time counts as now. |
GET /v1/library/artwork/{sha256}/{full|thumb} |
(URLs in responses) | Covers and photos. Cached forever, since the hash changes with the image. |
Library: managing mostly admin¶
| Call | api.js | Notes |
|---|---|---|
GET /v1/library/folders?path= admin |
folders(path) |
Browse the server's folders to pick a new library. |
POST /v1/library/libraries admin |
addLibrary() |
202. A scan starts at once. |
POST / GET /v1/library/libraries/{id}/scan |
scanLibrary(), scanStatus() |
Starting a scan needs an admin. Polling its progress doesn't. |
/v1/library/artists/{id}/image, …/image/choices, …/image/choice, …/refresh admin |
artistImageChoices, chooseArtistImage, uploadArtistImage (PUT), resetArtistImage (DELETE), refreshArtist |
Artist photos and details by hand (info_routes.py). |
/v1/library/albums/{id}/artwork, …/artwork/choices, …/artwork/choice, …/refresh admin |
albumCoverChoices, chooseAlbumCover, uploadAlbumCover, resetAlbumCover, refreshAlbum |
Album covers and details. |
Server player¶
Every command returns the new PlayerStateOut. A 409 means the command clashed with something: AirPlay is using the speakers, the queue changed on another device, or you tried to remove the playing track. A 503 means mpv isn't available.
| Call | api.js | Body |
|---|---|---|
GET /v1/player |
playerState() |
Asked on each player event while the server output is active, or every second without live updates. |
POST /v1/player/play |
playerCommand(action, body) |
{ track_ids, start }, up to 10,000 songs. Replaces the queue. The asker becomes the listener. |
POST /v1/player/queue |
{ track_ids, next } |
|
POST /v1/player/ pause · resume · toggle · stop · next · previous |
playerCommand(action) |
none |
POST /v1/player/seek · /volume |
playerCommand(action, body) |
{ position } · { volume } (0 to 100) |
POST /v1/player/queue/move |
playerMove() |
{ index, to, track_id } |
DELETE /v1/player/queue/{index}?track_id= |
playerRemove() |
The track_id guards against a queue that changed meanwhile. |
POST /v1/player/shuffle · /repeat |
playerCommand(action, body) |
{ shuffle: bool } · { repeat: "off"|"all"|"one" } |
Playlists¶
| Call | api.js | Notes |
|---|---|---|
GET / POST /v1/playlists |
playlists(), createPlaylist(name, ids) |
Only those you may see; whose=all|mine|others. A new one is yours. |
POST /v1/playlists/{id}/copy |
copyPlaylist(id, name?) |
A copy that's yours, " (copy)" added if you have that name. |
GET / PATCH / DELETE /v1/playlists/{id} |
playlist(), updatePlaylist(), deletePlaylist() |
PATCH takes { name, description } (if you may edit), private (its owner) and owner_id (its owner or an admin; null: the household). |
GET / POST /v1/playlists/{id}/entries |
playlistEntries(), addToPlaylist() |
Paged (100 per page). An entry's track is null for a song not in the library. |
DELETE …/entries/{entryId}, POST …/entries/{entryId}/move |
removeEntry(), moveEntry() |
Move takes { position }. |
POST /v1/playlists/import/preview · /import |
importPreview(), importPlaylists() |
M3U, CSV and similar. The preview matches songs and saves nothing. |
Smart playlists, mixes and AI¶
| Call | api.js | Notes |
|---|---|---|
GET /v1/smart-playlists?library_id=&key= |
smartPlaylists() |
Which lists are on offer. Keys: most-played, most-played-lately, recently-added, liked (Liked Songs: uncapped, and every library unless library_id narrows it), genre-mix, decade-mix, custom-mix. A missing library_id means all of your libraries. |
GET /v1/smart-playlists/{key}/tracks |
smartTracks(key, {libraryId, param, seed}) |
The songs, worked out on every request and never stored. param is a genre id, decade or mix id. Mixes keep the same songs all day unless seed changes (New mix). |
GET /v1/likes, PUT · DELETE /v1/likes/{track_id} |
likes(), like(id), unlike(id) |
Your liked songs as { track_ids }, newest first; like or unlike one (204; again changes nothing; PUT 404s an unknown song). Each person's own, in track_likes; with profiles off the household's (user_id null), which python -m app.cli give-likes hands to a profile. |
POST /v1/likes |
likeMany(ids) |
Likes many, the first the newest (an import's "Into Liked Songs"): { added }. At most 10,000 a request; songs not in the library are skipped. |
GET /v1/smart-playlists/ai |
aiStatus() |
Whether AI is on, plus the models and the default. |
POST /v1/smart-playlists/ai ai |
makeAiPlaylist() |
10 to 60 s. Returns a preview (name, plan, tracks, cost) and saves nothing. model is optional: without it, Settings → Server → Model (ai_model). The app sends none since PR #50; older clients and saved ?model= links still may. |
POST /v1/custom-mixes/preview |
previewCustomMix(body) |
Takes { library_id, rules, mix_id?, seed? } and saves nothing. Returns { fits, tracks, rules_in_words, loosen }. When nothing fits, loosen lists each rule group that would let songs in if dropped on its own, with how many. The app says that rather than guessing which rule you didn't mean. With mix_id, the pick is that mix's pick for today, favourite songs included. |
/v1/custom-mixes, /{id}, /{id}/copy |
customMix, createCustomMix, updateCustomMix, deleteCustomMix, copyCustomMix |
Saved rules from an AI playlist. Owned as playlists are: PATCH also takes private and owner_id. |
Profiles, settings, integrations, system¶
| Call | api.js | Notes |
|---|---|---|
GET /v1/me |
me() |
Always answered: { mode, status, profile, is_admin, can_use_ai, can_request, can_switch, message }. identity.js reads them through isAdmin(), canUseAi() and canRequest(); a server without can_request means only admins. |
GET / POST /v1/profiles, PATCH / DELETE /{id} |
profiles, createProfile, updateProfile, deleteProfile |
Admins manage everyone. Anyone may change their own name, PIN and libraries. The list says how many playlists each owns; The list says how many mixes too. DELETE takes theirs=household|give|delete and give_to. |
POST /v1/profiles/pick · /sign-out |
pickProfile(id, pin), signOut() |
Home devices only. Sets or clears the cookie. |
POST /v1/profiles/{id}/sign-out-devices |
signOutDevices(id) |
Signs out every home device using that profile. Yours, or anyone's if you're an admin (otherwise 403). Your own device gets a fresh cookie. 204. |
GET / PATCH /v1/settings |
serverSettings(), changeServerSettings() |
PATCH needs an admin and ALLOW_REMOTE_SETTINGS. Keys come back masked. |
POST /v1/settings/actions/{id} admin |
runSettingsAction() |
Buttons such as Test connection. |
GET /v1/stats/recaps, /v1/stats/recap?year= |
recapYears(), recap() |
Yearly recaps (app/services/recaps.py): the years with 30 or more plays on the asker's clock, and one year's facts (its stats, last year's totals, busiest day and month, top artist's song, the artist first heard that year, top 50 song ids). Same profile rule as stats; too few plays is a 404 with the reason. Window(…, year=2025) covers one calendar year. |
GET /v1/stats |
stats({ range, scope, tz }) |
Listening stats from the play history: totals, top 10 artists, songs, albums and genres, plays per day or month, by hour and weekday (in tz), streaks, decades. range: 30d, year or all; scope: mine or household. Admins can pass profile (an id) instead of scope for one profile's: the answer's scope is then profile, with profile: { id, name }. Anyone else gets a 403. Settings → Stats shows admins a Whose plays menu (?profile=). |
GET /v1/integrations |
integrations() |
Whether Lidarr is on. |
/v1/integrations/lidarr/… request |
lidarrSearch, lidarrArtistAlbums, lidarrAddAlbum, lidarrSearchAlbum |
Request music: search, an artist's albums, request an album, search again. Admins, and profiles with can_request. An album the artist's metadata profile leaves out is refused with 409 (NotKeptError, Lidarr._check_kept): Lidarr would accept it, then drop it at its next refresh. |
POST /v1/integrations/lidarr/artists admin |
lidarrAddArtist |
Adds a whole artist, which can mean every album they made, so admins only. POST …/lidarr/webhook is public and checks a token instead. |
GET /health, /version, /system-info |
version() |
Open to anyone, except system-info. /version's android_app is where to download the Android app, or null when the image has none. |
GET /v1/apps/web-files |
(the iOS app) | { digest, files: [{ path, size, sha256 }] }: the web app's files (index.html, the manifest, css/, js/, img/, vendor/; not v2/ or sw.js), for the iOS app to keep a copy of and open offline. digest changes with any file. |
GET /v1/events |
live.js |
Server-Sent Events: ready, library {library_id}, scan (a scan's status), player, notice ({id, text, variant, kind, created_at}), likes, resync. Filtered for whoever's asking (see Live updates). |
GET /v1/notifications, POST /seen · /clear |
notifications(), notificationsSeen(upTo), clearNotifications(upTo) |
The bell: { items: [{ id, text, variant, kind, created_at }], seen, unread }, the latest 50 from 30 days sent to you and not cleared (seen and unread null with profiles off). The POSTs take { up_to } and return 204; with profiles off they do nothing (see The bell). |
GET /v1/apps/car/browse?id=, /tracks?id=, /search?q= |
(Android Auto, CarPlay) | What a car shows: [{ id, title, subtitle, artwork, playable, browsable }] (root: the branches); { start, tracks: [{ id, title, artist, album, artwork, duration }] } to play; search, best match first. The asker's playlists, mixes, libraries and plays (see Android Auto and CarPlay). |
GET /v1/apps/android |
(the link in About) | The Android app built with this release, an APK named Nessie-<version>.apk, no-cache. 404 when the image has none. |