remote api documentation

casey langen edited this page Jan 27, 2018 · 51 revisions

intro

while musikcube is a rather complete frontend for musikcore, it only appeals to a very niche audience. on the desktop most users prefer point-and-click, drag-and-drop user interfaces. also in recent years, there has been a significant paradigm shift from thick desktop clients to thin web clients and native mobile apps.

the musikcore backend library is written in c++, making it somewhat difficult to integrate with other programming languages and frameworks. it does, however, have an extensible plugin architecture. this plugin architecture was utilized to expose a small language-agnostic, platform-agnostic, "connected" api layer that uses websockets for realtime, bidirectional client/server communication, and http for streaming audio data.

goal

the purpose of this api is to be something small and maintainable that serves the 95% use case. it's not meant to be an end-all-be-all solution that exposes every single bit of functionality musikcore provides.

it's supposed to be something that's easy to tinker with, and can be used to build and prototype custom music players easily and quickly.

api stability

the api is new and subject to change over time. here be dragons.

examples

musikdroid is an android app that implements most of the functionality described in this document. the code can be found here.

security

it's important to understand that, out of the box, the remote api should NOT be considered safe for use outside of a local network. the websockets service only supports a simple password challenge, and the audio http server just handles Basic authorization. it does not provide ssl or tls.

the server also stores the password in plain text in a settings file on the local machine.

you can fix some of this using a reverse proxy to provide ssl termination. details in the ssl-server-setup section. while this improves things, you should exercise caution exposing these services over the internet.

websocket message format

messages sent between the websocket client and server are simple json structs with the following format:

{ 
    "name": "<name_of_message>",
    "id": "<unique_message_id>",
    "device_id": "<unique_device_id>", /* request/broadcast only */
    "type": "<request | response | broadcast>",
    "options" {
        /* map of arguments */
    }
}

note that there are three types of messages: requests, responses and broadcasts. if one end of the connection sends a request, the other side of the server must send a response with the originating message's request name and id. broadcast messages are fire-and-forget and should not be responded to.

the device_id property is currently optional, but highly recommended for all request and broadcast messages originating from a client. when present, the server can cache context-sensitive data on behalf of the client, including snapshots of the play queue and other information. if your device doesn't have a unique id, that's fine! just generate a guid and use that.

the rest of the message structure should be straight forward.

authentication

authenticating with the websocket and http servers are straight forward, although relatively insecure.

websocket authentication

immediately after connecting to the websocket server, the client must send an authenticate message as follows:

{
    "name": "authenticate",
    "type": "request",
    "id": "<unique_id>",
    "device_id": "<unique_device_id>",
    "options": {
        "password": "<password>"
    }
}

upon successful authentication the server will respond as follows:

{
    "name": "authenticate",
    "type": "response",
    "id": "<original_request_id>",
    "device_id": "<unique_device_id>",
    "options": {
        "authenticated": true
    }
}

if an incorrect password is supplied by the client, the server will terminate the connection immediately.

http authentication

authentication against the streaming http server uses basic http authentication. that means every request against the server requires a header like the following:

Authorization: Basic <credentials>, where <credentials> is the base64 encoded string username:password. in the case of the streaming audio server, the username is always default and the password is the same password used in the websocket service.

for example, if the password is set to mypassword, <credentials> will be the base64 encoded value of default:mypassword, which is ZGVmYXVsdDpteXBhc3N3b3Jk. so the entire auth header will be:

Authorization: Basic ZGVmYXVsdDpteXBhc3N3b3Jk

if the auth header is not present, or the username or password are incorrect, the server will respond with an http 401 (unauthorized).

websocket messages

query optimization

it's not uncommon for users to have very large collections of music -- in some cases 100,000+ tracks. therefore, certain optimizations exist in a subset of queries that can be used to limit and page through the amount of data returned. queries that may return large amounts of data all follow a common pattern and accept the same optional inputs that should be used to optimize performance.

the optimization is a two-step process, as follows:

  1. run the query first with "count_only" : true specified as an option. the backend will run the query, but only return the number of results (but not the results themselves). clients should use this information to estimate the dimensions of their views.

  2. run the query again without count_only, and this time specify an offset and a limit to only retrieve metadata for the information currently in view -- plus maybe a couple pages before and after.

  3. as the user continues to scroll through the list, request subsequent sets of data using offset and limit.

resource types

this section describes a few common resource types that are used across multiple messages.

track

{
    "id": <int64>,
    "external_id": "<external id>",
    "title": "<title>",
    "track_num": <int32>,
    "album": "<album name>",
    "album_id": <int64>,
    "album_artist": "<album artist name>",
    "album_artist_id": <int64>,
    "artist": "<artist name>",
    "artist_id": <int64>,
    "genre": "<genre name>",
    "genre_id": <int64>
}

album

{
    "id": <int64>,
    "title": "<album title>",
    "album_artist": "<album artist name>",
    "album_artist_id": <int64>
}

category value

{
    "id": <int64>,
    "value": "<category (artist/genre/etc) value>",
}

playback overview

{
    "state": "<stopped | playing | paused>",
    "repeat_mode": "<none | track | list>",
    "volume": <0.0 to 1.0>,
    "shuffled": <true | false>,
    "muted": <true | false>,
    "play_queue_count": 10, /* total */
    "play_queue_position": 2, /* current */
    "playing_duration": 300.0, /* seconds */
    "playing_current_time": 10.0, /* seconds */
    "playing_track": {
        /* track resource */
    }   
}

broadcasts (server to client)

play_queue_changed

broadcasted whenever the play queue has changed (new list enqueued, rearranged, etc)

{
    "name": "play_queue_changed",
    "type": "broadcast",
    "id": "<unique_id>",
    "options": { }
}

playback_overview_changed

sent from the server to the client whenever the playback state changes.

{
    "name": "get_playback_overview",
    "type": "broadcast",
    "id": "<unique_id>",
    "options": { 
        /* playback overview resource */     
    }
}

broadcasts (client to server)

none!

requests (server to client)

none!

requests (client to server)

note: many responses are a generic success/failure, which have following format:

{
    "name": "<name_from_request>",
    "type: "response",
    "id": "<id_from_request>",
    "options" {
        "success": <true | false>
    }
}

get_playback_overview

returns a playback overview, suitable for updating transport controls on the client to match server playback state:

request:

{
    "name": "get_playback_overview",
    "type": "request",
    "id": "<unique_id>",
    "device_id": "<unique_device_id>",
    "options": { }
}

response:

{
    "name": "get_playback_overview",
    "type": "response",
    "id": "<request_id>",
    "options": { 
        /* playback overview resource */       
    }
}

general playback control

remote controlling playback is easy. most messages follow the same basic request/response format, so they have been consolidated into this section.

request:

{
    "name": "<pause_or_resume | stop | previous | next | toggle_shuffle | toggle_repeat | toggle_mute>",
    "type": "request",
    "id": "<unique_id>",
    "device_id": "<unique_device_id>",
    "options": { }
}

response: generic success/failure response.

important: all of these messages will implicitly trigger a playback_overview_changed broadcast from the server to the client immediately after internal state has been processed and updated.

set_volume (absolute)

set the playback volume to the specified value

request:

{
    "name": "set_volume",
    "type": "request",
    "id": "<unique_id>",
    "device_id": "<unique_device_id>",
    "options": { 
        "volume": <0.0 to 1.0>
    }
}

response: generic success/failure response.

set_volume (relative)

set the volume relative to the current volume

{
    "name": "set_volume",
    "type": "request",
    "id": "<unique_id>",
    "device_id": "<unique_device_id>",
    "options": { 
        "relative":" <up | down | -1.0 to 1.0>"
    }
}

if relative is up, it will increase the volume by one unit (matching whatever the server does by default). if set to down it will decrease by one unit. if a floating point number is specified it will be treated as a delta and applied to the current volume. negative deltas are allowed.

response: generic success/failure response.

seek_to

seek to an absolute position in the current track

request:

{
    "name": "seek_to",
    "type": "request",
    "id": "<unique_id>",
    "device_id": "<unique_device_id>",
    "options": {
        "position": 10.0 /* in seconds */
    }
}

response: generic success/failure response.

seek_relative

seek to a position relative to the current position (i.e. fast-forward or rewind)

request:

{
    "name": "seek_relative",
    "type": "request",
    "id": "<unique_id>",
    "device_id": "<unique_device_id>",
    "options": {
        "position": 10.0 /* in seconds. can be negative for rewind */
    }
}

response: generic success/failure response.

play_at_index

play the track at the specified index in the current play queue

{
    "name": "play_at_index",
    "type": "request",
    "id": "<unique_id>",
    "device_id": "<unique_device_id>",
    "options": {
        "index": <int32>, /* optional */
        "time": <double> /* in seconds. optional */
    }
}

response: generic success/failure response.

play_all_tracks

replace the current play queue with all tracks (filtered by optional keywords), and start play back at the specified index (defaults to the first track)

{
    "name": "play_all_tracks",
    "type": "request",
    "id": "<unique_id>",
    "device_id": "<unique_device_id>",
    "options": {
        "index": <int32>, /* optional */
        "time": <double>, /* in seconds. optional */
        "filter": "<filter>" /* optional */
    }
}

response: generic success/failure response.

play_tracks

replaces the current play queue with the specified array of tracks, and starts playback at the specified index (defaults to the first track)

{
    "name": "play_tracks",
    "type": "request",
    "id": "<unique_id>",
    "device_id": "<unique_device_id>",
    "options": {
        "ids": [ /* array of int64 ids */],
        "index": <int32>, /* optional */
        "time": <double> /* in seconds. optional */
    }
}

response: generic success/failure response.

play_tracks_by_category

play all tracks for the specified category (e.g. all tracks by artist "foo" or all tracks with genre "bar")

{
    "name": "play_tracks_by_category",
    "type": "request",
    "id": "<unique_id>",
    "device_id": "<unique_device_id>",
    "options": {
        "category": "<album | artist | album_artist | genre | playlist>",
        "id": <int64>, /* id for the selected category */
        "filter": "<filter_string>" /* optional */
    }
}

response: generic success/failure response.

query_tracks

query all tracks, optionally filtered by a string with an offset and a limit.

{
    "name": "query_tracks",
    "type": "request",
    "id": "<unique_id>",
    "device_id": "<unique_device_id>",
    "options": {
        "filter": "<filter string>", /* optional */
        "count_only": <true | false>, /* optional; default false */
        "limit": <integer>, /* optional */
        "offset": <integer> /* optional */
    }
}

response for count_only query:

{
    "name": "query_tracks",
    "type": "response",
    "id": "<request_id>",
    "options": {
        "data": [ ], /* empty array */
        "count": <integer>
    }
}

response for limit and offset query:

{
    "name": "query_tracks",
    "type": "response",
    "id": "<request_id>",
    "options": {
        "count": <integer>,
        "limit": <request_limit>,
        "offset": <request_offset>,
        "data": [
            /* array of track resources */
        ]
    }
}

note: see the query optimization section for more information.

query_tracks_by_external_ids

get metadata for all tracks with the specified external_ids

{
    "name": "query_tracks_by_external_ids",
    "type": "request",
    "id": "<unique_id>",
    "device_id": "<unique_device_id>",
    "options": {
        "external_ids": [<external_ids>]
    }
}

response:

{
    "name": "query_tracks_by_external_ids",
    "type": "response",
    "id": "<request_id>",
    "options": {
        "data": {
            { "<external_id>": { <track_resource> },
            ...
        }
    }
}

note: returned tracks will not be in order; rather, they will be in an object that maps the external_id to the track resource object.

query_tracks_by_category

query all tracks for the specified category (e.g. all tracks by artist "foo" or all tracks with genre "bar")

{
    "name": "query_tracks_by_category",
    "type": "request",
    "id": "<unique_id>",
    "device_id": "<unique_device_id>",
    "options": {
        "category": "<album | artist | album_artist | genre | playlist>"
        "id": <int64>, /* id for the selected category */
        "filter": "<filter string>", /* optional */
        "count_only": <true | false>, /* optional; default false */
        "limit": <integer>, /* optional */
        "offset": <integer>, /* optional */
        "predicates": [ /* optional */
            {
                "category": "<album | artist | album_artist | genre | etc>",
                "id": <int64>
            },
            ...
        ]
    }
}

response for count_only query:

{
    "name": "query_tracks_by_category",
    "type": "response",
    "id": "<request_id>",
    "options": {
        "data": [ ], /* empty array */
        "count": <integer>
    }
}

response for limit and offset query:

{
    "name": "query_tracks_by_category",
    "type": "response",
    "id": "<request_id>",
    "device_id": "<unique_device_id>",
    "options": {
        "count": <integer>,
        "limit": <request_limit>,
        "offset": <request_offset>,
        "data": [
            /* array of track resources */
        ]
    }
}

note 1: see the query optimization section for more information.

note 2: the results of the query can be further filtered by a list of predicates that contain category type and corresponding category id. currently, all specified predicates will be joined via AND. for example: you can get all albums with genre=foo AND year=bar. note that predicates are not supported for playlist requests.

list_categories

used to retrieve a list of all metadata categories that may be used for subsequent queries

request:

{
    "name": "list_categories",
    "type": "request",
    "id": "<unique_id>",
    "device_id": "<unique_device_id>",
    "options": { /* none */ }
}

response:

{
    "name": "list_categories",
    "type": "response",
    "id": "<request_id>",
    "options": {
        "data": ["artist", "album", ...]
    }
}

query_category

retrieve a list of albums/artists/tracks/genres/playlists

note 1: this query does not currently support limit and offset, but likely will in the future.

note 2: the results of the query can be further filtered by a list of predicates that contain category type and corresponding category id. currently, all specified predicates will be joined via AND. for example: you can get all albums with genre=foo AND year=bar. note that predicates are not supported for playlist requests.

request:

{
    "name": "query_category",
    "type": "request",
    "id": "<unique_id>",
    "device_id": "<unique_device_id>",
    "options": {
        "category": "<album | artist | album_artist | genre | playlist>",
        "filter": "<filter string>", /* optional */
        "predicates": [ /* optional */
            {
                "category": "<album | artist | album_artist | genre | etc>",
                "id": <int64>
            },
            ...
        ]
    }
}

response:

{
    "name": "query_category",
    "type": "response",
    "id": "<request_id>",
    "options": {
        "category": "<album | artist | album_artist | genre | playlist>"
        "data": [ /* array of category value resources */ ]
    }
}

query_albums

very similar to query_category, but returns album resources with additional metadata. it can also be used to retrieve all albums for a specified artist or genre.

note: in the future this method will likely be removed/deprecated, and query_category will just return album resources if the user asks for albums.

request:

{
    "name": "query_albums",
    "type": "request",
    "id": "<unique_id>",
    "device_id": "<unique_device_id>",
    "options": {
        "category": "<artist | album_artist | genre>", /* optional */
        "category_id", <int64>, /* optional */
        "filter": "<filter string>" /* optional */
    }
}

response:

{
    "name": "query_albums",
    "type": "response",
    "id": "<request_id>",
    "options": {
        "category": "album",
        "data": [ /* array of album resources */ ]
    }
}

query_play_queue_tracks

query tracks from the current play queue.

the caller can request "live" data (what's currently in the play queue), or "snapshot" data, which was a snapshot of the play queue at some point in the past. snapshots can be taken using the snapshot_play_queue message.

{
    "name": "query_play_queue_tracks",
    "type": "request",
    "id": "<unique_id>",
    "device_id": "<unique_device_id>",
    "options": {
        "count_only": <true | false>, /* optional; default false */
        "type": "<live | snapshot>", /* optional; default "live" */
        "limit": <integer>, /* optional */
        "offset": <integer> /* optional */
    }
}

response for count_only query:

{
    "name": "query_play_queue_tracks",
    "type": "response",
    "id": "<request_id>",
    "options": {
        "data": [ ], /* empty array */
        "count": <integer>
    }
}

response for limit and offset query:

{
    "name": "query_play_queue_tracks",
    "type": "response",
    "id": "<request_id>",
    "options": {
        "count": <integer>,
        "limit": <request_limit>,
        "offset": <request_offset>,
        "data": [
            /* array of track resources */
        ]
    }
}

rename_playlist

{
    "name": "rename_playlist",
    "type": "request",
    "id": "<unique_id>",
    "device_id": "<unique_device_id>",
    "options": {
        "playlist_id": <int64>, 
        "playlist_name": "<new_playlist_name>"
    }
}

response: generic success/failure response.

delete_playlist

{
    "name": "delete_playlist",
    "type": "request",
    "id": "<unique_id>",
    "device_id": "<unique_device_id>",
    "options": {
        "playlist_id": <int64>
    }
}

response: generic success/failure response.

save_playlist

playlists can be saved with either an list of track external_ids, or by using a query_tracks_by_category subquery (e.g. albums by "foo").

using external_ids:

{
    "name": "save_playlist",
    "type": "request",
    "id": "<unique_id>",
    "device_id": "<unique_device_id>",
    "options": {
        "playlist_id": <int64>, /* optional. if specified, overrides this 
                                playlist. otherwise, a new playlist will be 
                                created */
        "playlist_name": "<new_playlist_name>",
        "external_ids": [ <list_of_external_ids> ]
    }
}

using a query_tracks_by_category subquery:

{
    "name": "save_playlist",
    "type": "request",
    "id": "<unique_id>",
    "device_id": "<unique_device_id>",
    "options": {
        "playlist_id": <int64>, /* optional. if specified, overrides this 
                                playlist. otherwise, a new playlist will be 
                                created */
        "playlist_name": "<new_playlist_name>",
        "subquery": {
           /* options from query_tracks_by_category. see documentation 
           for this request */
        }
    }
}

response:

{
    "name": "save_playlist",
    "type": "response",
    "id": "<request_id>",
    "options": {
        "playlist_id": <int64> /* id of the playlist created/updated */
    }
}

append_to_playlist

this query is used to add one or more tracks to the specified playlist, at the optionally specified offset. tracks can either be added explicitly with an array of external_ids, or by using a query_tracks_by_category, similar to save_playlist:

using external_ids:

{
    "name": "append_to_playlist",
    "type": "request",
    "id": "<unique_id>",
    "device_id": "<unique_device_id>",
    "options": {
        "playlist_id": <int64>,
        "offset": <integer>, /* optional */
        "external_ids": [ <list_of_external_ids> ]
    }
}

using a query_tracks_by_category subquery:

{
    "name": "append_to_playlist",
    "type": "request",
    "id": "<unique_id>",
    "device_id": "<unique_device_id>",
    "options": {
        "playlist_id": <int64>,
        "offset": "<integer>", /* optional */
        "subquery": {
           /* options from query_tracks_by_category. see documentation 
           for this request */
        }
    }
}

response: generic success/failure response.

remove_tracks_from_playlist

use this to remove tracks from a playlist.

the input to this query is two arrays:

  1. a list of external ids
  2. the corresponding indices of these external ids in the playlist

this complexity exists because it's completely possible (and actually quite common) for the same song to exist in a playlist multiple times. if indices are not specified, it would not be possible for the server to figure out which to remove.

{
    "name": "remove_tracks_from_playlist",
    "type": "request",
    "id": "<unique_id>",
    "device_id": "<unique_device_id>",
    "options": {
        "playlist_id": <int64>
        "external_ids": [ <list_of_external_ids> ],
        "sort_orders": [ <list_of_indices> ] /* 0-based offsets */
    }
}

response:

{
    "name": "remove_tracks_from_playlist",
    "type": "response",
    "id": "<request_id>",
    "options": {
        "count": <integer> /* number of elements removed */
    }
}

run_indexer (0.36.0+)

used to remotely start a rescan of the user's metadata. the caller may choose between reindex (which will only scan files that have been updated since the last scan), or rebuild, which will rescan all files regardless of update time.

request:

{
    "name": "run_indexer",
    "type": "request",
    "id": "<unique_id>",
    "device_id": "<unique_device_id>",
    "options": { 
        "type": "<reindex | rebuild>"
    }
}

response: generic success/failure response.

list_output_drivers (0.36.0+)

returns a list of available output drivers and their respective devices. the response also includes the currently selected driver and device.

request:

{
    "name": "list_output_drivers",
    "type": "request",
    "id": "<unique_id>",
    "device_id": "<unique_device_id>",
    "options": { /* none */ }
}

response:

{
    "name": "list_output_drivers",
    "type": "response",
    "id": "<request_id>",
    "options": {
        "selected": {
            "driver_name": "<string>",
            "device_id": "<string>"
        },
        "all": [
            {
                "driver_name": "<string>",
                "devices": [
                   {
                       "device_name": "<string>",
                       "device_id": "<string>"
                   },
                   ...
                ],
                ...
            },
            ...
        ]
    }
}

set_default_output_driver (0.36.0+)

used to set the playback system's default driver and device. this call will automatically re-route playback to the newly specified device, immediately.

request:

{
    "name": "set_default_output_driver",
    "type": "request",
    "id": "<unique_id>",
    "device_id": "<unique_device_id>",
    "options": {
        "driver_name": "<string>",
        "device_id": "<string>" /* optional. */
    }
}

response: generic success/failure response.

get_gain_settings (0.36.0+)

retrieves preamp and replaygain settings

request:

{
    "name": "get_gain_settings",
    "type": "request",
    "id": "<unique_id>",
    "device_id": "<unique_device_id>",
    "options": { /* none */ }
}

response:

{
    "name": "get_gain_settings",
    "type": "response",
    "id": "<request_id>",
    "options": {
        "replaygain_mode": "<disabled | album | track>",
        "preamp_gain": <float32 -20.0 to 20.0>
    }
}

set_gain_settings (0.36.0+)

request:

{
    "name": "update_gain_settings",
    "type": "request",
    "id": "<unique_id>",
    "device_id": "<unique_device_id>",
    "options": {
        "replaygain_mode": "<disabled | album | track>",
        "preamp_gain": <float32 -20.0 to 20.0>
    }
}

response: generic success/failure response.

get_transport_type (0.36.0+)

returns the currently selected transport type ("gapless" or "crossfade")

request:

{
    "name": "get_transport_type",
    "type": "request",
    "id": "<unique_id>",
    "device_id": "<unique_device_id>",
    "options": { /* none */ }
}

response:

{
    "name": "get_transport_type",
    "type": "response",
    "id": "<request_id>",
    "options": {
        "type": "<gapless| crossfade>"
    }
}

set_transport_type (0.36.0+)

updates the currently selected transport type ("gapless" or "crossfade")

request:

{
    "name": "set_transport_type",
    "type": "request",
    "id": "<unique_id>",
    "device_id": "<unique_device_id>",
    "options": {
        "type": "<gapless| crossfade>"
    }
}

response: generic success/failure response.

snapshot_play_queue (0.36.0+)

takes a snapshot of the current play queue, and associates it with the specified device_id. callers may then call query_play_queue_tracks and specify "snapshot" in the type field to query this data.

snapshots are considered stale if not accessed for 6 hours, and will be automatically purged by the server.

request:

{
    "name": "snapshot_play_queue",
    "type": "request",
    "id": "<unique_id>",
    "device_id": "<unique_device_id>",
    "options": { /* none */ }
}

response: generic success/failure response.

invalidate_play_queue_snapshot (0.36.0+)

as described in the snapshot_play_queue documentation, snapshots not accessed for 6 hours are considered invalid and purged automatically. however, a well-behaved client can send an invalidate_play_queue_snapshot message as soon as it knows the snapshot is invalid, and the associated resources will be freed immediately.

request:

{
    "name": "invalidate_play_queue_snapshot",
    "type": "request",
    "id": "<unique_id>",
    "device_id": "<unique_device_id>",
    "options": { /* none */ }
}

response: generic success/failure response.

play_snapshot_tracks (0.36.0+)

replaces the play queue with the snapshot for the specified device_id, and starts playback at the specified index and time.

request:

{
    "name": "play_snapshot_tracks ",
    "type": "request",
    "id": "<unique_id>",
    "device_id": "<unique_device_id>",
    "options": {
        "index": <int32>, /* optional */
        "time": <double> /* in seconds. optional */
    }
}

response: generic success/failure response.

http urls

audio data

if you're writing a streaming audio client (not just a playback remote), you can request audio data from the server. there are two ways to request audio data:

  1. source audio data: this will stream the file as-is from your library. that is, a flac file will be sent as flac, an mp3 file will be sent as mp3, ogg as ogg, etc. no transformation or downsampling will be performed.
  2. downsampled audio data: source audio will be transcoded, on demand, to mp3 with the specified bitrate.

important: the first time an transcoded audio file is requested, it is downsampled in real-time, therefore cannot be seeked. that means that any requests with Range headers will be rejected with http 416. as soon as the initial transcode has completed, the result will be cached to disk, and subsequent requests can be seeked.

the format of the url is as follows:

http://host:port/audio/external_id/<external_id>?bitrate=xyz

note 1: the value for the track's external_id can be obtained in the websocket metadata queries described above.

note 2: the transcoder will only run if the bitrate parameter is present and valid. otherwise, no downsampling will be performed, and the requested file will be returned without transformation.

note 3: make sure you url encode the <external_id> in the path! library plugins can format external ids however they wish, and may include characters that need to be encoded!

album artwork

similar to the audio data requests, album artwork can be obtained using the following url:

http://host:port/thumbnail/<thumbnail_id>

the thumbnail_id value can be found in both album and track resources.

You can’t perform that action at this time.
You signed in with another tab or window. Reload to refresh your session. You signed out in another tab or window. Reload to refresh your session.
Press h to open a hovercard with more details.