Skip to content

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.