GainDrive speaks the Subsonic API, and this page documents only what it adds to it: the endpoints Subsonic does not define, and the extra parameters and response fields that standard endpoints carry. The standard endpoints themselves behave as specified and are not repeated here.
Everything is served at /rest/<name>.view unless stated otherwise, answers in XML or in JSON (f=json), and requires authentication - either u and p or the token scheme u, t and s. A parameter marked with a * is required.
Clients discover this whole surface through getOpenSubsonicExtensions, which advertises an extension named gaindrive.
getOpenSubsonicExtensions advertises an extension named gaindrive at version 1. No authentication is needed for that call, so a client can negotiate before login. Everything on this page is version 1.
Beside it the standard transcodeOffset is advertised, also at version 1. It names behaviour rather than adding any - stream honours timeOffset for audio, and always has - so nothing on this page depends on it.
A new endpoint will earn a new version, because a client cannot discover one without being told. A new optional parameter on an endpoint an existing version already names will not - a client that ignores it gets exactly what it got before, so nothing it was told has been redefined.
A client that does not know the extension will not call any of these, so there is no conformance risk in advertising it. Nothing in the standard endpoints changes when the extension is present.
Carried by getOpenSubsonicExtensions. Only the gaindrive additions are listed here.
Advertises the gaindrive extension, and the standard transcodeOffset and playbackReport beside it, with the versions this server implements.
| Field | Type | Description |
|---|---|---|
openSubsonicExtensions.name | string | gaindrive for this page. transcodeOffset and playbackReport are advertised beside it and are the standard extensions of those names. |
openSubsonicExtensions.versions | int[] | In XML, a comma-separated versions attribute. |
Recently played tracks for the authenticated user, newest first.
| Parameter | Type | Description |
|---|---|---|
size | int | How many to return. Clamped to 1..500. default 50. |
offset | int | How many to skip. default 0. |
| Field | Type | Description |
|---|---|---|
recentSongs.song | Child[] | Ordinary song entries, newest first. |
lastPlayed on song | string | When the play this entry reports happened. |
Carried by getMusicFolders. Only the gaindrive additions are listed here.
Each root says which of the two library layouts it holds.
| Field | Type | Description |
|---|---|---|
contentType on musicFolder | string | artists or categories. |
contentType
Artist and category roots cannot be told apart by shape - both are L1/L2/[L3]/files - so which one a root is has to be configured, and a client that wants to draw them differently has to be told.
The uploads root, if configured, is never listed: it holds per-user personal files rather than shared library content.
Carried by getIndexes, getArtists. Only the gaindrive additions are listed here.
Narrow the listing to one kind of root, or replace it with the uploads area.
| Parameter | Type | Description |
|---|---|---|
contentType | string | Restrict to roots of one kind. one of artists, categories. |
personal | string | Replace the shared library with the uploads area. one of true, *. |
contentType
It complements musicFolderId rather than replacing it: a kind may span several roots - two artist roots must list together - which a single folder id cannot express.
Omitting it returns every root's children mixed, which is what a client with no concept of categories should get. The sections browse perfectly well as pseudo-artists.
personal
personal=true - the calling account's own uploads.personal=* - every account's, and admin only; anything else gets error 50. An admin is the only account that can moveAlbum an upload into the shared library, so without this a non-admin's upload is visible to its owner and to nobody able to act on it.personal=* groups the response by owner rather than by first letter, so the index labels come back as usernames instead of A, B, #. That is what makes two accounts' identically named folders tellable apart - grouped by letter they would interleave under one heading with nothing saying whose is whose - and it needs nothing new from a client, since an index label was always an arbitrary string.
A client drawing an alphabet scrubber from those labels will want to suppress it when they are not letters.
Root folders themselves never appear as artists. Both endpoints return the level-1 entries of every root flattened into one list - musicians from artist roots and sections such as Film or Series from category roots.
Nothing below the top level is affected: getArtist, getAlbum and getMusicDirectory take plain ids and neither know nor need to know which side of the library one came from.
Carried by getAlbumList, getAlbumList2, search2, search3. Only the gaindrive additions are listed here.
Search and list within the uploads area instead of the shared library.
| Parameter | Type | Description |
|---|---|---|
personal | bool | The calling account's own uploads. Unlike on getIndexes, personal=* is not accepted here. one of true. |
Carried by getCoverArt. Only the gaindrive additions are listed here.
Reach the extra images in an album folder, and let a receiver fetch the sleeve.
| Parameter | Type | Description |
|---|---|---|
index | int | 0 is the main cover; 1 and up select the extras getAlbumImages counted. default 0. |
castToken | string | A token from getCastToken, accepted in place of credentials. |
castToken
Only getCastToken's, not castLoad's: the server's own cast sends a receiver no artwork at all, so the session token has no reason to reach here and does not.
Scoped to the one cover the grant's song carries, resolved when the grant was minted rather than named by the caller. Presenting it for any other id is refused as though it were absent, and a grant for a track with no artwork authorises nothing here.
A receiver fetches the sleeve named in the LOAD's metadata and has no account to fetch it with. Without this the picture would be the one thing still carrying u/t/s to the television, which would have defeated the point of the token.
Carried by getArtistInfo, getArtistInfo2. Only the gaindrive additions are listed here.
Re-ask the metadata providers for an artist, and say when an answer is still being worked out.
| Parameter | Type | Description |
|---|---|---|
force | bool | Queue a fresh provider lookup rather than letting the cached artist_info_cache row stand. |
force
The chain is MusicBrainz, then Wikidata, Wikipedia, TheAudioDB and Discogs, paced against MusicBrainz's rate limit. It does not run while you wait. The request queues the artist at the front of the background resolver and returns whatever is cached right now, with resolving set; poll until that field is gone.
| Field | Type | Description |
|---|---|---|
resolving on artistInfo / artistInfo2 | bool | Present and true while the providers have yet to be asked about this artist. Absent means the answer above is final, empty or not. |
resolving
gaindrive never queries a metadata provider from a request thread. The lookup runs on the same background resolver that finds artist portraits - one artist at a time, paced against MusicBrainz's rate limit - and asking for an artist it has not reached yet is what pushes them to the front of its queue. So a first request for an unknown artist returns at once with an empty biography and this field set, and a later one returns the biography.
The field is emitted only when it is true, so its absence means the same thing to a client that has never heard of it as to one that has: this is everything there is. A client that ignores it sees an empty biography on the first view and a real one on the next.
An artist the providers had nothing to say about settles with no biography and no resolving. That is a final answer and not a reason to keep polling.
| Code | Kind | When |
|---|---|---|
50 | Subsonic | A non-admin sent force. The lookup is not queued and nothing cached is returned; drop the parameter to read. |
Carried by getAlbumInfo, getAlbumInfo2. Only the gaindrive additions are listed here.
The same two additions, for an album.
| Parameter | Type | Description |
|---|---|---|
force | bool | Queue a fresh provider lookup rather than letting the cached album_info_cache row stand. Admin only. |
force
This is the only way to correct a cached empty answer, and before it existed there was none: the row was written whatever MusicBrainz had said, including nothing at all, and no read ever consulted its age. An album viewed once while MusicBrainz was shedding load therefore had no description for good.
That half is fixed too - a row is written only when MusicBrainz actually answered, so a failed lookup is retried on the next view - but a successful lookup that found nothing is final, and force is how to ask again.
Admin only, on the same terms as getArtistInfo's: the endpoint stays open to any user, the re-ask does not.
| Field | Type | Description |
|---|---|---|
resolving on albumInfo / albumInfo2 | bool | Present and true while the providers have yet to be asked about this album. Absent means the answer above is final, empty or not. |
resolving
Emitted only when true, exactly as on artistInfo2, so a client that ignores it sees empty notes on the first view and real ones on the next.
Albums and artists share one background resolver and one queue, because MusicBrainz's rate limit is per address: a second worker would add no throughput and would spend the courtesy headroom the first one leaves for requests somebody is actually waiting on.
| Code | Kind | When |
|---|---|---|
50 | Subsonic | A non-admin sent force. The lookup is not queued and nothing cached is returned; drop the parameter to read. |
These behave exactly as the getArtistInfo pair above, and for the same reason: the endpoint used to run the MusicBrainz release-group search, the url-rels lookup, Wikidata and Wikipedia on the request thread, so one album nobody had looked up could hold an HTTP worker for minutes. It reads album_info_cache and queues the lookup instead.
The chain is shorter than the artist one - MusicBrainz, then Wikidata and Wikipedia - and there is no image, so an album carries no coverArt answer from this endpoint either way.
Carried by getAlbumList, getAlbumList2, getArtist, getAlbum. Only the gaindrive additions are listed here.
Say how much of an album is video, before its tracks are fetched.
| Field | Type | Description |
|---|---|---|
videoCount on Album | int | How many of the album's songs are video. 0 for an ordinary record. |
videoCount
isVideo is a property of a song, so nothing on an album entry otherwise says that opening it will start a film - a client would have to fetch every album's tracks to find out. A count rather than a flag because the two cases differ: a folder holding one bonus documentary beside its songs is not a folder of films, and videoCount equal to songCount is what a season of a series or a film looks like.
The album entries returned by getMusicDirectory, getStarred, getStarred2, search2 and search3 do not carry it, so its absence there is not a claim that an album has no video.
Carried by any endpoint returning song entries. Only the gaindrive additions are listed here.
One extra field on every song entry.
| Field | Type | Description |
|---|---|---|
transcodedBitRate on Child | int | The bitrate the stream would carry, in kbps, when this entry would be transcoded. |
transcodedBitRate
Subsonic defines transcodedContentType and transcodedSuffix but no bitrate, so a client could see that a track would be converted without being able to say to what. It is computed the same way stream decides, including the account's own ceiling, so the two cannot disagree.
Carried by reportPlayback, getNowPlaying. Only the gaindrive additions are listed here.
The OpenSubsonic playbackReport extension, and what GainDrive decides where the standard leaves it open.
Advertised as playbackReport version 1. mediaType=podcast is answered with error 0, Not implemented.
Each account has one now-playing entry, whichever of its clients reported last. stopped removes it only while it still names the reported song and the same client name c, so a client that stops after another device of the same account took over leaves that device's entry alone.
An entry not reported for five minutes is dropped from getNowPlaying, in every state. A client that keeps playing must therefore report at least that often; a paused one need not, and simply disappears.
Unless ignoreScrobble=true, a play is counted once per starting, when a report puts the position at or past half the track or four minutes, whichever comes first. A report naming a different song from the entry it replaces starts a new play, starting or not. A client that scrobbles with submission=true itself should send ignoreScrobble=true, or the play is counted twice.
scrobble with submission=false still sets the entry, without a timeline: its getNowPlaying entry carries no state, positionMs or playbackRate. positionMs is the position as last reported, not extrapolated.
The playhead of one paced stream, so the server throttles it against the real position.
| Parameter | Type | Description |
|---|---|---|
token | string | The posToken the stream URL carried. At most 64 characters. |
pos | float | The playhead in seconds, relative to the served stream (a timeOffset stream starts at the seek point). |
playing | bool | Whether the playhead is advancing. While true the server extrapolates between reports; while false it holds the reported position. default false. |
playing
Send a report every few seconds while a posToken stream is open, paused included, because a paused report is what holds the stream at its lead instead of letting the wall clock drift it ahead. A report older than 30 seconds stops steering the stream, which then degrades to unpaced delivery; there is no need to unregister a finished stream.
Carried by stream. Only the gaindrive additions are listed here.
Eight gaindrive parameters on the standard streaming endpoint.
| Parameter | Type | Description |
|---|---|---|
pace | bool | Deliver at roughly 1x playback rate rather than as fast as the socket takes it. |
posToken | string | Audio only. Names this stream so reportPosition calls can steer its pacing. An opaque client-chosen string, at most 64 characters. |
castToken | string | A token from castLoad or getCastToken, accepted in place of credentials. |
castController | string | The id given to startCast. Only the owner of a live cast session has its playback redirected to the receiver. |
castRedirect | bool | Send false to be served the stream even though you own the live cast session. default true. |
size | string | Video only. A cap on the output frame size, as WxH - 640x480. |
duration | int | Video only. Bound the output to this many seconds from timeOffset, and switch the container to MPEG-TS. That is one HLS segment. |
playable | string | A comma list of what this client can be sent untouched, so the server skips work it would otherwise pay - mpeg/mp3,mp4/aac for audio, mkv,mov,avi for video. |
startImmediately | bool | Video only. Be sent a stream that starts at once rather than one that can be seeked, when the two conflict. |
pace
Send it when the URL is being handed to something that will play it in real time and cannot be recognised from the request - a Cast receiver fetching for itself is the case it exists for. Without it such a receiver is sent the whole track at once, stops reading when its buffer fills, and the connection is then idle until its own 60-second no-data timeout, or a reverse proxy's, kills it mid-track.
Never send it on a download or a prefetch: that would make the transfer take as long as the music. A browser is paced already, by its User-Agent, and video is never paced.
posToken
With it, the paced delivery is throttled against the playhead the client reports through reportPosition, instead of against a wall-clock estimate: the same mechanism a cast stream gets from the receiver's status. The wall-clock estimate assumes 1x playback from the moment the request started, which drifts on a paused player and starves on a file whose front is not audio; the reported position does neither.
Use a fresh random token per stream URL. Positions are read in the served stream's own time frame, so a timeOffset re-fetch is a new stream and needs a new token. Scoped to the authenticated account: another account sending the same token names a different pacing session. If the reports stop, the stream degrades to unpaced delivery rather than stalling.
castToken
A receiver fetches the media for itself and has no account, so the token is what authorises it. It is not single-use per request - the receiver makes many range requests for one track - but it is scoped to one song and to a limited lifetime. Presenting it for a different id is refused as though it were absent.
Two different tokens travel on this parameter, and a URL looks the same whichever issued it. They are not interchangeable behind it:
castLoad's belongs to the server's own cast session. There is genuinely no account behind a request carrying it, so it skips the account bitrate ceiling, and the request is also subject to the session's own offset - the receiver probes with timeOffset stripped, and the server answers from what the LOAD declared rather than from the URL. On getCaptions it authorises the caption ids that LOAD declared, and nothing else.getCastToken's belongs to an account, for a cast some other sender is driving. So the ceiling is applied - there is somebody to apply it to - and nothing about a session applies: such a request is an ordinary one that happens to carry a token where a password would be, timeOffset and all. It also reaches getCoverArt, which the session token does not, because the server's own cast sends a receiver no artwork and a client's does.castController
While a cast session is live, a request from the client that owns it is answered 204 and the track is sent to the Chromecast instead, so that client does not play it a second time itself. Every other caller - a client that sent no castController, one whose id does not match, or a different account - is served the stream normally.
Without this the redirect was global: any request in the server was answered 204 while anyone was casting, so starting a track on a phone played it on whatever receiver a browser had picked earlier.
castRedirect
The redirect above assumes that an owner asking for a stream is about to play the track a second time. One request is not that: when the receiver cannot display video it is sent the film's soundtrack, and the client can keep the picture, muted and in step with it. That is one playback in two halves, and the half that stays here has to be served rather than answered 204.
It matters more than a lost picture. The 204 path issues a fresh castLoad as a side effect, so the request would also restart what the receiver is playing.
size
Any value here disqualifies the direct and remux tiers, so the video is re-encoded on the fly and the response is chunked with no Content-Length.
Strictly WxH, both between 16 and 7680/4320. Anything else is ignored rather than rejected, and means "do not scale" - the same as omitting it.
playable
Most players handle more than a browser does, and a server that cannot be told so re-encodes for no reason. An MP3 played through a client that decodes MP3 is the plain case: asking for format=opus converts every track, including the ones that needed nothing. Matroska is the video case - an H.264/AAC .mkv is remuxed to MP4 only because no browser reads Matroska, and that remux is a whole-file -c copy the client waits out before it hears anything.
A token is a bare container for video, and a container/codec pair for audio. The split is by medium, not by anything about the file:
vob. Video is a container-only declaration by design: the server keeps its own codec test, so naming mkv says which containers may be served untouched and nothing more.mpeg/mp3, mp4/aac, ogg/vorbis, flac/flac and so on. Both halves are compared against what the scan observed and recorded, never against a filename.That is why audio has no bare form. A .ogg and a .oga are one container under two extensions, and a .m4a is an MP4 - so a token naming an extension would reach some of a container's files and not others. Naming the container itself, ogg or mp4, reaches all of them, and mp3 alongside mpeg/mp3 would be two spellings of one thing.
Containers are the real ones: mpeg, flac, ogg, mp4, adts, riff, asf. Codec names are lowercase and spelled as ffprobe spells them: mp3, aac, alac, vorbis, opus, flac, speex, pcm.
Neither half is checked against a list. Both are compared for equality with what was stored, so a spelling this server does not use simply never matches and the file is converted exactly as it was before.
Declaring something means "send me this", not "I can decode this." They are different claims, and only the second is about your decoder. A client set to Opus 160 that also declares flac will be sent FLAC, which is the opposite of what it asked for. There is deliberately no server-side guard against that: a guard would be the server overruling the one party that knows what it wants.
What it does is narrow, and deliberately so.
maxBitRate and timeOffset each force a transcode, and size and duration do on video. A declared 320 kbps MP3 under a 160 ceiling is still converted.vob is refused. A DVD titleset is one continuous stream split across numbered VOBs and the stored path names only the first, so serving it untouched would hand back twenty minutes of a two-hour film with nothing reporting an error.riff or asf will never match. Those forms are recorded, but a client that wants a lossless or legacy file untouched asks for the original instead - no format at all, which is served straight off disk.transcodedContentType, transcodedSuffix, transcodedBitRate and nativeSeek are not affected by it, deliberately. They describe what any client would be sent, so they stay cacheable and stay right for the one consumer that is not you - a Cast receiver being told what it is about to fetch.
Ignored when a castToken is presented, and ignored on hls.m3u8, where every segment is bounded by duration and is therefore an encode whatever the container holds.
Never put this on a URL you are handing to something else - a Cast receiver above all. The LOAD you sent it declared a contentType taken from transcodedContentType, and a receiver told video/mp4 while being sent Matroska refuses the media outright, which looks like a broken file rather than a mislabelled one. The audio half is no safer: the same LOAD named the type the transcode was going to produce. Apply it where the request is made, not where the URL is built.
startImmediately
Only the remux tier reads it, and only when its cached file is not there yet. That tier copies the whole film into a seekable MP4 before it sends a byte, which for a feature film is tens of seconds and for a disc rip minutes. With this, the server answers from a fragmented pipe straight away and builds the seekable file beside it, for the next request.
The one response that produces is not seekable: chunked, Accept-Ranges: none, no Content-Length. nativeSeek still describes the file rather than this one request, deliberately - see below - so a client sending this must be ready for a stream it cannot seek, and must find out by testing rather than by asking. In a browser that is HTMLMediaElement.seekable being empty; a refused seek raises nothing.
X-Gaindrive-Transcode: building marks such a response, which is how a client can tell before it tries. hit or miss means the seekable file was there after all and was sent instead.size, maxBitRate and timeOffset each force a real re-encode, and this does not make one seekable or skip it.castToken is presented.true counts.transcodedContentType, transcodedSuffix and nativeSeek are not affected by it. They describe what any client would be sent, which is what keeps them cacheable and keeps them right for the one consumer that is not you - a Cast receiver being told what it is about to fetch.
Never send it on a URL something else will fetch, a Cast receiver above all. Two things break rather than one: the receiver is handed a stream with no length that it was told was MP4, and a client that fetches one byte ahead of a LOAD in order to make the build happen gets an instant reply having warmed nothing - so the first cast of every affected film fails and the second works, which reads as a random fault rather than as a setting.
Carried by hls.m3u8. Only the gaindrive additions are listed here.
Also served at hls.view, and a frame size may ride along with each variant bitrate.
| Parameter | Type | Description |
|---|---|---|
bitRate | string | May carry an @WxH suffix naming the frame size for that variant - 1000@640x480. may repeat. |
bitRate
Given once, it caps the segments of an ordinary media playlist. Given twice or more, the answer is a master playlist instead: one EXT-X-STREAM-INF per distinct bitrate, ascending, each naming this endpoint again with that one bitrate. RESOLUTION is announced only for a variant that supplied @WxH.
A value that names no bitrate - 0, or anything unparseable - cannot be a variant, since BANDWIDTH is required of one. 0@640x480 is still honoured on a media playlist, where 0 is the spec's "no limit" and the frame size governs on its own.
The spec spells this endpoint hls.m3u8, and that spelling is the one to prefer - a player often infers HLS from the .m3u8 extension alone. But a client that composes every URL as <name>.view would otherwise be unable to reach it at all, so the same playlist is served at /rest/hls.view, and at /rest/hls, which the server rewrites to the latter.
The playlist is stateless - it is arithmetic over the stored duration, and every segment is an ordinary stream request bounded by timeOffset and duration. Nothing is written to disk and nothing needs cleaning up if a client stops mid-playlist.
Every URI in the body is relative, so it resolves against /rest/ whichever of the three paths was fetched. A variant URI names this endpoint again, and names it with the spelling the request arrived on.
Carried by any endpoint that returns a song entry. Only the gaindrive additions are listed here.
Two fields on a video entry, carried by every endpoint that returns one.
| Field | Type | Description |
|---|---|---|
nativeSeek on Child | bool | Whether the stream this entry would produce carries a Content-Length and answers Range requests. |
season on Child | int | The season an episode belongs to; present only when the entry is one. |
nativeSeek
A client can let its media element seek by itself instead of re-requesting with timeOffset.
It is not the same question as "will ffmpeg run": the remux tier runs ffmpeg but writes a real file, so it seeks as well as an untouched original. Only a re-encode is unseekable. transcodedSuffix cannot be used for this - it is present for both the remux and re-encode tiers, which differ precisely here.
A client that sends timeOffset for a remuxable file demotes it from a -c copy remux to a full re-encode.
season
discNumber already carries the same number - that is the field every Subsonic client groups and sorts by, and none of them knows what a season is - so a client that ignores season groups a series correctly and simply calls the groups discs. A client that reads it can head them "Series 2" instead.
The number comes from an S02E03-style marker or from a folder naming its season. A Disc 2 or CD1 folder is deliberately not one, and neither is an unnumbered Specials folder, so both of those keep reporting a disc.
Both fields are populated wherever a song entry appears. That was not always so: they once came only from getAlbum, getMusicDirectory, getVideos and getSong, and a video reached any other way reported nativeSeek: false whatever its codecs were - so the same file answered differently depending on which endpoint was asked.
Carried by getVideoInfo. Only the gaindrive additions are listed here.
One field on each caption entry, saying where that subtitle lives.
| Field | Type | Description |
|---|---|---|
source on captions | string | Where the track lives: container for a stream inside the video, sidecar for a subtitle file beside it. |
source
The same two words getChapters uses for the same distinction, and it matters for the same kind of client: one that demuxes the container itself.
Every caption listed here can be fetched as WebVTT from getCaptions, whichever it is. But a client playing the container directly - see playable on stream - is handed the embedded tracks by its own demuxer, so side-loading those as well lists every subtitle twice. The sidecar is the half no container carries, so it still has to come from here.
A client that is being served a remux or a re-encode has no embedded tracks at all: the conversion keeps the first video and audio stream and drops the rest. So it should side-load every caption regardless of source, and only a client reading the original container should filter on it.
Carried by getCaptions. Only the gaindrive additions are listed here.
A Chromecast fetches its own subtitle track and has no credentials.
| Parameter | Type | Description |
|---|---|---|
castToken | string | A token from castLoad or getCastToken, accepted in place of credentials. |
castToken
Scoped as it is on stream, and the two issuers differ by one notch here. castLoad's is additionally scoped to the caption ids that LOAD declared: a track the LOAD did not offer is one the receiver has no reason to ask for, so the token does not authorise it. getCastToken's authorises any caption of its song, because there is no LOAD of ours to scope it against - the client builds its own - and a subtitle of a song that account may read is no wider a reach than the song.
The converted result is cached, because ffmpeg has to demux the whole container to collect one subtitle stream and the receiver fetches the track the moment it is switched on.
A concert, a DJ set, a mixtape and a lecture recording are each one file holding a dozen items, and chapter markers are what let a client say where each one starts, jump between them and show which one is playing. Nothing here is restricted to video: an audio file carries them for exactly the same reason, and every endpoint below takes either.
Markers live in a sidecar text file beside the media, <stem>.chapters.txt -- Concert.chapters.txt beside Concert.mkv -- in the format mp4chaps --export writes, which means mp4chaps --import will move them into the container for anyone who wants them there. The double extension is deliberate: a plain <stem>.txt is indistinguishable from liner notes, which getAlbumTexts lists, and that listing skips anything ending .chapters.txt for the same reason.
gaindrive never writes markers into a container. MP4 chapters are a track inside the file, so a save would be an ffmpeg -c copy rewrite of every byte of a multi-gigabyte concert, needing that much free space as well as the time. A container's own chapters are read, as a fallback, and only for video.
Nothing about a marker is durable in either database. The file on disk is the only copy, so a rebuilt library still has them, a moved directory carries them with it, and a sidecar written by hand or by another tool needs no API call to take effect. What the databases do hold is an index, rebuilt by the scan from those same files, and the split between the two is the thing to know before building on any of this:
getChapters reads the file, on every call. A playback lookup has to be right, and it is the only endpoint that can see a container's own markers.getAlbumChapters and the search2/search3 additions read the index. A browse listing has to be cheap, and reading a sidecar per item -- or running ffprobe for a video without one -- every time somebody opens an album is exactly the cost the index removes.saveChapters writes the file and updates the index in the same call, so a save is visible everywhere before it returns. A sidecar changed outside gaindrive reaches getChapters at once and the other two only after the next scan of that folder.A chapter is not a song and has no id of its own. It cannot be streamed, starred, queued or bookmarked; a client acts on one by streaming the song it is inside and seeking to start. That is a decision rather than an omission, and the reason is durability -- a synthetic id would have to encode a path, and a folder rename would then orphan every star keyed on one.
The markers inside one recording, read from the file itself.
| Parameter | Type | Description |
|---|---|---|
id * | string | A song id, audio or video. |
| Field | Type | Description |
|---|---|---|
chapters.id | string | The song id that was asked for, echoed back. |
chapters.source | string | Where the list came from: sidecar, container or none. |
chapters.writable | bool | Whether this caller may save markers for this item. |
chapters.chapter | Chapter[] | The markers, earliest first. At most 1000; see the notes on saveChapters. |
index on Chapter | int | Position in the list, from 1. |
start on Chapter | float | Seconds from the start of the file, rounded to the millisecond. |
duration on Chapter | int | Whole seconds until the next marker, or until the end of the file. |
name on Chapter | string | The title on that line, which may be empty. |
chapters.source
A sidecar wins outright when both exist, because somebody put it there deliberately -- the same rule a sidecar subtitle follows.
container means the markers were read out of the file with ffprobe -show_chapters, and they are read-only in the sense that editing them writes a sidecar which shadows them. Deleting that sidecar brings them back.
none covers every remaining case as one answer: no sidecar and an audio file, no sidecar and a video whose container carries nothing, an unreadable file, or a failed probe. It is not a distinction a client can act on, so it is not reported as several.
chapters.writable
The same rule every other library edit follows: an admin anywhere, or the owner of the item inside their own uploads. Offered as a field so a client can draw a jump list rather than a form it cannot submit.
It is about permission alone. A container list is writable in this sense -- saving one writes the sidecar that shadows it.
index
Assigned after the sort, so it numbers the list as returned and is not whatever order the file happened to be in. It is a position and not an identity: inserting a marker renumbers every one after it.
start
Not whole seconds, because a save is a round trip: a client reads this list, changes one marker and writes the rest back, so truncating here would flatten every fractional timestamp in a hand-written file. A millisecond is also exactly what the file format holds, so the round trip is a fixed point.
duration
Derived rather than stored -- the file records only where each marker begins. The last one is measured against the item's own duration, which is why the server computes this rather than leaving each client to.
0 when that span is not positive, which is what a marker past the end of the file gives, and what two markers on the same timestamp give. Neither is an error and neither is rejected on save: a hand-typed file is allowed to be wrong, and dropping a line would be data loss.
| Code | Kind | When |
|---|---|---|
10 | Subsonic | id missing. |
70 | Subsonic | No such song, or one inside the uploads of another user. |
The authority, and the only endpoint that reads the media rather than the index. Use it for playback: a client that is about to jump between the songs in a concert needs the list that is actually on disk, including one somebody edited a moment ago in another window.
The sidecar leads, and does so even when it is empty. An empty file is a tombstone reported as source: sidecar with no markers at all, which is the only way to say "this one has no chapters" about a rip whose container disagrees.
source: container is reachable for a video only, and only when the container actually yielded a marker. Reading a container costs an ffprobe, which is affordable once per film and not once per audio track in a library; an audio file's markers come from its sidecar or not at all. An M4B audiobook is the case this shuts out, and it is a known gap rather than a decision about audiobooks.
A marker's name is reported exactly as the file holds it, empty included. Draw your own placeholder for a bare marker; the server will not invent one, because a client that saved back what it read would then write the placeholder into a line somebody deliberately left blank.
See also saveChapters, getAlbumChapters.
Replaces the marker list for one item. The request body is the chapter file itself.
Permission: Admin anywhere, or your own upload.
| Parameter | Type | Description |
|---|---|---|
id * | string | A song id, audio or video. Query string, not the body. |
| Code | Kind | When |
|---|---|---|
10 | Subsonic | id missing. |
70 | Subsonic | No such song, or one inside the uploads of another user. |
50 | Subsonic | The caller may not modify this item. |
0 | Subsonic | More than 1000 markers, or the file could not be written. |
id goes in the query string; the body is the contents of the sidecar, sent as text/plain; charset=utf-8 and parsed by the same code that reads one off disk. That is one definition of the format rather than two that can drift, and it makes a round trip a fixed point. Repeated start=/name= parameters were the obvious shape and do not work here: the server drops an exact duplicate pair, so two chapters both called Encore would lose one, and the urlencoded body limit is 8 kB whatever the configured payload limit says.
The body is bounded at 1 MiB - a request naming more Content-Length than that is answered 413, and one naming none at all 411 - which clears any real chapter list by three orders of magnitude, the marker cap being 1000.
The parse is deliberately liberal, so a block pasted out of a video description works as it stands:
MM:SS, H:MM:SS and HH:MM:SS.mmm, with , accepted as the decimal point-, |, en dash or em dash between the time and the title is dropped, but only when whitespace follows it, so a title that genuinely begins with a dash keeps it# comments, a UTF-8 BOM and CRLF endings are all toleratedFour rules inside that are decisions rather than arithmetic, and each is the one a client is most likely to get wrong:
00:00.5 is half a second, not five thousandths of one.90 is a track index in every list that has one, so at least two colon-separated fields are required and a line holding one number is skipped.90:00 Encore is ordinary in a hand-typed list while 13:99:00 is a typo. Seconds are always below 60.13:35abc from parsing as a time followed by a title.Lone-CR line endings -- classic Mac -- are not supported, and saying so is the point: an invisible CR would otherwise travel into a JSON string, an XML attribute and back out into the file again.
A title is trimmed, has its control characters dropped rather than escaped, is cleaned of invalid UTF-8, and is truncated at 500 bytes. Dropping is not a style choice: the format is line-based and has no escape syntax, so a newline inside a title would silently become another marker.
The file is rewritten in canonical form -- markers sorted by start, one HH:MM:SS.mmm Title line each, LF endings. Comments, blank lines, the original spelling of every timestamp and any line the parse skipped are not preserved. A save is a replacement of the file, not an edit of it. Markers sharing a timestamp keep the order the body gave them and are not merged.
The reply is the saved list in the shape getChapters returns, re-read from disk. Adopt it rather than what you sent: the server sorts, sanitises and may drop a line it could not read, so anything else leaves the client describing a file that is not the one on disk. The index behind getAlbumChapters and the search additions is updated in the same call, so the save is visible to every endpoint before it returns.
The write publishes by rename, so no reader -- including the person with the file open in an editor -- ever sees half a save.
An empty body writes an empty file rather than deleting it. That empty file is what says "this one has no chapters" -- without it, clearing the markers of a rip whose container carries its own would simply make those reappear, and there would be no way to overrule them.
See also getChapters, getAlbumChapters.
Every chaptered item in one album folder, with its markers.
| Parameter | Type | Description |
|---|---|---|
id * | string | An album folder id, as getAlbum takes. |
| Field | Type | Description |
|---|---|---|
albumChapters.id | string | The album folder id that was asked for, echoed back. |
albumChapters.song | Song[] | One entry per chaptered item, in the order the album lists its tracks. Anything with no markers is absent. |
id on Song | string | The song id. Stream this and seek to a marker; a chapter has no id of its own. |
title on Song | string | Its own title, for use as a group heading. |
chapter on Song | Chapter[] | Its markers, in the same shape getChapters returns. |
| Code | Kind | When |
|---|---|---|
10 | Subsonic | id missing. |
70 | Subsonic | No such album folder, or one inside the uploads of another user. |
What a concert folder needs to list its songs rather than one row named after the file. A folder holding several recordings returns each of them, audio or video alike, and a client can draw the markers as the album's tracks under a heading naming the recording they came from.
This reads the index, where getChapters reads the file. It covers the album folder and its disc subdirectories -- the same scope the album listing itself uses -- and returns them in the order that listing already draws, so the two can be zipped together without re-sorting.
Only sidecars are indexed. A video whose markers live solely in its container appears in getChapters and not here, until they are saved -- which writes the sidecar the scan then indexes.
An item whose sidecar is the empty tombstone has no markers and is therefore absent, exactly as one with no sidecar at all. The distinction the two carry in getChapters has nothing to add to a listing.
See also getChapters, saveChapters.
Carried by search2, search3. Only the gaindrive additions are listed here.
Matching the song titles inside a recording, as well as the library.
| Parameter | Type | Description |
|---|---|---|
chapterCount | int | How many chapter matches to return. Clamped to 0..500, where 0 asks for none. default 0. |
chapterOffset | int | How many to skip. default 0. |
chapterCount
Defaults to 0, unlike artistCount, albumCount and songCount, which default to 20. A client that does not know about chapters should not be made to pay for the extra query, and one that does asks for them by name.
| Field | Type | Description |
|---|---|---|
searchResult.chapter | Chapter[] | Chapters whose title matched. Absent when none did, or when chapterCount was not sent. |
songId on Chapter | string | The song this marker is inside. Stream that and seek to start. |
parent on Chapter | string | That song's album folder id, so a client can open the listing it sits in. |
start on Chapter | float | Seconds into that song, rounded to the millisecond. |
index on Chapter | int | Its position in that song, from 1. |
name on Chapter | string | The marker title that matched. |
track on Chapter | string | The title of the song or film the marker is inside, for context in a result row. |
album on Chapter | string | Its album title. |
artist on Chapter | string | Its artist name. |
searchResult.chapter
A different shape from the Chapter the other three endpoints return: it carries the context needed to draw a result row, and it has no duration, which cannot be known without the rest of the list.
A whole concert is one file, so without this the songs in it cannot be found at all.
Matching is on the marker title only, as a case-insensitive substring, and the results are ordered by that title rather than by any measure of relevance. That ordering is global and stable, which is what makes chapterOffset paging meaningful. It reads the index, so a video whose markers live only in its container is not searchable until they are saved.
Matches come back in an array of their own and never as song entries. A chapter has no id anything can stream, star or queue, so reporting one as a song would hand a client a track that does not work. Act on one by streaming songId and seeking to start.
See also getChapters, getAlbumChapters.
Liner notes and scans that sit alongside the audio as ordinary files.
Lists the .txt files in an album folder, sorted by name.
| Parameter | Type | Description |
|---|---|---|
id * | string | Album folder id. |
| Field | Type | Description |
|---|---|---|
albumTexts.textFile.name | string | Filename, to be handed back to getAlbumText. |
Utility files that are not human-readable notes are excluded: currently fingerprints.txt by name, and anything ending .chapters.txt by suffix, since that is a chapter sidecar rather than prose. The suffix is matched rather than the whole name because the rest of it is the media file's stem.
An album that is a single media file has no folder to list. Its notes are the sidecar named after it - makingcheese.txt beside makingcheese.mp4 - and that one file is what this returns, if it is there. The chapter sidecar cannot be confused with it: that is makingcheese.chapters.txt, a different name.
Serves one of those files. The response is the file itself, not a Subsonic envelope.
Content type: text/plain; charset=utf-8
| Parameter | Type | Description |
|---|---|---|
id * | string | Album folder id. |
name * | string | Filename from getAlbumTexts. Must end in .txt and may not contain /, \ or ... |
| Code | Kind | When |
|---|---|---|
400 | HTTP | The name fails either rule above. |
How many images an album holds - the cover plus any extras.
| Parameter | Type | Description |
|---|---|---|
id * | string | Album folder id. |
| Field | Type | Description |
|---|---|---|
albumImages.count | int | Total image count, so valid index values are 0 to count-1. |
Use it to drive the index parameter of getCoverArt.
For an album that is a single media file the extras are the images sitting beside it and named after it, rather than the contents of a directory.
Writes the album its cover image, whichever of JPEG or PNG the uploaded bytes actually are.
Permission: Your own upload batch, or admin anywhere.
| Parameter | Type | Description |
|---|---|---|
id * | string | Album folder id. Required in both cases. |
file | multipart | A multipart part whose content type must begin with image/. |
url | string | An http:// or https:// URL, fetched server-side; the response must have an image/ content type. |
Supply exactly one of file or url.
A url must be http or https and must resolve to a globally routable address. Loopback, link-local, RFC1918 and other private ranges are refused, and a redirect into one is refused at the hop that names it - the server is reachable from places the caller is not, and an image fetch is not a reason to lend it out. The refusal is deliberately worded the same as a fetch that simply failed, so the endpoint cannot be used to tell one host or port from another. The response body is also size-capped.
JPEG and PNG only - the format is taken from the magic bytes, not from the declared content type, and anything else is rejected. That is not a new restriction so much as the removal of a trap: the scanner indexes only .jpg, .jpeg and .png, so a WebP written as cover.jpg used to become a cover nothing could decode.
If the upload changes the format, the file it replaces is removed, since cover.jpg outranks cover.png when the scanner looks for a cover.
A cover set this way is marked as chosen by a person and is never overwritten by a scan - which for a film is the only way to correct a wrong TMDB match.
Where it is written depends on what the album is. An ordinary album is a directory and gets cover.jpg or cover.png inside it. An album that is a single media file gets the sidecar named after it - makingcheese.jpg beside makingcheese.mp4 - because there is no inside. For the sidecar the scanner's preference order is .jpg, .jpeg, .png, so uploading a PNG removes both earlier spellings; the -poster.* forms rank below all three and are left alone.
This is what makes the note above true of a loose film at all. Under the rule this replaced, a media file sitting directly in a section was not an album, so an upload here reached only the section's own cover - and a wrongly matched film could not be given the right poster by any means.
See also getAlbumImages.
Carried by getAlbum. Only the gaindrive additions are listed here.
Say whether this caller may edit an album, before offering to.
| Field | Type | Description |
|---|---|---|
writable on Album | bool | Whether this caller may edit this album -- its tags, its cover and its name. |
writable
The same rule every other library edit follows, and the same question getChapters answers as chapters.writable: an admin anywhere, or the owner of the item inside their own uploads.
Offered as a field because no set of roles answers it. An account with uploadRole may edit some albums and not others, so a client deriving this from getUser would either hide the button from people who may edit or draw a form the server refuses.
moveAlbum is fractionally stricter for a non-admin -- it also wants the full uploads shape, and refuses anything naming a destination root, that being a move into the shared library rather than an edit. Renaming your own upload in place, which is what an album edit sends, is covered.
Only getAlbum carries it. The album entries from getAlbumList2, search3 and getMusicDirectory do not, and its absence there is not a claim that an album is read-only.
See also updateSong, setCoverArt, moveAlbum.
Updates the tags of a single track.
Permission: Your own upload batch, or admin anywhere.
| Parameter | Type | Description |
|---|---|---|
id * | string | Song id. |
title | string | Omitted fields are left untouched. |
track | int | Track number. |
year | int | Year. |
disc | int | Disc number, written through TagLib's PropertyMap as DISCNUMBER since the generic tag API has no disc setter. |
For an audio track the file is authoritative. Tags are written to it first via TagLib and mirrored into the database only if that succeeds, so a failed write leaves both in their original state.
For a video the edit is recorded in the server's user database instead, and re-applied over the scanned values on every scan. No tag is written, for any container: the scanner reads a video's title, year and episode number from its filename and never opens it with TagLib, so a tag would be a second copy of a fact that nothing reads. This applies to .mp4 as much as to .mkv, even though TagLib could write the first of those.
The practical difference is that a video edit survives a rebuild of the music library database while remaining invisible to other tools, whereas an audio edit travels with the file itself. Renaming the file is still the way to change a video's title such that another program will see it.
Puts an album somewhere, under a name, by moving the directory on disk.
Permission: Your own upload batch, or admin anywhere.
| Parameter | Type | Description |
|---|---|---|
id * | string | Album folder id. |
musicFolderId | string | The destination root, as getMusicFolders reports it. Omitted keeps the album where it is. |
folder | string | The level under the root: an artist under an artists root, a category under a categories one. A name not already present is created, so there is no separate operation for adding an artist or a category. |
album | string | New album name. Omitted keeps the current one. |
musicFolderId
Error 70 if it names anything that is not a browsable library root - including the uploads root, which that listing excludes and which is never a destination.
Naming a root requires admin, since it is what puts something into the shared library.
folder
Required when musicFolderId is given, and omitted keeps the current one otherwise. It was briefly allowed to default when promoting and the default was a guess that filed things wrongly: it used the batch's own artist name, which for a fetched video is the channel that published it and never a category. A caller that does not know where something belongs should be made to decide rather than have it decided badly for them.
| Field | Type | Description |
|---|---|---|
movedAlbum.id | string | The album folder id afterwards. Usually unchanged, but a move into a directory that did not exist yet mints one. |
movedAlbum.parent | string | The artist or category folder id afterwards. |
movedAlbum.album | string | The album name as it was applied, after sanitising. |
movedAlbum.artist | string | The folder name as it was applied, after sanitising. |
movedAlbum.tagFailures | int | How many files could not have their tags rewritten. The move still happened. |
movedAlbum.tagFailures
Zero when no tag needed changing - a move that alters neither name opens no files at all.
| Code | Kind | When |
|---|---|---|
10 | Subsonic | id missing or not a number; folder missing while musicFolderId is given; a supplied name holding nothing usable once sanitised. |
50 | Subsonic | Naming a destination root without admin, or moving anything that is not your own upload batch without admin. |
70 | Subsonic | No album with that id, or musicFolderId does not name a browsable library root. |
0 | Subsonic | The album is its own artist and no destination root was named, something with that name is already in the destination, or the target falls outside every configured root. |
Every parameter but id is optional and every omitted one means unchanged, so this one endpoint covers renaming an album in place, re-filing it under a different artist, moving it between library roots, and promoting an upload into the shared library. It replaces renameAlbum and promoteAlbum, which were the same operation described two ways.
Where it lands is the one rule worth reading:
musicFolderId given - the album goes to <that root>/<folder>/<album>, exactly two levels under the root. Both library layouts are L1/L2/[L3]/ files, L2 is the album being moved, and L3 is only ever a disc or season directory inside it.musicFolderId omitted - everything above the artist level is left alone: the library root, and for an upload the owner and the batch id. The album goes to <all of that>/<folder>/<album>.So a plain rename sends album; a re-file sends folder; a promote sends musicFolderId and folder; and moving a film out of the music root and under a categories one sends all three.
Artist and album names are directory names - the scanner never reads them from tags - so this is the only way to correct them, and it is necessarily a filesystem move.
It re-files only the album it is given: changing folder moves that one album and leaves the artist's other albums alone. A whole-artist rename is deliberately not offered - an album edit screen silently rewriting other albums is too easy to do by accident.
The directory move happens first and is the authoritative act; the database rows are then repaired to carry the new paths, so stars, play counts, playlist entries, the queue and bookmarks all survive. Tags are rewritten afterwards and a file TagLib will not write is counted in tagFailures rather than failing the call - the opposite order to updateSong, where the database mirrors the tag rather than the directory.
The artist tag is only rewritten under an artists root. Under a categories root the level is a category - Film, Series - and writing that into an artist tag would stamp "Film" across every file of a promoted documentary. The album tag is rewritten whenever album changes.
An artist tag naming somebody other than the old folder is left alone. Only a tag that already held the old folder's name is rewritten, because anything else is a real credit - every track of a compilation has one - and overwriting it would report the whole album as by one artist ever after. The comparison ignores case and punctuation, the same rule that decides a song entry's artist.
An emptied source directory is removed, so it does not linger in the listing reading "0 albums", and an upload's batch directory goes with it.
An album may be a single media file. A media file sitting directly in an artist or category folder is an album of its own, and this endpoint moves and renames it like any other. Three things follow. The file keeps its extension: album is a name, so the source's extension is put back, and a name that already ends in it does not acquire a second one. The album field in the response is the name without the extension, and it is that name - not the on-disk leaf - that is written into the album tag. And the file's sidecars travel with it: its cover and poster, its subtitle files, its chapter markers and its liner notes.
A single file sitting directly in a library root can be renamed in place, with no musicFolderId. Such an item has no artist directory of its own - the root plays that part - so folder, if given, must name that root. A directory in the same position still cannot: there is no artist level to change, and the error says so.
See also updateSong, deleteUpload.
Admin only, and JSON only - these carry credentials for third-party services and have no XML representation.
Reports whether the server holds each third-party API credential.
| Field | Type | Description |
|---|---|---|
serverSettings.discogsTokenSet | bool | Whether a Discogs personal access token is stored, used in the artist-portrait chain. |
serverSettings.tmdbKeySet | bool | Whether a TMDB API key is stored, used to identify films and series during the scan. |
| Code | Kind | When |
|---|---|---|
50 | Subsonic | The caller is not an admin. |
The credentials themselves are never returned, only whether each is set. Nothing needs to read one back - they are written once and used server-side - and a secret the server hands out is one in an API response, in the client's memory and in every cache between. It also stopped a browser's own password manager offering to store the value as a login, the web client having put it in a masked box.
Editing one is therefore blind: send a new value to saveServerSettings, or omit the field to leave it alone.
Sets one or both of those credentials.
| Parameter | Type | Description |
|---|---|---|
discogsToken | string | New Discogs token. Omit to leave unchanged; send empty to clear. getServerSettings reports only whether one is stored, so a client cannot show the current value. |
tmdbKey | string | New TMDB key. Omit to leave unchanged; send empty to clear. Re-read at the top of every scan. |
| Code | Kind | When |
|---|---|---|
50 | Subsonic | The caller is not an admin. |
Only the settings the caller actually sent are written, so omitting one preserves it. With a single field that distinction did not exist; with two, a client saving just one of them would otherwise blank the other.
Send an empty value to clear a setting.
Live load and capacity snapshot: HTTP workers, ffmpeg processes, caches, queues, uptime and memory.
| Field | Type | Description |
|---|---|---|
serverStatus.uptimeSeconds | int | Seconds since the server process started. |
serverStatus.memoryRssBytes | int | Resident set size of the server process. Absent on a system without a proc filesystem. |
serverStatus.http.busy | int | HTTP worker threads currently handling a request, this one included. |
serverStatus.http.queued | int | Accepted requests waiting for a free worker. |
serverStatus.http.threads | int | Size of the worker pool. |
serverStatus.http.queueCap | int | Bound on the wait queue; a request past it is refused rather than queued. |
serverStatus.transcode.piped | int | ffmpeg processes streaming straight to a response: seeks, HLS segments, cast pipes and cache fallbacks. |
serverStatus.transcode.pipedMax | int | Ceiling on piped transcodes; one past it gets a 503. |
serverStatus.transcode.running | int | ffmpeg processes filling the on-disk transcode cache, background builds included. |
serverStatus.transcode.bgRunning | int | The background builds within running. |
serverStatus.transcode.jobsMax | int | The --transcode-jobs bound on running. |
serverStatus.transcode.cacheEnabled | bool | False when the on-disk cache is disabled; the byte fields then read zero. |
serverStatus.transcode.cacheBytes | int | Bytes on disk in the transcode cache, files still being written included. |
serverStatus.transcode.cacheCapBytes | int | The --transcode-cache-mb cap, in bytes. |
serverStatus.coverCache.memBytes | int | Memory held by the scaled-cover cache. |
serverStatus.coverCache.memCapBytes | int | Its cap; least-recently-used images are dropped past it. |
serverStatus.coverCache.entries | int | Scaled images currently held in memory. |
serverStatus.coverCache.building | int | Image decodes running right now. |
serverStatus.coverCache.jobsMax | int | Bound on concurrent decodes. |
serverStatus.fetch.queued | int | URL fetches waiting for the single fetch worker. |
serverStatus.fetch.queueCap | int | Bound on that queue. |
serverStatus.scan.scanning | bool | Whether a library scan is running. |
serverStatus.scan.count | int | Files seen by the scan so far, or by the last one. |
serverStatus.cast.active | bool | Whether a cast session is up. |
serverStatus.streamGrants | int | Unexpired one-song stream grants (see the Stream grants section). |
serverStatus.loginThrottle | int | Addresses in the login-throttle table. A spike means someone is guessing passwords. |
| Code | Kind | When |
|---|---|---|
50 | Subsonic | The caller is not an admin. |
Every counter is instantaneous, read at the moment of the request. http.busy includes the worker answering this very request, so it never reads zero while a client is polling.
Queue an online lookup for every artist with no biography and every album with no description.
| Parameter | Type | Description |
|---|---|---|
what | string | artists, albums, or all for both. Any other value is rejected rather than treated as all, since promoting a typo to "everything" would start a pass nobody asked for. default 'all'. |
| Field | Type | Description |
|---|---|---|
infoLookup.artistsQueued | int | How many artists were added to the queue. |
infoLookup.albumsQueued | int | How many albums were added to the queue. |
| Code | Kind | When |
|---|---|---|
0 | Subsonic | what is not one of the three accepted values. |
50 | Subsonic | The caller is not an admin. |
The remedy for a library full of blank biographies, which happens because a lookup that only half succeeded was cached as a complete answer: the artist chain writes its row as soon as the MusicBrainz search has worked, so an artist whose Wikipedia or TheAudioDB request fell over keeps an empty biography - and nothing re-asks, since the background resolver's own seed query is about missing portraits, which that artist has. force on getArtistInfo2 fixes one artist. This fixes all of them.
It takes hours, and it is meant to be left running. MusicBrainz allows one request a second per address, the resolver waits two seconds between jobs on top of that, and each artist costs two MusicBrainz requests plus up to four more providers. A library of a few thousand artists is an overnight job.
Everything is queued behind whatever a client is looking at, so browsing stays responsive while it runs.
There is no status endpoint and no way to stop it short of restarting the server. The queue is in memory, so a restart simply forgets the rest of the pass and pressing the button again re-queues whatever is still missing. The server log names each artist and album as it is resolved.
An entry with no words is re-asked on every press - no interval is remembered. That is deliberate: it is what makes a spell of provider failure recoverable, and the cost is that a second press also re-asks about everyone nobody has written about.
Video is untouched. A film's or series' description comes from TMDB during the scan, and the two seed queries only look at artists roots - asking MusicBrainz about a categories section called "Film" is the mistake getCoverArt already avoids. A concert filed under the performer rather than under a category is included, since that test is on the root and not on whether an album holds video.
Carried by getUser, getUsers, createUser, updateUser. Only the gaindrive additions are listed here.
One role Subsonic does not define.
| Parameter | Type | Description |
|---|---|---|
castRole | bool | On createUser and updateUser, the literal string true enables; anything else means false. |
castRole
On updateUser, as with every other field there, omitting the parameter keeps the account's existing value.
| Field | Type | Description |
|---|---|---|
castRole on user | bool | Whether the account may use the cast endpoints. New accounts default to false. |
Roles gaindrive does not implement are still reported, with fixed values, so that clients expecting them do not break. That behaviour is standard-shaped and documented with the endpoints themselves.
Each upload is extracted into <uploads root>/<username>/<uuid>/ so that archives with a missing or messy artist/album structure stay isolated and the scanner always has a stable artist-level root to work from.
That isolation ends when the upload succeeds. Each artist directory is then merged into the one the account already has under an earlier batch, and the emptied batch is removed. So uploading two archives that name the same artist gives one artist holding both albums, and fetching several tracks into one album adds them to it rather than making a second album of the same name. Colliding files are suffixed, never overwritten. fetchUrl shares this step, which is why artist and album behave identically on both: a blank or absent value keeps what the source supplied, and a value holding nothing usable once sanitised is refused rather than silently ignored.
Uploads an archive of audio or video into the calling account's personal area.
| Parameter | Type | Description |
|---|---|---|
file * | multipart | A multipart part named file, with a .zip, .tar, .tar.gz or .tgz filename. |
artist | string | Files the batch under this name instead of whatever the archive's own folders and the files' tags say. |
album | string | The same for the level below. |
| Field | Type | Description |
|---|---|---|
status | string | ok or error. |
files | int | How many media files the archive yielded. |
batch | string | The batch id the upload landed in. |
| Code | Kind | When |
|---|---|---|
400 | HTTP | No uploads root, an unsupported archive type, a supplied name holding nothing usable once sanitised, or an archive none of whose contents could be written. |
Note the path: this is not under /rest/, and it answers with a bare {"status": ..., "message": ...} object rather than a Subsonic envelope. A refusal arrives as {"status": "error", ...} with HTTP 400, not as a Subsonic error code.
With no uploads root configured the endpoint refuses rather than writing into a library root.
See also fetchUrl, moveAlbum, deleteUpload.
Removes an uploaded album, files and all. Irreversible - there is no trash behind it.
| Parameter | Type | Description |
|---|---|---|
id * | string | Album folder id. |
| Code | Kind | When |
|---|---|---|
0 | Subsonic | Not an upload, or not yours - "Item is not in your uploads." |
The id must resolve to a path of exactly the shape <uploads root>/<owner>/<batch>/<artist>/<album>, and the owner must be the calling account unless the caller is an admin.
That shape check is the security boundary, not a validation nicety - without it this is "delete the folder with this id", which is "delete any folder on the server", reachable by any account with upload rights guessing integers. One message covers both "not an upload" and "not yours", since telling them apart is only useful to somebody probing ids.
An admin may delete anybody's because an admin is who approves them: with personal=* they can see everyone's, and being able to see junk without being able to clear it would leave them asking its owner to do it.
It tidies upwards. An artist directory left empty by the deletion is removed too, or it would remain in the listing as an artist showing no albums; the batch directory goes after it. Stars, play counts, playlist entries, the queue and bookmarks naming the deleted files are deleted with them - those live in the client database, which no scan has ever touched.
The second way something reaches a personal folder: instead of uploading an archive, paste a URL and let the server fetch it with an external tool. All four endpoints require uploadRole or admin and return error 50 without it.
The server matches the URL against a table of handlers configured by the operator. A URL matching no handler is refused, and that refusal is the security boundary - without it, any account allowed to upload could make the server issue outbound requests to anywhere it can reach, including hosts unreachable from outside the network. Only http and https URLs are considered at all.
A fetch runs in the background, one at a time, and the request returns immediately with a job id. The job's state is one of queued, running, scanning, done, error or cancelled; the library scan happens before done, so a client seeing done can re-read the listing with no further wait. A failed, timed-out or cancelled job deletes its batch directory.
What this server can fetch.
| Field | Type | Description |
|---|---|---|
urlHandlers.urlHandler.name | string | A label for the handler, for showing the user what is supported. |
urlHandlers.urlHandler.audio | bool | Whether this handler can fetch in audio mode. |
urlHandlers.urlHandler.video | bool | Likewise for video. |
An empty list means the feature is unavailable, either because no handlers are configured or because the tool they name is not installed; a client should then not offer the option at all.
The pattern and the command line are deliberately not reported - a handler's argv can carry cookies, a proxy credential or an API key.
Queue a fetch. Returns immediately with a job id; the work happens in the background.
| Parameter | Type | Description |
|---|---|---|
url * | string | The URL to fetch. |
mode | string | Which one a handler supports is what getUrlHandlers reports. default audio; one of audio, video. |
artist | string | The artist directory the batch is filed under, replacing whatever the handler would have chosen. Absent or blank keeps the handler's own choice. |
album | string | Likewise for the album directory. |
url
It travels as a query parameter, so it is subject to the server's request-line length limit of about 8 kB.
| Field | Type | Description |
|---|---|---|
fetchJob.id | string | The job id, for getFetchJobs and cancelFetch. |
fetchJob.batch | string | The batch directory the fetch will write into, in stored form. |
fetchJob.handler | string | Which handler matched the URL. |
fetchJob.mode | string | audio or video, as requested. |
fetchJob.artist | string | The name that will be applied, or empty. |
fetchJob.album | string | Likewise. |
fetchJob.state | string | One of queued, running, scanning, done, error, cancelled. |
| Code | Kind | When |
|---|---|---|
10 | Subsonic | url missing, or artist or album given but containing nothing usable as a directory name. |
0 | Subsonic | No uploads root; the scheme is not http(s); no handler matches; the matched handler cannot do the requested mode; the same URL is already being fetched for this user; or the queue is full. |
The names are applied by renaming the batch's two directory levels after the tool exits and before the library scan, so they cost nothing and can never be seen half-applied. Three consequences are worth stating because none of them is guessable:
artist keeps the handler's album names; giving only album applies that name inside every artist directory.Note also that the duplicate guard keys on the URL alone, so the same URL cannot be fetched twice under two different names while one is in flight.
Where the handler reports chapter markers of its own -- the built-in one asks for them -- they are written out as a sidecar beside the media, so a fetched concert or DJ set arrives with its markers already in place. An existing sidecar is never overwritten, and a fetch that reports no markers writes nothing rather than the empty file that would assert there are none.
See also getUrlHandlers, getFetchJobs, cancelFetch.
The calling user's own jobs, newest first.
| Field | Type | Description |
|---|---|---|
fetchJobs.fetchJob | object[] | Everything fetchUrl returns, plus the fields below. |
url on fetchJob | string | The URL as submitted. |
percent on fetchJob | int | Progress, parsed from the tool's output. A line carrying no percentage keeps the previous value rather than resetting to zero. |
detail on fetchJob | string | The tool's last output line. |
error on fetchJob | string | Why the job failed, when its state is error. |
files on fetchJob | int | How many media files the fetch produced. |
started on fetchJob | string | When the job began. |
finished on fetchJob | string | When it ended, if it has. |
detail
Paths in it are rewritten to their stored form - a root path is never surfaced in an API response.
Admins see their own and nobody else's: this is a progress display, not an audit log.
Finished jobs are kept for fifteen minutes, or the last ten per user.
Stop a queued or running fetch and delete its batch.
| Parameter | Type | Description |
|---|---|---|
id * | string | The job id. |
| Code | Kind | When |
|---|---|---|
10 | Subsonic | id missing. |
70 | Subsonic | No such job belongs to the caller. |
Casting is server-driven: the browser asks gaindrive to load a URL onto the Chromecast, and the device then fetches the media from stream itself using a token. All seven endpoints require castRole in addition to authentication, and return error 50 without it. stream and getCaptions are exempt when called with a valid castToken, since a token is only ever issued to a caller that has already passed those checks - see getCastToken for the other issuer and for what it is bounded by instead.
Every one of them except stopCast also requires the caller to be on a network the server is attached to, and returns error 50 with a message naming the network rather than the role. stopCast is answered from anywhere, because refusing it would leave the music playing in the house with no way to end it from wherever the person holding the client now is. A client discovers the answer in advance from localNetwork on ping.
The rule is the client's address - X-Forwarded-For where a trusted proxy supplied one, otherwise the peer - falling inside a subnet the server is directly attached to. A VPN deliberately does not qualify. Casting exists to make sound come out of a device in the room, and a full tunnel from a hotel on another continent reaches the speakers just as well as standing next to them does; the point of the check is that the second case is almost always an accident. Interfaces are selected by flag rather than by name, so a point-to-point tunnel is excluded whatever it is called.
What the check cannot see is a VPN bridged into the LAN, or a router handing VPN clients addresses out of the LAN's own pool - such a client is on the subnet by every question available at this layer.
A refused call also ends the session it owns. Somebody who walks out of the house mid-album wants the music to stop, and leaving it to the castEvents idle watchdog would reach the same end by an indeterminate route. A refused caller that owns nothing tears down nothing.
A session belongs to one account and one client instance. The client half is the castController parameter - an opaque id the client makes up once and keeps. startCast requires it and records the pair; every other endpoint here compares against it, and a caller that does not match is treated as having no session at all: castSession reports inactive, castEvents answers 204, castLoad and castControl return error 0, and stopCast succeeds without stopping anything.
That is also what keeps stream honest. While a session is live, stream answers 204 to the session's owner and redirects playback to the receiver; everyone else is served normally. Without the owner test it answered 204 to every caller in the server, so playing a track in one client sent it to whatever receiver another client had most recently picked.
The id cannot be the standard c= parameter, which names the kind of client rather than the instance - every browser sends the same value, so two of one user's browsers could not be told apart. For the same reason there is no fallback to c= when castController is absent: two installs of one app would collapse into a single identity. A caller that sends none simply never owns a session, which is what every client that does not drive these endpoints wants.
castController is not a credential and authorises nothing. Authentication is unchanged, and the id only breaks ties among one account's own devices.
Only one session exists at a time, because the server holds one control channel. startCast from a different owner therefore takes the session over: the previous owner's receiver is stopped and its castEvents stream is dropped, so its UI leaves cast mode by itself.
The token is minted per castLoad and is scoped to what that load declared - one song, and the caption ids offered for it. It is not a general credential: presenting it for any other id is refused exactly as if it were absent.
Carried by ping. Only the gaindrive additions are listed here.
Says whether this request reached the server from a network the server is on.
| Field | Type | Description |
|---|---|---|
localNetwork | bool | True when the caller is on a network this server is directly attached to, and casting will therefore be accepted. |
localNetwork
It rides on ping because it is a fact about the request, not about the account: the same person is on the home network in the morning and not in the afternoon. That is also why it is not folded into castRole, which names what an account is allowed and must keep meaning that - a client reading a role that changed with the weather would write the change back the next time somebody edited the user.
A client should re-ask when the answer may have changed rather than once at startup: the web client does so on online and when a hidden tab is shown again, which between them cover shutting a laptop at home and opening it elsewhere.
Being told true is not permission. The seven casting endpoints test the same thing for themselves on every call, and this field exists so that a client can disable a control instead of offering one that fails.
Mints a token that lets a receiver fetch one track without credentials.
| Parameter | Type | Description |
|---|---|---|
id * | string | The song the token will be good for, and only that one. |
| Field | Type | Description |
|---|---|---|
castToken | string | Pass as the castToken parameter of stream, getCoverArt or getCaptions. |
| Code | Kind | When |
|---|---|---|
10 | Subsonic | No id was given. |
70 | Subsonic | No song with that id. |
50 | Subsonic | The song is a personal upload belonging to another account. |
For a cast this server is not driving. A client that holds its own Cast control channel - the Android and iOS apps both do - builds the receiver's URLs itself, and the receiver has no account to build them with. Before this existed those URLs carried u/t/s, which is to say the account's password: t is md5(password + salt) and s is the salt, and together they read the whole library as that person until the password changes.
Requires only authentication. Not castRole, and not the local-network rule the rest of this section carries. Neither would mean anything here: a client casting for itself is not asking this server to cast, so the permission to drive this server's Chromecast, and the question of which network this server is on, are both beside the point.
What bounds it instead is the grant - one song, one account, twelve hours - and the read-permission check below, which is the load-bearing one, since what comes back opens URLs needing no credentials at all.
One grant covers the three things a LOAD needs, and stopping at the audio would have defeated the exercise: the sleeve travels in the LOAD's metadata and the receiver fetches that too, so a stream-only grant would have left the password on the television regardless.
stream - the song the grant names.getCoverArt - that song's own artwork, resolved when the grant is minted rather than named by the caller, so asking for a grant can never be a way to name somebody else's.getCaptions - any caption of that song. Deliberately one notch wider than the session token, which scopes to the ids one LOAD declared: there is no LOAD here to mirror, and a subtitle of a song the account may read is no wider a reach than the song.hls.m3u8 is not covered. Its playlist copies the request's credentials onto every segment URL, which a grant would have to be propagated through.
It widens one thing deliberately: any account can now produce such a URL, where before only a castRole one could. The floor under that is that anyone who can read a song can already download it.
Mint one per track. Unlike castLoad's, a seek does not need a fresh one - a client that owns the control channel seeks the receiver rather than reloading it, and the receiver goes on fetching the URL it already has, which is why the lifetime is hours rather than minutes.
Do not put startImmediately or playable on a URL built with it - see those parameters for what a receiver does with the response they produce - and do put pace=true on it, since the server otherwise paces only for a browser's own User-Agent.
See also castLoad.
Chromecast devices on the LAN: those discovered over mDNS, plus any named in the server configuration.
| Field | Type | Description |
|---|---|---|
castDevices.castDevice.id | string | Hand this to startCast. For a configured device it is manual:<address>:<port>, derived rather than random so it survives a server restart. |
castDevices.castDevice.name | string | The device's friendly name, from its fn record. A device sending none is named by its instance label, so a row is never labelled by its IP. |
castDevices.castDevice.model | string | The device's own model string, from its md record. Empty for a configured device, which has no announcement to read. |
castDevices.castDevice.address | string | IP address. |
castDevices.castDevice.port | int | Usually 8009; a multizone group gets a dynamic one. |
castDevices.castDevice.manual | bool | True for a configured device. |
castDevices.castDevice.videoOut | bool | Whether the device can display a picture, from bit 0 of its ca record. True when it announced nothing, which is every configured device: refusing the picture on a guess is worse than the guess. castLoad sends a video to a device with this false as its soundtrack alone, unless videoPref overrules it. |
castDevices.castDevice.videoPref | string | auto, send or sound - what a person decided about this device with setCastDevicePref, which overrides videoOut. auto when nobody has. |
Discovery assembles a device only from records belonging to _googlecast._tcp, and returns one entry per Cast id, so a device announcing several services or several spellings of its name still appears once.
A configured device whose address and port were also discovered appears once, as the discovered one, since that carries the device's own name and Cast id. The port belongs in that comparison because a Cast multizone group lives at its leader's address on a different one.
Configured devices exist because discovery can fail for reasons no client can fix - a device that answers TLS on 8009 while its mDNS responder has stopped replying even to a direct unicast query. The address must be an IP literal; a hostname is refused at startup.
Records what a person decided about one cast device, overriding what it announced about itself.
| Parameter | Type | Description |
|---|---|---|
deviceId * | string | Device id from listCastDevices. Must name a device currently in that list. |
videoPref * | string | auto, send or sound. Anything else is rejected rather than stored, since an unrecognised value would read back as auto with nothing saying the setting had not taken. |
A device's ca record is not always the last word. A Chromecast-built-in amplifier announces no video output and plays a video file's sound perfectly well anyway, while a configured device announces nothing at all and so reads as capable even when it has no screen.
The setting is held by the server rather than by each client, because it is a fact about the device: the answer is the same in every browser and on a phone.
send does not give the device a screen, and is not meant to: the picture is still not shown there, and castLoad goes on reporting receiverShowsVideo false so a client keeps its own copy of the picture exactly as it does for a soundtrack. What it buys is that nothing has to be demuxed first.
send only ever raises a device that announced no screen, and only for a file that can be sent untouched. Anything needing a remux or a re-encode falls back to the soundtrack - a remux reads and writes the whole film, and a re-encode re-encodes a picture nobody can see, so both are worse than extracting the sound. So this cannot be used to force a re-encode, and on a DVD rip or an HEVC film it will appear to do nothing, which is the intended behaviour rather than a failure.
sound is the other direction: it forces the soundtrack for a device that announced a screen, or announced nothing.
Calling this also clears what the device has been recorded as refusing. A LOAD the receiver rejects is remembered per codec pair so it is not attempted again, and changing your mind about a device is the only place that judgement is reconsidered - otherwise new firmware, or a different device that inherited the address and with it the id, would be judged for ever on what its predecessor could not play.
See also listCastDevices, castLoad.
Activates cast mode, targeting one device.
| Parameter | Type | Description |
|---|---|---|
id * | string | Device id from listCastDevices. |
castController * | string | An opaque id identifying this client instance, which together with the account becomes the owner of the session. Make one up once and keep it - the web client stores eight random bytes in localStorage, so it survives a page reload. |
castController
Required, and deliberately without a fallback to c=. A client asking the server to drive a Chromecast already speaks this extension, so it can be asked to name itself, and refusing the nameless case is what guarantees a caller that sends no id can never own a session - and so can never be confused with another that also sent none.
It is not a credential. It authorises nothing and only distinguishes one account's devices from each other.
| Code | Kind | When |
|---|---|---|
10 | Subsonic | No castController was given. |
70 | Subsonic | No device with that id. |
This is where the session's owner is recorded: the authenticated account together with castController.
While the session is active, stream answers HTTP 204 to that owner and sends the track to the Chromecast instead, so the owner does not end up playing it twice. Every other caller is served the stream normally.
Only one session exists at a time. Calling this while another owner holds one takes it over: that receiver is stopped and the previous owner's castEvents stream is dropped, which is how its UI learns to leave cast mode. It is also the way back in for a client that has lost the id it started the session with.
Deactivates cast mode and stops playback on the device.
| Parameter | Type | Description |
|---|---|---|
castController | string | The id given to startCast. Without a match this caller is treated as having no session. |
Only the session's owner stops anything. A caller that does not match still gets a success response, deliberately: a client displaced by a takeover runs its own cleanup, and that must neither stop the session that replaced it nor raise an error about a session it no longer has.
Tells the Chromecast to fetch and play a track.
| Parameter | Type | Description |
|---|---|---|
id * | string | Song id. |
timeOffset | float | Seek position in seconds, sent to the receiver as currentTime. |
trackId | int | Subtitle track to start with, numbered from 1 in the order getVideoInfo lists captions. 0 or absent means none. default 0. |
castController | string | The id given to startCast. Without a match this caller is treated as having no session. |
trackId
trackId, not captionId: the cast API numbers tracks 1..n by position, and 0 means off. captionId cannot express "off" - the sidecar caption index and a missing parameter are both -1, so "no subtitles" and "the sidecar file" would be one value.
| Field | Type | Description |
|---|---|---|
castLoad.audioOnly | bool | True when the receiver was sent the video's soundtrack rather than the video, because it announced no videoOut. Always false for an audio track. |
castLoad.receiverShowsVideo | bool | Whether a picture will actually appear on the receiver. Not the negation of
audioOnly: a device whose videoPref is send is handed the whole film to
save extracting its soundtrack and displays none of it, so there the file goes
out as video and this is still false. A client deciding where to draw the
picture - keeping its own copy locally, muted, or showing a "playing on the
television" panel - keys on this. audioOnly describes the bytes on the wire
and is the info-panel's business, not the picture's.
|
castLoad.contentType | string | The contentType declared in the Cast LOAD, which is what the receiver picks its decode pipeline from. Not the source container: everything the remux or re-encode tier touches is announced as video/mp4, and a VP9/Opus .mkv is announced as video/webm although its bytes go out untouched. |
castLoad.sentSuffix | string | The container the receiver actually receives. The source extension on the direct tier, mp4 on the other two. A soundtrack reports whatever it was put in: m4a, mp3, flac, opus or ogg when the track was copied, and flac when it had to be encoded. |
castLoad.sentBitRate | int | Kbps, or 0 when the stream is not a fixed-rate encode. An audio cast is the raw
file byte-ranged - no format, no maxBitRate, and the account ceiling is
exempt for a cast token - so this is the file's own bitrate rather than anything
the transcoded* fields describe. A video remux keeps the source's rate and a
re-encode is not fixed-rate, so both report 0. So does a copied soundtrack - the
track's own rate is not the container's, and nearly right is the worst thing a
diagnostic can be - and so does an encoded one, FLAC having no target rate at
all.
|
castLoad.tier | string | Which of the three delivery tiers this fetch lands on: direct (the file as it stands, byte-ranged), remux (-c copy through the transcode cache, still seekable), or encode (re-encoded). The same two words apply to a soundtrack, and there they are the answer to "why does this sound worse": remux is the track copied out of the film untouched, encode a lossless FLAC of it. A soundtrack is never re-encoded lossily - the source is already lossy, and this is the one route with no choice about transcoding. |
| Code | Kind | When |
|---|---|---|
0 | Subsonic | No cast session is active, or the caller does not own it. |
Seeking is also a castLoad, not a castControl with action=seek - every seek is a full reload. The server returns as soon as the load is queued; the actual Cast message goes out on a background connection.
Subtitle tracks are declared to the receiver in every load, whether or not one is switched on, because castControl with action=captions can only activate a track the load already declared. Emitting them only when a caption is already chosen works when the viewer picks before playing and never when they pick during, which reads as an intermittent fault rather than a missing feature.
A video loaded onto a receiver that cannot display one is sent as its soundtrack, and the reply says so in castLoad.audioOnly. Which receivers those are is bit 0 of the ca record listCastDevices reports as videoOut, overridden by whatever setCastDevicePref recorded for that device. The decision is made here rather than by the client, so that a client draws what happened instead of deciding it a second time from the device list - and no subtitle tracks are declared for such a load, there being no picture to put them on.
A LOAD the receiver refuses outright is retried one rung down, rather than replayed. The first rung drops the subtitle tracks, since one unreachable track URL fails the whole thing. The second applies only where the picture was sent to a device that announced no screen - videoPref = send - and replaces the film with its soundtrack: the picture was never going to be seen there, so nothing anyone was looking at is lost. Watch castEvents for the description that results.
That second rung exists because the Direct tier is decided by browser codec support, which is wider than a Cast receiver's: an AV1/Opus MP4 passes it and no amplifier plays it. The refusal is remembered per device and codec pair, so the same film goes straight to its soundtrack next time; setCastDevicePref clears what a device has refused.
A video sent to a receiver that did announce a screen has no such fallback, and a film it cannot decode simply fails. There is nothing better to offer it: the remux tier is -c copy, so it would send the identical codecs again.
Such a load does not begin immediately: the soundtrack is produced in full before the receiver is told anything, because a receiver abandons a session after about a minute with no data on the HTTP body and a feature-length film takes longer than that to convert. How long that is depends on tier. A track already in a codec the receiver decodes - AAC, MP3, FLAC, Opus or Vorbis - is copied out of the container, which is remux and costs one pass over the file. Anything else (AC3, DTS, TrueHD, PCM, so most disc rips) is decoded and re-encoded, which is encode and is several times slower.
See also castControl, castSession.
Transport control for the active session.
| Parameter | Type | Description |
|---|---|---|
action * | string | What to do. one of play, pause, seek, captions, volume. |
time | float | Seconds, for action=seek. |
trackId | int | For action=captions, numbered as for castLoad. 0 or absent turns subtitles off. |
level | float | The absolute level, 0..1, required for action=volume (error 10 without it) and clamped into that range. Absolute rather than a step, so a client can show what it asked for and be corrected by the report. |
castController | string | The id given to startCast. Without a match this caller is treated as having no session. |
action
play resumes a paused session. If the session has gone IDLE - the receiver times out during long pauses - this instead re-issues a full load from the last known position.seek is only for in-session seeks; the web client seeks via castLoad, which is a full reload.captions silently does nothing when the receiver has no media session yet. The track belongs on the castLoad in that case.volume sets the receiver's own volume, the one its remote and Google Home move. The receiver reports the level back on its own schedule, so the confirmed value arrives as volume on castEvents rather than in this reply.| Code | Kind | When |
|---|---|---|
0 | Subsonic | No cast session is active, or the caller does not own it. |
A non-blocking snapshot of the current session, for restoring the UI after a page reload.
| Parameter | Type | Description |
|---|---|---|
castController | string | The id given to startCast. Without a match this caller is treated as having no session. |
| Field | Type | Description |
|---|---|---|
castSession.active | bool | Whether a session exists for this caller. The remaining fields are present only when it does. |
castSession.deviceId | string | The device being cast to, as listCastDevices reports it. |
castSession.deviceName | string | Its friendly name. |
castSession.deviceModel | string | The model string from the device's own mDNS announcement ("WiiM Amp Ultra"),
empty for a manually configured device. It is what tells a client whether
castWiimEq applies, and it is repeated here because a page restoring a
session has no device list to look the id up in.
|
castSession.songId | string | What is loaded. |
castSession.startOffset | float | Add this to the receiver's reported currentTime to get the absolute position in the track. Zero under native seek, which is every codec today. |
castSession.playerState | string | As the receiver reports it: PLAYING, PAUSED, BUFFERING, IDLE. |
castSession.currentTime | float | The receiver's reported position. |
castSession.duration | float | Duration as the receiver understands it. |
castSession.songDuration | float | Duration as the library has it, which is the one to trust for a progress bar. |
castSession.trackId | int | The subtitle track currently showing, or 0. |
castSession.notice | string | Something a person should be told about the last load, or empty. It exists for the failures the receiver never reports, because it was never told: a television that had not finished starting up when the LOAD was due, or one that could not be reached at all. Cleared when a new load begins. |
castSession.noticeSeq | int | Changes whenever notice is a new one. Show a notice when this differs from the last value seen and the text is non-empty; a client restoring a session after a reload should adopt the value without acting on it, since the notice describes a load it was not there for. |
castSession.audioOnly | bool | The same answer castLoad returned for the load now playing. Present here because a client that has just reloaded has no castLoad reply to have read it from. |
castSession.receiverShowsVideo | bool | The same answer castLoad returned for the load now playing. Whether a picture is appearing on the receiver, which is not the negation of audioOnly - see there. |
castSession.contentType | string | The contentType declared in the Cast LOAD, which is what the receiver picks its decode pipeline from. Not the source container: everything the remux or re-encode tier touches is announced as video/mp4, and a VP9/Opus .mkv is announced as video/webm although its bytes go out untouched. |
castSession.sentSuffix | string | The container the receiver actually receives. The source extension on the direct tier, mp4 on the other two. A soundtrack reports whatever it was put in: m4a, mp3, flac, opus or ogg when the track was copied, and flac when it had to be encoded. |
castSession.sentBitRate | int | Kbps, or 0 when the stream is not a fixed-rate encode. An audio cast is the raw
file byte-ranged - no format, no maxBitRate, and the account ceiling is
exempt for a cast token - so this is the file's own bitrate rather than anything
the transcoded* fields describe. A video remux keeps the source's rate and a
re-encode is not fixed-rate, so both report 0. So does a copied soundtrack - the
track's own rate is not the container's, and nearly right is the worst thing a
diagnostic can be - and so does an encoded one, FLAC having no target rate at
all.
|
castSession.tier | string | Which of the three delivery tiers this fetch lands on: direct (the file as it stands, byte-ranged), remux (-c copy through the transcode cache, still seekable), or encode (re-encoded). The same two words apply to a soundtrack, and there they are the answer to "why does this sound worse": remux is the track copied out of the film untouched, encode a lossless FLAC of it. A soundtrack is never re-encoded lossily - the source is already lossy, and this is the one route with no choice about transcoding. |
castSession.volume | object | The receiver's own volume as it last reported it: level (0..1), muted,
and fixed, true for a receiver whose volume nothing can move (its
controlType is fixed, typically a television driving an amplifier).
null until the first report arrives, so "not reported yet" and level zero
stay distinguishable; a client should keep its volume controls disabled on
null. The same object rides every castEvents payload.
|
Only the caller's own session. Another owner's is reported as active: false - otherwise a second browser would adopt it wholesale on page load and show itself as casting.
Server-sent events carrying the receiver's status as it changes.
Content type: text/event-stream
| Parameter | Type | Description |
|---|---|---|
castController | string | The id given to startCast. Without a match this caller is treated as having no session. |
| Code | Kind | When |
|---|---|---|
204 | HTTP | No cast session is active, or the caller does not own it. There is nothing to stream, so no event stream is opened. |
An event is emitted whenever the receiver pushes a status change, and at least every 15 seconds otherwise. Each data: payload is a JSON object with playerState, currentTime, duration, idleReason, startOffset, notice and noticeSeq, plus the same stream description castLoad and castSession carry (audioOnly, receiverShowsVideo, contentType, sentSuffix, sentBitRate and tier) and the same volume object castSession describes, null until the receiver has reported one. A volume change on the device itself arrives this way too.
notice and noticeSeq are as castSession describes them: a sentence about a load the receiver was never told about, and a number that changes when there is a new one. A client should show a notice once, when the number changes and the text is non-empty - an unchanged status is republished every 15 seconds, so acting on the text alone would repeat it for as long as the session lasted.
Those six are repeated here rather than left to the castLoad reply because that reply describes an attempt. A receiver that refuses a LOAD is sent a degraded one instead - a film it cannot decode becomes its soundtrack - so the description a client read when it issued the load can already be describing something that is not playing. Reading them from each event is what keeps a client from having to reload the page to find out.
The SSE connection doubles as the liveness signal for the cast session. Once the last listener disconnects, casting is torn down automatically after 30 seconds - so a client that wants the session to survive must keep this open.
The stream also ends when another client takes the session over. A client should treat that as having left cast mode rather than as a transport error - the retry is answered 204, so EventSource reaches CLOSED and stops.
See also castSession.
A relay to the session device's own equalizer, for WiiM receivers.
| Parameter | Type | Description |
|---|---|---|
action * | string | What to do. one of state, presets, load, on, off, setBands. |
name | string | The preset, for action=load, spelled as presets listed it. |
bands | string | For action=setBands: exactly ten comma-separated integers 0..99, in band order 31 Hz to 16 kHz. |
castController | string | The id given to startCast. Without a match this caller is treated as having no session. |
action
state reads the whole state; presets lists the device's preset names, including ones its owner created on it.load loads the named device preset and switches the equalizer on.on / off move the switch alone.setBands writes the whole curve, always all ten bands.load, on, off and setBands answer with the same fields as state, freshly re-read; presets answers with presets alone.
| Field | Type | Description |
|---|---|---|
wiimEq.on | bool | Whether the equalizer is engaged. Independent of preset: the device keeps reporting the loaded preset while switched off. |
wiimEq.preset | string | The preset name the device reports, possibly stale after setBands; see the notes. |
wiimEq.bands | array | The ten fader positions, 0..99, or absent when the device did not report a usable set. |
wiimEq.presets | array | For action=presets: the preset names, as the device lists them. |
| Code | Kind | When |
|---|---|---|
0 | Subsonic | No cast session is active, the caller does not own it, or the device did not answer. |
10 | Subsonic | name missing for load, or bands missing or malformed for setBands. |
A WiiM is a Chromecast receiver and a LinkPlay device on one address, and its ten-band equalizer is reachable only over the latter's private HTTP API, which a browser cannot speak to (a self-signed certificate, and no CORS). So the server relays, and only to the device of the caller's own active cast session: the address is never a parameter.
Whether a device answers this is inferred from its model string containing wiim (case-insensitive); listCastDevices and castSession.deviceModel both carry it. A manually configured device has no model string and is assumed not to.
Bands are the device's fixed ten, 31 Hz to 16 kHz, each 0..99 with 50 flat. How that maps to decibels is undocumented, so a client should show offsets from flat rather than dB figures. bands may be absent from a reply: the device answered but did not report a usable band array (all ten or nothing), and a client should degrade to the on/off switch and the preset list.
Every mutating action re-reads the device state afterwards and answers with what the device said, not what was asked. The reported preset name goes stale after setBands (the device keeps naming the last load), so a client that wrote the curve itself should keep its own bookkeeping for the name.
See also listCastDevices, castSession.
Everything above is what GainDrive adds to Subsonic. This last section is the other side of the same question: the Subsonic and OpenSubsonic endpoints GainDrive does not answer. They are listed so that a client author can tell a deliberate gap from a server too old for a feature, and so that nobody has to discover one by calling it.
A GET for any of them reaches a catch-all and is answered HTTP 200 carrying Subsonic error 0, Not implemented. - always as XML, whatever f asked for. A POST matches no route at all and gets a bare 404 with no body, which behind a reverse proxy configured to serve a single-page client on 404 arrives as 200 and a page of HTML.
Retrieval and annotation.
getLyrics, and getLyricsBySongId of the OpenSubsonic songLyrics extension. No lyric tag and no .lrc sidecar is read.getAvatar. An account carries no picture.The play queue.
getPlayQueueByIndex and savePlayQueueByIndex of the OpenSubsonic indexBasedQueue extension. The by-id pair is implemented; the index pair exists so a queue may hold the same track twice, which the by-id form cannot express.Whole subsystems. Each of these is a feature rather than an endpoint, and the roles GainDrive already reports say so: shareRole, podcastRole, jukeboxRole and commentRole are false on every account, so a client that reads them need not probe.
jukeboxControl, which plays through the sound card of the machine running the server. Casting is GainDrive's answer to playing somewhere other than the client.Users and system.
deleteUser, the only one of the six user endpoints missing. Removing an account means deciding what becomes of its stars, playlists, queue, bookmarks and uploaded files, every one of which is keyed on it.tokenInfo of the OpenSubsonic apiKeyAuthentication extension. GainDrive issues no API keys; u and p, or the token scheme, are the whole of authentication.Extensions GainDrive could advertise and does not. getOpenSubsonicExtensions names gaindrive and transcodeOffset. topSongsByArtistId is one parameter away, but getTopSongs answers an empty list here, so advertising it would promise data that does not exist. formPost is not supported - every standard endpoint is GET.
Finally, there are some endpoints which GainDrive intentionally does not and will not implement. Apart from the ones listed here, these also include (some of the) deprecated endpoints of the OpenSubsonic specification.
Browsing, lists and similarity.
getRandomSongs.getSimilarSongs and getSimilarSongs2, and findSonicPath and getSonicSimilarTracks of the OpenSubsonic sonicSimilarity extension. Nothing here fetches or computes similarity.Retrieval and annotation.
getAvatar. An account carries no picture.setRating. There is no rating column: star and unstar are the whole annotation surface, and no entry carries userRating or averageRating.getTranscodeDecision and getTranscodeStream of the OpenSubsonic transcoding extension. GainDrive decides what to transcode itself, from the request and the codecs recorded at scan time, so there is nothing here that would read a client's capability payload.Whole subsystems.
getShares, createShare, updateShare and deleteShare.getPodcasts, getNewestPodcasts, refreshPodcasts, createPodcastChannel, deletePodcastChannel, deletePodcastEpisode, downloadPodcastEpisode, and getPodcastEpisode of the OpenSubsonic extension of the same name. Fetching a URL into personal files is the nearest thing GainDrive has, and it is not a subscription.getInternetRadioStations, createInternetRadioStation, updateInternetRadioStation and deleteInternetRadioStation. A station is not a library file, so it would need storage of its own.getChatMessages and addChatMessage.