Skip to content
Documentation
Project

Server — App and API

API contract

Detailed Aède reference: api contract.

For a new or adapted reader, start with the Compatible Aède client specification: it defines catalog, native-player and adapter profiles, requirements and acceptance checks. This API reference defines their wire contract.

The separate Subsonic/OpenSubsonic adapter uses /rest, revocable API keys and XML/JSON protocol envelopes. It adds ID3 browsing, original audio, sidecar artwork and private favourites, ratings, ordered playlists and client-declared listens without changing the native v1 contract below. Public extension discovery is the only anonymous adapter method; remote access retains the HTTPS/Host/Origin boundary. The guide includes a first macOS test with Supersonic.

This is the frozen client contract for the read-only catalog API and its additive account/personal interfaces. A divergence between this document and the implementation is a bug. It is independent of the on-disk format_version values. The prefix /api/v1 freezes the existing field names, types and meanings; compatible additions may add optional fields or new endpoints. Removing or reinterpreting a field, changing a required field's type, or changing query semantics requires a new prefix. Clients must ignore unknown response fields and use error.code, not error.message, for decisions.

Start it with aede serve [--port N] after aede scan <folder>. The default address is 127.0.0.1:8787; --port 0 asks the system for a free local port and prints it. The process reads the same JSON catalog as the CLI. Starting without a catalog fails. It never changes audio files or tags.

The server README is the complete route and parameter reference, including the additive CLI-shaped navigation and administrative jobs described below. Its documented new response shapes are part of this v1 contract. Restart the server after upgrading its executable to use newly added routes.

The additive parental contract restricts authenticated account visibility/listening without changing the shared physical catalogue.

Access boundary#

All /api/v1 endpoints, including /status and the WebSocket, are read-only. Without accounts they are anonymous and can expose music names, paths, comments and attributed origins to other local processes/users. With accounts, catalog reads require a session or administrative token; /api/me/v1 isolates personal reads/writes to the authenticated owner. An auditor session may use only its own GET/HEAD personal routes, while account administration requires an administrator. Personal annotations and external service credentials are never included in catalog responses. Once a running process has observed an account store, missing or unreadable credentials fail closed until that process stops. A fresh local HTTP process with no account store starts in the anonymous compatibility mode; HTTPS always requires initialized accounts. An unreadable store fails closed even on first access.

The default HTTP server binds to loopback. An explicit HTTPS listener requires accounts, a certificate/key and one configured public authority. On HTTPS, catalog and personal requests require a session, legacy administrative tokens are disabled and /api/admin requires a current named administrator session. No automatic forwarding, cross-origin permission or browser login interface is implemented. Transitional administrative personal routes retain local on local HTTP.

Every HTTP request and WebSocket handshake must have one Host matching the listener's permitted authority. Default HTTP accepts 127.0.0.1 or localhost and the actual port (80 may be omitted); explicit loopback IPs use their actual address. HTTPS accepts only its configured HOST:PORT. Missing, duplicate, malformed or foreign authorities return 403 invalid_host or a parser error. Absolute-form targets use the transport's scheme and the same authority. Native clients can omit Origin; if supplied, exactly one matching origin using HTTP or HTTPS respectively is required. Foreign, null, malformed or duplicate origins return 403 invalid_origin. Legacy administrative-token requests additionally refuse even a matching browser origin.

Transport and representations#

The additive native desktop presentation routes expose attributed projections, local artwork, own annotation indexes/M3U and catalog-scoped media jobs. Artwork is binary JPEG/PNG; its errors keep the ordinary JSON envelope.

HTTP responses from successful API requests and application errors are UTF-8 JSON with Content-Type: application/json. GET is defined; ordinary JSON routes also answer HEAD with the same status and headers but no body. Unknown paths return JSON 404; other methods on known paths return JSON 405. The WebSocket handshake is the exception: HTTP upgrade failures are transport errors and do not promise the JSON error envelope. No write method is defined under /api/v1.

All field names use snake_case. Times named scanned_at are Unix seconds; durations are milliseconds; file sizes are bytes. Optional scalar values are null, not absent. Collections are arrays, including when empty. Names and titles preserve the spelling read from files. Relations between entities use stable reference tokens from EntityRef, never catalog vector indexes. A track reference is path-based and changes if the file moves. Clients must URL-encode a token when passing it as a query value.

The original /status and /library routes read snapshot metadata and counts in constant time and do not interpret query parameters. They remain outside the catalog inspection-worker budget; omit query parameters on both. Authentication and transport admission still apply. /library adds an opaque decimal-string catalog_revision for the current server process: compare it within a session to detect successful catalogue publications, including changes with identical scanned_at and counts. Reset the comparison after reconnecting; this marker is not a persistent graph identity or a cross-request snapshot guarantee. Failed reloads retain the preceding revision.

RequestResponse
GET /api/v1/status{ "status": "ok", "api_version": 1, "catalog_loaded": bool }, including while the catalog is absent after server startup.
GET /api/v1/library{ "catalog_revision": string, "scanned_at": u64, "files": usize, "artists": usize, "releases": usize, "recordings": usize, "tracks": usize }.
GET /api/v1/artistsPage of artist summaries.
GET /api/v1/releasesPage of release summaries.
GET /api/v1/tracksPage of track summaries.
GET /api/v1/recordingsPage of recording summaries.
GET /api/v1/entities?ref=<token>One entity detail, selected by a stable token.
GET /api/v1/lyrics?track=<token>Complete local lyrics with optional line timestamps, or lyrics: null.
GET /api/v1/eventsWebSocket catalog notifications, unchanged from the frozen v1 contract.
GET /api/v1/activityWebSocket catalog and task activity notifications.

Every list response has { "items": [...], "total": usize, "offset": usize, "limit": usize, "scanned_at": u64 }. total counts rows after search and filters, before pagination. items is sliced after sorting. offset and limit echo the effective values. A page beyond the end has items: [] and the filtered total. All fields in the following summary and detail tables are required in their respective shape, even when a value is null or an array is empty.

SummaryFields
Artistreference: string, name: string, sort_name: string, mbid: string|null, aliases: string[].
Releasereference: string, title: string, year: u32|null, album_artist: reference|null, track_count: usize, cover_path: string|null.
Trackreference: string, title: string, release: reference|null, recording: reference|null, duration_ms: u64|null.
Recordingreference: string, title: string, mbid: string|null, track_count: usize, work_count: usize.

/entities returns one object with kind and reference plus these fields. The recognized kinds are artist, release, track, recording, work, release_group, label, and genre.

KindAdditional fields
artistname, sort_name, mbid, aliases, releases: reference[].
releasetitle, year, album_artist: reference|null, tracks: reference[], release_group: reference|null, labels: reference[], cover_path: string|null.
tracktitle, release: reference|null, recording: reference|null, duration_ms: u64|null, path: string, size: u64; newer servers add analyses: object[].
recordingtitle, isrc: string|null, mbid: string|null, tracks: reference[], works: reference[].
worktitle, mbid: string, recordings: reference[].
release_grouptitle, mbid: string, releases: reference[].
labelname, mbid: string|null, releases: reference[].
genrename, releases: reference[], tracks: reference[].

The reference arrays in an entity detail are complete, not paginated. Clients that only need a listing should use a paginated list endpoint and its filters. A future paginated relation subresource can be added without changing these v1 fields.

Each track analysis contains source, source_version, imported_at, stale, the previously stored measurement fields, and source_data. source_data is the complete source file entry when retained, or null for older imports; the other measurement fields continue to describe those older imports. Analyses are attributed to their source and are not silently merged into local file facts. /api/v1/track and /api/v1/entities return the same track detail shape.

CLI-shaped additions#

/albums exposes albums with CLI-shaped artist/genre/label/name/year filters, while /releases keeps its original query meanings. Singular /album, /artist, /track, /recording, /work, /release-group, /genre and /label select exactly one entity by ref or name. Exact normalized matches take precedence over partial matches; ambiguity returns 409 ambiguous_entity with up to 200 {reference,name} candidates in error.candidates, never an arbitrary first result. These details use the entity fields above; /artist adds an origin object. /from returns that artist's reference, name and origin alone, explicitly known or unknown with an explanatory message and source attribution. Only already-stored, trusted MusicBrainz facts establish an origin.

/genres, /labels, /works, /release-groups, /countries, /years and /roles expose further paginated navigation. /doctor, /stats and /roots provide structured diagnostics and inspection; /search ranks names and optionally stored comments; /query evaluates the public subset of the CLI expression grammar. Owner-dependent predicates/sorts and lyrics queries are refused. None of these reads fetches external information. See the complete parameters and shapes.

Catalog lists and entity details, including the original /artists, /releases, /tracks, /recordings and /entities, share two bounded blocking-worker slots with navigation, inspection and personal-data operations. Catalog/navigation/inspection saturation returns 429 inspection_busy; personal operations return 503 personal_busy. Query expression parsing is bounded to 64 complexity units. HTTPS adds a separate bounded request-admission budget, offloads routed work from runtime workers and has a response deadline; target-device capacity remains a measured deployment concern. Doctor reads current catalog/conclusions and sources under the shared writer lock (409 store_busy when unavailable); other endpoints use the cached catalog and, when needed, the latest atomically saved source file. Corrupt sources return an error, not an unknown-origin answer. No multi-request or catalog/source snapshot is guaranteed for the latter reads.

Search, filters, sorting and pagination#

These parameters apply to the four list endpoints. Unknown parameters, duplicate parameters, an empty q, unsupported sort values and malformed references are errors. Search and filters combine with AND; each matching row appears once. Text comparison uses Aède's normalized matching (case and accent insensitive) and checks for a substring. Search only examines the fields below; it does not silently search unrelated tags or external source claims.

Catalog q, name and mbid values are limited to 2048 UTF-8 bytes after URL decoding and before normalization. Stable references, including /entities?ref=… and reference filters, are limited to 16384 UTF-8 bytes. Where a selector accepts either a name or a reference, names use the 2048-byte limit and recognized reference-kind prefixes use the 16384-byte limit; the reference must still have the kind required by that route. Oversized values return 400 invalid_query.

Listq searchesExact filtersAccepted sort
/artistsname and aliasesmbid=<string>catalog (default), name (by sort_name).
/releasestitleyear=<u32>, artist=<artist reference>catalog (default), title, year.
/trackstitlerelease=<release reference>catalog (default), title.
/recordingstitlework=<work reference>catalog (default), title.

order=asc|desc defaults to asc and applies to any accepted sort. catalog means the deterministic order saved by the scan. Text sorts compare normalized text, then catalog order to break ties. year places releases without a year last in both directions; equal years are ordered by normalized title, then catalog order. Filters using references require the expected kind. A syntactically valid but absent reference returns 404 entity_not_found; a malformed or wrong-kind reference returns 400 invalid_query. An exact mbid filter is case-sensitive.

The artist filter on releases matches the album artist, not every credited artist. The release filter on tracks matches their containing release. The work filter on recordings matches an explicitly identified work. year is the release's stored year, and mbid is the artist's stored identifier. No filter consults fetched claims or private annotations.

offset defaults to 0. limit defaults to 50 and must be between 1 and 200. They are non-negative decimal integers with no sign or whitespace. Search and filters run first, then sorting, then the page slice. The catalog can change between requests: clients comparing pages should compare scanned_at and restart paging after a change. The API makes no cross-request snapshot guarantee.

Errors and versioning#

Application errors are { "error": { "code": string, "message": string } }. code is stable within v1; message is for people and may change. The defined outcomes are:

HTTPerror.codeMeaning
400invalid_queryUnknown, duplicate or invalid search/filter/sort/order parameter.
400invalid_paginationBad offset or limit.
400invalid_referenceMissing or malformed /entities reference.
403invalid_hostThe request authority is not this local listener or is ambiguous.
403invalid_originA browser origin is foreign, malformed or ambiguous.
404entity_not_foundWell-formed reference absent from the current catalog.
404not_foundUnknown path.
404source_unavailable, lyrics_unavailableThe catalogued audio or lyrics sidecar is unavailable.
405method_not_allowedA known path was called with an unsupported HTTP method.
409ambiguous_entityA singular name selects several entities; bounded candidates accompany the error.
409store_busyDoctor or the synchronous administrative scan cannot acquire the data lock.
409source_changed, lyrics_changedLyrics cannot be associated with current regular audio/sidecar identity; restore or rescan the source.
413lyrics_too_largeComplete lyrics exceed an input, expansion or serialized-response limit.
429inspection_busyThe shared catalog/navigation/inspection worker budget is full.
500sources_unavailableA stored source file could not be read.
500catalog_read_failedDoctor could not read catalog/conclusions.
500inspection_failedA background inspection worker failed.
500lyrics_read_failedA complete lyrics read failed.
503catalog_unavailableThe catalog was removed while the server was running.
503connection_limitThe shared WebSocket connection limit has been reached.
503lyrics_timeoutThe complete local lyrics read exceeded its response deadline.

The server checks catalog.json approximately once per second. A successful replacement swaps the in-memory snapshot. An unreadable replacement leaves the previous snapshot in service and is logged to standard error; removing the file makes catalog endpoints return 503 until a new catalog is loaded. /status remains available. Unexpected transport/runtime failures are outside the application error envelope.

The /api/v1/events WebSocket sends { "type": "snapshot", "scanned_at": u64|null } immediately after connection and { "type": "catalog_changed", "scanned_at": u64|null } on successful replacement or removal. null means no catalog is loaded. This frozen stream does not gain task or error message types. Events carry no partial graph update: clients re-read HTTP pages. Notifications are best effort; a reconnect receives a fresh snapshot, and clients must not rely on receiving every intermediate change. The socket accepts no commands.

The two notification routes share a limit of 64 open WebSockets. Additional handshakes return 503 connection_limit until a connection closes; ordinary HTTP reads remain available. Incoming frames and messages are limited to 1 KiB, and any text or binary application message closes the connection; standard ping, pong and close frames remain supported. A send that cannot finish within five seconds closes that connection. HTTPS additionally bounds routed requests to two blocking workers with a 15-second response deadline; work that continues after timeout retains its admission slot. These safeguards still require target-device capacity validation.

Activity stream#

GET /api/v1/activity is a separate WebSocket for clients that want task state as well as catalog changes. It starts with the same snapshot message and includes catalog_changed with the same meaning as /api/v1/events. It adds these JSON messages:

typeFieldsMeaning
task_startedtask_id: u64, task_kind: stringAn accepted task began; a delegated command may still be waiting for the data-folder lock.
task_progresstask_id, task_kind, phase: string, done: usize, total: usizeProgress for that task.
task_completedtask_id, task_kind, scanned_at: u64|nullThe operation completed. The server attempts a catalog reload before this event; if another writer holds the lock, catalog_changed may follow later.
task_failedtask_id, task_kind, code: string, message: stringThe operation failed; no completion message follows.
erroroperation: string, code: string, message: stringA background operation without a task ID failed, currently a catalog reload.

task_id is unique within one server process but is not durable or sequential across restarts; do not use it as a catalog identifier. Task kinds are scan for scans, identification for a delegated CLI or asynchronous HTTP fetch, and command for other delegated CLI mutations. A synchronous administrative scan emits task_started, task_progress (phase discovered, then reading when fresh files need reading), catalog_changed, then task_completed. In discovered, done and total both equal the number of audio files found. In reading, they count files that require fresh tag reads, not every discovered file. Reading progress is throttled to roughly four messages per second, with the final value sent. Delegated CLI commands and asynchronous HTTP jobs also emit running before their existing command begins and refreshing before the server publishes its resulting catalog snapshot. Those lifecycle phases use done: 0, total: 0: the command has no reliable per-item total to publish. Detailed output remains on the connected CLI or the authenticated HTTP task result. A delegated task can be waiting for the shared data lock after task_started. A rejected request, including 401 unauthorized or 409 store_busy, never starts a task and emits no task messages.

Codes currently emitted are scan_failed, store_error or catalog_unavailable for a failed administrative scan, command_failed for a delegated CLI command or asynchronous HTTP job, task_cancelled for a delegated or HTTP scan/fetch stopped by its user, and catalog_reload_failed for a background reload. message is for display, not programmatic decisions. Clients must ignore unknown activity message types, task kinds, phases and fields so future operations remain additive. This stream is best effort and has no replay: on reconnect, clients receive a fresh catalog snapshot, not the prior task history or guaranteed current task status. Neither WebSocket accepts commands. Messages never contain the full catalog; read the HTTP endpoints after catalog_changed.

The /api/v1 prefix and api_version: 1 are independent of the program version and JSON store versions. Additive endpoints and optional response fields may appear in v1. Existing required fields, error codes and query meanings remain stable. A breaking change receives /api/v2; old clients can continue to use v1 while it is served.

Administrative work (separate, opt-in API)#

POST /api/admin/v1/scan with no body preserves the original synchronous behavior: rescan existing watched roots, including saved artist merges and independent conclusions, and return { "status": "completed", "scanned_at": u64, "files": usize } once saved and published. Its writer lock remains held through snapshot publication. A busy writer returns 409 store_busy; scan/storage failures return 500 scan_failed or 500 store_error.

An object body (even {}) selects the new asynchronous scan mode. POST /api/admin/v1/fetch also takes an object and starts a typed asynchronous job. Fields correspond to CLI options, including scan folders/full/thread settings and explicit fetch passes/targets. Accepted submissions return 202 {task_id,status:"queued",status_url}. GET /api/admin/v1/tasks/{id} returns current status and bounded output; POST /api/admin/v1/tasks/{id}/cancel with no body requests cancellation. These jobs wait for the existing writer lock, survive HTTP client disconnection and are awaited at shutdown. At most four can be active; 64 records are retained in memory, with terminal records evicted first. No job history survives restart. Neither polling nor HTTP cancellation addresses delegated CLI jobs or synchronous scans. See the administrative route reference for all input fields, states, outputs and limits.

Task administration accepts no query parameters (400 invalid_query). Object bodies must fit within 16 KiB and finish in one second; malformed/unknown fields return 400 invalid_body, semantic option errors 400 invalid_parameters, and body timeouts 408 request_timeout. Capacity exhaustion returns 503 task_limit, absent/evicted tasks 404 task_not_found, invalid IDs 400 invalid_task_id, and cancellation of terminal work 409 task_not_cancellable. Registry failure returns 500 task_error. A cancellation request is not a rollback of earlier saved work. No arbitrary executable, argument list, data-directory override or credential override is accepted.

Administrative personal routes retain local; account sessions use identical operations at /api/me/v1 bound to their authenticated owner. The route reference defines selectors and JSON shapes. No owner argument is accepted. Annotations set favourites, 1–5 ratings, notes and tags without modifying audio. Operations load the current disk catalog and user data under the shared lock, recheck sessions and keep the lock through atomic writes. Contention is 409 store_busy, busy workers 503 personal_busy, unreadable user data 500 user_unavailable. History is retained by event time, including delayed submissions; all-time counts include accepted events outside the retained log. Persistent ordered playlists are available through /rest, with no equivalent native v1 route yet. HTTP relation annotations remain future work.

Administrator sessions authorize these routes. The optional legacy AEDE_ADMIN_TOKEN still requires a private ASCII secret of at least 32 characters configured before startup. Without accounts or a token, administration is unavailable. Every request uses one Authorization: Bearer <token> header; missing/duplicate/incorrect credentials give JSON 401 unauthorized, and a user or auditor account gives 403 forbidden. Never place secrets in URLs. Legacy token requests refuse every Origin; account sessions follow the shared same-origin check. Installation jobs use the server's OS permissions, so administrator privileges remain trusted. The account reference defines login, self-service password changes, administration, expiry and throttling.

On Unix, CLI commands that may write Aède's stores automatically delegate to the running server for the same data folder. A private Unix socket handles this local command channel; it is not an HTTP endpoint or a remote-access mechanism. The server runs the command independently, streams its normal output back to the CLI, and lets it finish even if that CLI disconnects. This matters for long fetch runs, which already save after each answer. A delegated scan or fetch prints its server task ID; aede cancel <task-id> asks that same local server to stop it. Cancellation returns immediately and the original command exits with code 130 once stopped. A task that has already finished, an administrative HTTP scan, and other CLI commands are not cancellable through this command. The task ID is valid only for the current server process. Closing the CLI, including with Ctrl-C, does not itself cancel the task. Previously saved JSON data remain intact; an interrupted download may leave a hidden temporary sidecar, never a truncated final image or lyric. The socket lives in a private, mode-0700 directory under /tmp so long data-folder paths work; the data folder must not be group- or world-writable. No administrative token is needed for this same-user local channel. Without a server, CLI commands continue locally and aede cancel refuses. On Windows, local delegation and cancellation remain unavailable; native CI validates scanning, sidecars and copying (see Paths).

The exclusive .aede.lock file in the data folder remains the final protection against lost updates: delegated subprocesses and CLI commands without a server hold it for the entire read/modify/write operation; aede backup holds it for a coherent multi-file snapshot. A synchronous administrative scan returns 409 instead of waiting if the lock is held; asynchronous HTTP jobs wait for it. Keep this file in place even when the server is stopped: removing it while another process holds it can defeat the lock. Atomic JSON replacement still protects readers from partial files. This is cooperative coordination between current Aède processes; hand-editing JSON or using an older Aède executable concurrently remains unsafe. The HTTP server reloads external CLI catalog changes approximately once per second.

The server handles Ctrl-C and SIGTERM by stopping new connections and closing active WebSockets. Accepted scans and fetches are allowed to finish before the process exits; cancel an unwanted asynchronous job before shutting down. Default HTTP stays local; remote clients must use the explicit account-backed HTTPS configuration.

Local lyrics#

GET /api/v1/lyrics?track=<stable track token> is a read-only, non-paginated addition. It rejects missing, duplicate, unknown and wrong-kind parameters. A current track with no nonempty lyrics returns { "track": "track:…", "lyrics": null }; otherwise the complete response is:

{"track":"track:…","lyrics":{"source":"sidecar","synced":true,"lines":[{"at_ms":1250,"text":"First line"},{"at_ms":2500,"text":""},{"at_ms":null,"text":"Untimed verse"}]}}

source is tag or sidecar; nonempty tag lyrics precede the catalogued adjacent .lrc. No extra filesystem origin is supplied. synced means at least one timed line. at_ms is an unsigned millisecond position from track start, or null; the parser has already applied an LRC offset. text is literal UTF-8, with invalid sidecar bytes replaced. Source order, repeated timestamps and timed empty lines are preserved. Timed lines may be unsorted; a client groups equal timestamps and selects the latest chronological group no later than its own consumed playback position. Untimed lines remain readable but receive no invented timing. See the client clock rules.

Catalog authentication applies, including read-only auditors, and is repeated before publication. Only the current catalog can select a source; arbitrary paths are never accepted. Audio size/precise modification time and opened/path metadata are checked; legacy whole-second catalogs require a normal scan. A sidecar must have the same basename and parent as its audio, with case-insensitive .lrc extension. Linked/nonregular or changed sources are refused. Input is at most 256 KiB, complete decoded/expanded text at most 1 MiB and serialized JSON at most 1 MiB, including escaping/line overhead. Oversized answers fail explicitly; they are never silently truncated. The shared inspection workers bound file I/O and serialization, with a ten-second response deadline; an underlying blocking operation keeps its permit until completion even after timeout/disconnection. Responses use Cache-Control: no-store.

This endpoint fetches nothing, writes nothing and runs independently of audio. The PCM messages and Subsonic/OpenSubsonic method list do not change. Clients such as Phémios can retrieve the lyrics once per track and follow their own audio clock; a client must clear account-specific cached data on logout and refresh when the source/catalog changes.

Authenticated audio#

GET /api/me/v1/playback upgrades to a WebSocket using a user or administrator session. The versioned PCM contract defines start/format/audio/end messages, acknowledgement flow control, DSP settings and per-occurrence listening history, saved as a bounded batch at stream end. The optional finite-queue extension preserves compatible processing joins with cumulative acknowledgements. The separate opt-in interactive extension adds source seeking, revision-checked future-tail edits and epoch/reset acknowledgement boundaries. An optional private profile checkpoints queue/settings/acknowledged position for explicit resume after reauthentication; it neither restarts audio automatically nor duplicates earlier listening history. It uses the session's owner and accepts only current catalogued tracks. The two notification streams remain unchanged. This native client transport is separate from the Subsonic/OpenSubsonic original-file transport.

Native decoded playback verifies a nonzero FLAC STREAMINFO MD5 at complete decoded EOF, before DSP. A mismatch returns the existing decode_failed code, stops the queue and prevents a completed listen for that occurrence; accepted partial consumption can still be recorded. Early client closure does not establish a complete digest. The original-file adapter passes encoded bytes unchanged, so its client's decoder owns decoded-content verification.

Administrative MusicBrainz source review provides GET/PATCH /api/admin/v1/source-review, with exact optimistic revisions and a separate explicit biography job. This common-catalogue operation uses existing admin authorization, never private annotations or source-file edits.