openapi: 3.0.3 info: title: mStream API version: "1" description: | REST API for the [mStream](https://github.com/IrosTheBeggar/mStream) music streaming server. ## Authentication Most endpoints require a JWT bearer token. Tokens are issued by `POST /api/v1/auth/login` and can be supplied via any of (checked in order): 1. Request body field `token` 2. Query parameter `token` 3. HTTP header `x-access-token` 4. Cookie `x-access-token` ### Public mode If no users exist in the database, all requests are authenticated as a virtual public user with full library access — no token required. This is the default for fresh installs. ### Special tokens - **Admin tokens** have `admin: true` in the payload and are required for every `/api/v1/admin/*` endpoint. - **Jukebox tokens** (`jukebox: true`) are short-lived and bound to an active remote-control session. - **Shared-playlist tokens** (`shareToken: true`) restrict file access to the tracks in one specific shared playlist. - **Federation invite tokens** (`invite: true`) can only call `POST /api/v1/federation/invite/exchange`. ## Errors Errors use the shape `{ "error": "" }`. Status codes: | Code | Meaning | |------|---------| | 400 | Bad request (invalid input) | | 401 | Unauthenticated | | 403 | Forbidden (Joi validation error, missing permission) | | 404 | Resource not found | | 405 | Admin API locked | | 500 | Server error | | 503 | External dependency unavailable (ffmpeg, rust audio server) | ## Virtual paths (vpaths) Libraries are mounted under named vpaths (e.g. `music`, `audiobooks`). Filepaths in request/response bodies are `/`. The server translates these to absolute paths internally and enforces per-user library access on every request. license: name: GPL-3.0 url: https://www.gnu.org/licenses/gpl-3.0.en.html servers: - url: http://localhost:3000 description: Default local server - url: "{scheme}://{host}:{port}" description: Self-hosted instance variables: scheme: enum: [http, https] default: http host: default: localhost port: default: "3000" tags: - name: Auth description: Login and token issuance. - name: System description: Server bootstrap, API discovery. - name: Library description: Artists, albums, genres, tracks — browsing the music database. - name: Search description: Full-text search across the library. - name: Stats description: Play counts, ratings, recently-played, Wrapped. - name: Playlists description: Saved playlists (create, load, modify). - name: Smart Playlists description: Rule-based dynamic playlists (Velvet UI). - name: Shared Playlists description: Public shareable playlist links. - name: File Explorer description: Directory browsing, upload, M3U parsing. - name: Download description: Multi-file ZIP downloads. - name: Transcode description: On-the-fly audio transcoding via ffmpeg. - name: Album Art description: Album art search, upload, serving. - name: Waveform description: Per-track waveform data for the progress bar. - name: Lyrics description: | Per-track lyrics — embedded tag (USLT / Vorbis LYRICS / MP4 ©lyr / APE Lyrics) or sibling `.lrc` / `.txt` sidecar files. LRC-format content is parsed into line-timed entries. Optional LRCLib external fallback (opt-in via admin toggle) populates a cache table for tracks with no local lyrics. Subsonic clients get `/rest/getLyrics` + `/rest/getLyricsBySongId`; the Velvet UI uses this endpoint. - name: Subsonic API description: | Subsonic 1.16.1 + OpenSubsonic REST API served at `/rest/` (and the historical `/rest/.view` alias). 61 methods implemented — full spec at . See the "Show method list" card in the admin panel for the full list including per-method FULL/STUB status. Auth uses Subsonic's own credential scheme (`u/p`, `u/t/s`, or `apiKey`) — NOT mStream's JWT — so these endpoints are not enumerated individually here. - name: User API Keys description: | Per-user API keys for Subsonic clients that can't send a JWT cookie (DSub, Ultrasonic, Substreamer, etc.). Mint via the mStream UI; the key becomes the `apiKey` query param on every `/rest/*` call. - name: Cue Points description: Bookmarks/markers within tracks. - name: User Settings description: Client-persisted preferences and queue state. - name: Scrobbler - Last.fm description: Last.fm scrobbling integration. - name: Scrobbler - ListenBrainz description: ListenBrainz scrobbling integration. - name: Discogs description: Discogs lookup for album art and metadata. - name: Server Playback description: Proxy to the bundled Rust audio player. - name: Jukebox description: Remote-control mode via WebSocket-backed REST. - name: YouTube DL description: Download YouTube audio into the library. - name: Admin - Config description: Server-level settings (address, port, UI, secret). - name: Admin - Scanner description: Scanner parameters and manual scan triggers. - name: Admin - Users description: User CRUD and permission management. - name: Admin - Libraries description: Library (vpath) CRUD. - name: Admin - Transcode description: Transcode defaults and ffmpeg setup. - name: Admin - DLNA description: DLNA server configuration. - name: Admin - SSL description: SSL certificate management. - name: Admin - Logs description: Server log download. - name: Admin - Discogs description: Discogs API credentials. - name: Admin - Shared description: Shared-playlist administration. - name: Admin - File Explorer description: Unrestricted filesystem browsing (admin only). - name: Admin - Subsonic description: | Subsonic-specific admin widgets: mode toggle (disabled / same-port / separate-port), live now-playing strip, jukebox status, token-auth attempts log, per-user API-key minting, lyrics-cache (LRCLib) config + stats. - name: Federation description: Mesh-sync between mStream instances via Syncthing. - name: Torrent description: | User-facing torrent feature. Operators upload `.torrent` files or paste magnet URIs; mStream hands them off to the configured client (Transmission, qBittorrent, or Deluge). Completed downloads land inside an mStream library and get picked up by a targeted scan. Per-user `allow_torrent` gating (when `enabledFor: 'whitelist'`) plus per-user vpath access checks every submission. Auto-detect runs a 3-tier metadata pipeline (name-parse → file-list heuristics → partial-byte tag fetch) so the player can pre-fill artist / album / year before submit. - name: Admin - Torrent description: | Admin-side torrent configuration. Picks the active backend (`disabled` / `transmission` / `qbittorrent` / `deluge`), saves per-client login credentials, manages the per-vpath path-mapping cache, maintains per-vpath path templates the player uses to pre-fill destination paths, lists daemon-side torrents, and removes mStream-added torrents from the daemon (files on disk kept). security: - bearerAuth: [] - cookieAuth: [] components: securitySchemes: bearerAuth: type: http scheme: bearer bearerFormat: JWT description: | Pass the JWT in the `x-access-token` header. Despite the OpenAPI "bearer" label, the value does NOT include a `Bearer ` prefix — it's the raw token. cookieAuth: type: apiKey in: cookie name: x-access-token schemas: Error: type: object required: [error] properties: error: type: string description: Human-readable error message. example: error: Authentication Error Empty: type: object description: Empty success response — the operation completed with no additional data. example: {} Metadata: type: object description: Shared track metadata shape used across nearly every library endpoint. properties: filepath: type: string description: Virtual path (`/`). metadata: type: object properties: title: type: string nullable: true artist: type: string nullable: true album: type: string nullable: true track: type: integer nullable: true disk: type: integer nullable: true year: type: integer nullable: true hash: type: string nullable: true description: Content hash (used as a stable ID across re-scans). album-art: type: string nullable: true description: Filename in the album-art cache directory. Serve via `GET /album-art/{file}`. rating: type: integer minimum: 0 maximum: 10 nullable: true play-count: type: integer nullable: true last-played: type: string format: date-time nullable: true replaygain-track: type: number nullable: true description: Track-level ReplayGain adjustment in dB. bpm: type: integer nullable: true description: | Beats-per-minute, range-validated 20..300 at scan time (V32). NULL when the file's `TBPM` / Vorbis `BPM` / MP4 `tmpo` tag is absent or out of range. Drives the webapp Auto-DJ BPM-continuity feature. musical-key: type: string nullable: true description: | Musical key as stored in the file's tag (`TKEY` / `INITIALKEY` / Vorbis `KEY`), trimmed and capped at 12 chars at scan time. Format varies by tagger — may be a raw key name ("A minor"), a Camelot code ("8A"), or an enharmonic spelling ("Am"). Clients that need canonicalisation should run the value through `toCamelot()` (see the velvet helper or the server-side `CAMELOT_TO_KEYS` map). Field name is kebab-case on the wire to match the other multi-word fields here (`album-art`, `play-count`, `last-played`, `replaygain-track`). The DB column name is `musical_key` (snake_case, SQL convention). Album: type: object properties: name: type: string artist: type: string nullable: true year: type: integer nullable: true album_art_file: type: string nullable: true Library: type: object properties: id: type: integer name: type: string description: Virtual path (vpath). root_path: type: string description: Absolute filesystem path. type: type: string enum: [music, audio-books] User: type: object properties: admin: type: boolean vpaths: type: array items: type: string allowMkdir: type: boolean allowUpload: type: boolean allowFileModify: type: boolean IgnoreVPaths: type: object properties: ignoreVPaths: type: array items: type: string description: Libraries (vpaths) to exclude from the query. SearchArtistOrAlbumItem: type: object description: | One artist or album hit from `/api/v1/db/search`. The `filepath` field is always the literal boolean `false` on artist/album rows — used by clients as a sentinel that distinguishes "browseable group" results from individual tracks (which have a real `filepath` string). required: [name, album_art_file, filepath] properties: name: type: string description: Artist or album name. album_art_file: type: string nullable: true description: Filename of associated cover art, or `null` if none. Resolve via `/album-art/`. filepath: type: boolean enum: [false] description: Always `false`. Sentinel value distinguishing this from a track-shaped result. SearchTrackItem: type: object description: | One track hit from `/api/v1/db/search`. Returned in the `title` array (when the track's title matched) and the `files` array (when the filepath matched). required: [name, album_art_file, filepath] properties: name: type: string description: | Display name. For title-matched tracks: `" - "` when an artist is known, otherwise just the title. For filepath-matched tracks: the same library-prefixed path value as `filepath`. album_art_file: type: string nullable: true description: Filename of the track's cover art, or `null`. filepath: type: string description: Library-prefixed path (e.g. `"music/Artist/Album/01.flac"`). Use directly with `/media/<library>/<rest>` to stream. ScanOptions: type: object properties: skipImg: type: boolean scanInterval: type: number bootScanDelay: type: number compressImage: type: boolean scanCommitInterval: type: integer autoAlbumArt: type: boolean albumArtWriteToFolder: type: boolean albumArtWriteToFile: type: boolean albumArtServices: type: array items: type: string enum: [musicbrainz, itunes, deezer] analyzeBpm: type: boolean description: | Run scanner-time BPM + musical-key analysis via stratum-dsp on tracks that don't already have tag-sourced values (TBPM/TKEY etc.). Rust scanner only — the JS fallback accepts but ignores. Default true. # ── Torrent feature schemas ───────────────────────────────────────── TorrentItem: type: object description: | A torrent as the daemon currently sees it. Returned by `GET /api/v1/admin/torrent/list`. `managedByMstream` is true iff the torrent's info_hash matches a row in `managed_torrents` for the active client — i.e. mStream added it via `/api/v1/torrent/add`. properties: clientTorrentId: type: string nullable: true description: Daemon-native id (Transmission's torrent-id, qBit's hash, etc.). infoHash: type: string description: 40-char lowercase hex SHA-1 of the bencoded info dict. name: { type: string } status: type: string enum: [downloading, seeding, paused, queued, verifying, error, unknown] percent: { type: number, minimum: 0, maximum: 1 } rateDownload: { type: integer, description: "Bytes per second." } rateUpload: { type: integer } eta: { type: integer, description: "Seconds, or -1 when unknown." } sizeBytes: { type: integer } downloadedBytes: { type: integer } errorMessage: { type: string } addedAt: { type: integer, description: "Unix epoch seconds." } doneAt: { type: integer, description: "Unix epoch seconds; 0 when still downloading." } managedByMstream: { type: boolean } managedBy: type: string nullable: true description: Username of the user who added the torrent via mStream, when known. VpathAccessRow: type: object description: | One row of the per-(client, vpath) path-mapping cache. Maps an mStream library name to the absolute path the daemon uses for it. Drives the add-torrent gate at submission time. properties: daemonPath: { type: string, description: "Daemon-side absolute path; null when unconfirmed." } mstreamWritable: type: boolean nullable: true confidence: type: string enum: [verified, inferred, pending, unconfirmed] description: | `verified` = daemon round-trip confirmed the shared filesystem view. `inferred` = daemon's save_path or known-paths match but no probe ran. `pending` = a background probe is in flight. `unconfirmed` = probe failed or no row yet. source: type: string enum: [auto, manual] method: { type: string, description: "How the row was derived (e.g. `deluge:known-paths`)." } lastProbedAt: { type: integer, description: "Unix epoch seconds." } lastError: type: string nullable: true TorrentMetadata: type: object description: Metadata the 3-tier extraction pipeline produces. properties: artist: { type: string } album: { type: string } year: { type: string } genre: { type: string } TorrentFileShape: type: object description: Tier-2 file-list heuristics. Helps the UI distinguish single-tracks from albums. properties: fileCount: { type: integer } audioFileCount: { type: integer } totalSize: { type: integer } releaseType: { type: string, enum: [single, album, compilation, unknown] } hasTrackNumberPrefixes: { type: boolean } hasAudio: { type: boolean } TorrentClientCreds: type: object description: Shared shape for the three login forms. `username`/`rpcPath` are only set on the clients that need them. properties: host: { type: string, example: "127.0.0.1" } port: { type: integer, minimum: 1, maximum: 65535 } username: { type: string } password: { type: string, format: password } rpcPath: { type: string, example: "/transmission/rpc", description: "Transmission only." } useHttps: { type: boolean, default: false } configured: { type: boolean, description: "Read-only on GET; true iff a host has been saved." } TorrentErrorEnvelope: type: object description: Standard error shape for `/api/v1/torrent/*` and `/api/v1/admin/torrent/*`. required: [ok, error, message] properties: ok: { type: boolean, enum: [false] } error: { type: string, description: "Stable machine-readable code." } message: { type: string, description: "Human-readable explanation." } SeedResult: type: object description: | Per-torrent result from `POST /api/v1/admin/torrent/seed-existing`. The route accepts ONE torrent per request; the admin UI fires N parallel requests and aggregates results client-side. Absolute on-disk paths (`vpathRoot`, `partialRoot`, `matchedRoot`) are admin-only and are STRIPPED from the user-facing `POST /api/v1/torrent/seed-existing` response — see `UserSeedResult` for that variant's contract. required: [ok, outcome] properties: ok: { type: boolean, enum: [true] } outcome: type: string enum: [seeded, partial_match, no_match, already_in_daemon, invalid_torrent, daemon_error] description: | `seeded` — every file matched and the daemon accepted the add. `partial_match` — some files matched but not all; daemon not touched. `no_match` — no candidate vpath had any files present. `already_in_daemon` — the daemon already had this info-hash; no-op. `invalid_torrent` — the metainfo wasn't parseable. `daemon_error` — files matched but the daemon refused the add. infoHash: { type: string, nullable: true, description: "Null when outcome=invalid_torrent." } name: { type: string, nullable: true } # seeded + partial_match + daemon_error vpath: { type: string, description: "Library where the files were found. Present on seeded/partial/daemon_error." } vpathRoot: { type: string, description: "Absolute on-disk root of the vpath. Present on seeded/partial/daemon_error." } # seeded + daemon_error matchedRoot: { type: string, description: "Absolute on-disk path where the files live. Present on seeded/daemon_error." } addedAt: { type: string, description: "Daemon-side absolute path the torrent was registered against. seeded only." } # partial_match only partialRoot: { type: string, description: "Absolute on-disk directory where the partial files live. partial_match only (top-level mirror of matches[0].partialRoot)." } matched: { type: integer, description: "Count of files that matched. partial_match only (top-level mirror of matches[0].matched)." } total: { type: integer, description: "Total file count in the torrent. partial_match only." } missing: type: array items: { type: string } description: "Forward-slash relative paths of missing/mismatched files. Capped at 20 entries. partial_match only." matches: type: array description: "One entry per vpath that had >0 matching files. Sorted by match ratio descending. partial_match only." items: type: object required: [vpath, vpathRoot, partialRoot, matched, total] properties: vpath: { type: string } vpathRoot: { type: string, description: "Absolute on-disk root of the matched library." } partialRoot: { type: string, description: "Absolute on-disk path of the directory where the partial files live." } matched: { type: integer } total: { type: integer } missing: type: array items: { type: string } description: "Forward-slash relative paths of missing files for this vpath." checkedVpaths: type: array items: { type: string } description: "Vpaths searched in this request. Present on partial_match and no_match." # invalid_torrent / daemon_error error: { type: string, description: "Human-readable failure reason. invalid_torrent + daemon_error." } UserSeedResult: type: object description: | Per-torrent result from `POST /api/v1/torrent/seed-existing` — the user-facing variant of the admin `SeedResult` shape. Server-absolute filesystem paths (`vpathRoot`, `matchedRoot`, `partialRoot`, `addedAt`) are stripped before the response crosses the user boundary. For `partial_match`, each entry in `matches[]` carries a vpath-relative `relativePath` string the client can feed back into `POST /api/v1/torrent/add` as a `directoryName` + optional `subPath`. For `daemon_error`, the raw RPC failure message is replaced with a generic line so daemon-internal hostnames/versions/paths don't leak out. required: [ok, outcome] properties: ok: { type: boolean, enum: [true] } outcome: type: string enum: [seeded, partial_match, no_match, already_in_daemon, invalid_torrent, daemon_error] infoHash: { type: string, nullable: true } name: { type: string, nullable: true } vpath: { type: string, description: "Present on seeded/partial_match/daemon_error." } matched: { type: integer, description: "partial_match only (mirror of matches[0].matched)." } total: { type: integer, description: "partial_match only." } missing: type: array items: { type: string } description: "Forward-slash relative paths. partial_match only." relativePath: type: string description: "Top-level mirror of matches[0].relativePath. partial_match only." matches: type: array description: "One row per vpath with >0 matches. Sorted by match ratio desc. partial_match only." items: type: object required: [vpath, relativePath, matched, total] properties: vpath: { type: string } relativePath: { type: string, description: "Forward-slash path relative to the vpath root. Feed as directoryName (+optional leading subPath segments) into /torrent/add." } matched: { type: integer } total: { type: integer } missing: type: array items: { type: string } checkedVpaths: type: array items: { type: string } description: "Present on partial_match and no_match." error: type: string description: | invalid_torrent: bencode parser message from the user's own upload. daemon_error: generic redaction — the raw daemon RPC error is NOT forwarded to non-admin callers. responses: EmptySuccess: description: Operation succeeded. content: application/json: schema: { $ref: "#/components/schemas/Empty" } Unauthorized: description: Auth required or token invalid. content: application/json: schema: { $ref: "#/components/schemas/Error" } Forbidden: description: Validation error or permission denied. content: application/json: schema: { $ref: "#/components/schemas/Error" } NotFound: description: Resource not found. content: application/json: schema: { $ref: "#/components/schemas/Error" } AdminLocked: description: Admin API disabled (either by configuration or because the user is not an admin). content: application/json: schema: { $ref: "#/components/schemas/Error" } ServerError: description: Server error. content: application/json: schema: { $ref: "#/components/schemas/Error" } paths: # ── System ─────────────────────────────────────────────────────────────── /: get: tags: [System] summary: Main webapp description: Serves the main webapp HTML. Redirects to `/login` if users exist and the request is unauthenticated. security: [] responses: "200": description: HTML page. content: text/html: schema: { type: string } /admin: get: tags: [System] summary: Admin panel webapp description: Serves the admin panel HTML. Access-controlled the same way as `/`. security: [] responses: "200": description: HTML page. content: text/html: schema: { type: string } /api: get: tags: [System] summary: API version discovery description: Returns the server version and the list of available API versions. security: [] responses: "200": description: Version info. content: application/json: schema: type: object properties: server: type: string description: Server version (from package.json). apiVersions: type: array items: { type: string } example: server: "6.4.2" apiVersions: ["1"] /api/v1/ping: get: tags: [System] summary: Bootstrap payload description: | Returns everything the webapp needs on load: the user's libraries, their playlists, transcode capabilities, and server-level flags. Called once after login. responses: "200": description: Bootstrap info. content: application/json: schema: type: object properties: vpaths: type: array items: { type: string } playlists: type: array items: type: object properties: name: { type: string } transcode: oneOf: - type: boolean enum: [false] - type: object properties: defaultCodec: { type: string } defaultBitrate: { type: string } noMkdir: { type: boolean } noUpload: { type: boolean } noFileModify: { type: boolean } supportedAudioFiles: type: object additionalProperties: { type: boolean } vpathMetaData: type: object additionalProperties: type: object properties: type: { type: string } "401": { $ref: "#/components/responses/Unauthorized" } # ── Auth ───────────────────────────────────────────────────────────────── /api/v1/auth/login: post: tags: [Auth] summary: Log in description: | Authenticates with username + password. On success returns a JWT token and sets it as an `x-access-token` cookie with a 5-year max age. On failure, the response is delayed by 800ms to slow down brute-force attempts. security: [] requestBody: required: true content: application/json: schema: type: object required: [username, password] properties: username: { type: string } password: { type: string } responses: "200": description: Authenticated. content: application/json: schema: type: object properties: token: { type: string, description: "JWT." } vpaths: type: array items: { type: string } "401": description: Login failed. content: application/json: schema: { $ref: "#/components/schemas/Error" } # ── Library ────────────────────────────────────────────────────────────── /api/v1/db/status: get: tags: [Library] summary: Library status description: Total track count across the user's accessible libraries and whether a scan is currently running. responses: "200": description: Status. content: application/json: schema: type: object properties: totalFileCount: { type: integer } locked: type: boolean description: True while a scan is in progress. "401": { $ref: "#/components/responses/Unauthorized" } /api/v1/db/metadata: post: tags: [Library] summary: Get metadata for a single track requestBody: required: true content: application/json: schema: type: object required: [filepath] properties: filepath: type: string description: Virtual path. responses: "200": description: Metadata for the requested file. content: application/json: schema: { $ref: "#/components/schemas/Metadata" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } /api/v1/db/metadata/batch: post: tags: [Library] summary: Get metadata for multiple tracks description: Request body is a bare JSON array of virtual paths. requestBody: required: true content: application/json: schema: type: array items: { type: string } responses: "200": description: Map of filepath → metadata. content: application/json: schema: type: object additionalProperties: { $ref: "#/components/schemas/Metadata" } "401": { $ref: "#/components/responses/Unauthorized" } /api/v1/db/artists: get: &artistsOp tags: [Library] summary: List all artists description: Unique artist names from the user's accessible libraries, case-insensitive sort. responses: "200": description: Artists. content: application/json: schema: type: object properties: artists: type: array items: { type: string } "401": { $ref: "#/components/responses/Unauthorized" } post: <<: *artistsOp requestBody: description: Optional `ignoreVPaths` filter. content: application/json: schema: { $ref: "#/components/schemas/IgnoreVPaths" } /api/v1/db/artists-albums: post: tags: [Library] summary: List one artist's albums requestBody: required: true content: application/json: schema: allOf: - type: object required: [artist] properties: artist: { type: string } - { $ref: "#/components/schemas/IgnoreVPaths" } responses: "200": description: Albums. content: application/json: schema: type: object properties: albums: type: array items: { $ref: "#/components/schemas/Album" } "401": { $ref: "#/components/responses/Unauthorized" } /api/v1/db/artists-albums-multi: post: tags: [Library] summary: List albums across several artists requestBody: required: true content: application/json: schema: type: object required: [artists] properties: artists: type: array items: { type: string } responses: "200": description: Albums. content: application/json: schema: type: object properties: albums: type: array items: { $ref: "#/components/schemas/Album" } "401": { $ref: "#/components/responses/Unauthorized" } /api/v1/db/albums: get: &albumsOp tags: [Library] summary: List all albums responses: "200": description: Albums. content: application/json: schema: type: object properties: albums: type: array items: { $ref: "#/components/schemas/Album" } "401": { $ref: "#/components/responses/Unauthorized" } post: <<: *albumsOp requestBody: content: application/json: schema: { $ref: "#/components/schemas/IgnoreVPaths" } /api/v1/db/album-songs: post: tags: [Library] summary: List tracks in an album requestBody: required: true content: application/json: schema: allOf: - type: object required: [album] properties: album: { type: string } artist: { type: string } year: { type: integer } - { $ref: "#/components/schemas/IgnoreVPaths" } responses: "200": description: Tracks. content: application/json: schema: type: array items: { $ref: "#/components/schemas/Metadata" } "401": { $ref: "#/components/responses/Unauthorized" } /api/v1/db/genres: get: &genresOp tags: [Library] summary: List all genres responses: "200": description: Genres. content: application/json: schema: type: object properties: genres: type: array items: type: object properties: name: { type: string } track_count: { type: integer } "401": { $ref: "#/components/responses/Unauthorized" } post: <<: *genresOp requestBody: content: application/json: schema: { $ref: "#/components/schemas/IgnoreVPaths" } /api/v1/db/genre-songs: post: tags: [Library] summary: List tracks in a genre requestBody: required: true content: application/json: schema: allOf: - type: object required: [genre] properties: genre: { type: string } - { $ref: "#/components/schemas/IgnoreVPaths" } responses: "200": description: Tracks. content: application/json: schema: type: array items: { $ref: "#/components/schemas/Metadata" } "401": { $ref: "#/components/responses/Unauthorized" } /api/v1/db/genre-groups: get: tags: [Library] summary: Genre groups (Velvet) description: Alternative genre listing used by the Velvet UI. responses: "200": description: Genres with counts. content: application/json: schema: type: object properties: genres: type: array items: type: object properties: genre: { type: string } count: { type: integer } groups: { type: object, nullable: true } "401": { $ref: "#/components/responses/Unauthorized" } /api/v1/db/genre/albums: post: tags: [Library] summary: Albums in a genre (Velvet) requestBody: required: true content: application/json: schema: type: object required: [genre] properties: genre: { type: string } responses: "200": description: Albums. content: application/json: schema: type: object properties: albums: type: array items: { $ref: "#/components/schemas/Album" } "401": { $ref: "#/components/responses/Unauthorized" } /api/v1/db/genre/songs: post: tags: [Library] summary: Songs in a genre (Velvet) requestBody: required: true content: application/json: schema: type: object required: [genre] properties: genre: { type: string } responses: "200": description: Tracks. content: application/json: schema: type: array items: { $ref: "#/components/schemas/Metadata" } "401": { $ref: "#/components/responses/Unauthorized" } /api/v1/db/decades: get: tags: [Library] summary: List decades (Velvet) responses: "200": description: Decades with counts. content: application/json: schema: type: object properties: decades: type: array items: type: object properties: decade: { type: integer } cnt: { type: integer } albums: { type: integer } "401": { $ref: "#/components/responses/Unauthorized" } /api/v1/db/decade/albums: post: tags: [Library] summary: Albums in a decade (Velvet) requestBody: required: true content: application/json: schema: type: object required: [decade] properties: decade: { type: integer } responses: "200": description: Albums. content: application/json: schema: type: object properties: albums: type: array items: { $ref: "#/components/schemas/Album" } "401": { $ref: "#/components/responses/Unauthorized" } /api/v1/db/decade/songs: post: tags: [Library] summary: Songs in a decade (Velvet) requestBody: required: true content: application/json: schema: type: object required: [decade] properties: decade: { type: integer } responses: "200": description: Tracks. content: application/json: schema: type: array items: { $ref: "#/components/schemas/Metadata" } "401": { $ref: "#/components/responses/Unauthorized" } /api/v1/db/songs-by-artists: post: tags: [Library] summary: Random tracks by the given artists (Velvet) requestBody: required: true content: application/json: schema: type: object required: [artists] properties: artists: type: array items: { type: string } limit: { type: integer } responses: "200": description: Tracks. content: application/json: schema: type: array items: { $ref: "#/components/schemas/Metadata" } "401": { $ref: "#/components/responses/Unauthorized" } /api/v1/db/random-songs: post: tags: [Library] summary: Get a random song (Auto-DJ) description: | Returns one random song. With no body fields set, behaves like the pre-V32 route — pick a random track from everything the user can see. When any BPM / key / artist filter is set the server runs a fallback waterfall that progressively relaxes constraints until at least one track matches. Without `artists`: 1. tight BPM + key 2. wide BPM + key (only if `bpmRangesWide` set) 3. tight BPM only (drop key) 4. wide BPM only (drop key) 5. unrestricted random within library + rating scope With `artists` (similar-artists Auto-DJ — typically the output of `GET /api/v1/lastfm/similar-artists`), a similar-prioritised chain runs FIRST, and only falls through to the chain above if every similar-artist combination is empty: 1. similar + tight BPM + key 2. similar + wide BPM + key 3. similar + tight BPM (drop key) 4. similar + wide BPM (drop key) 5. similar only (drop BPM/key) 5b. similar (drop `ignoreArtists` cooldown — only if set) 6-10. the non-similar chain above After the SQL-side waterfall, a tier filter sorts the surviving rows so an in-range pick wins over an unknown-tag pick wins over a known-wrong pick. The route always returns exactly one song (or 400 if scope is empty). Musical keys are Camelot codes (`1A`..`12B`). The server expands each code to all known raw-key spellings — `8A` matches stored values "A minor", "Am", "Amin", or literal "8A" — so tags from any common DJ-software convention work without client-side normalisation. Unknown codes silently match nothing. Tracks must have the corresponding column populated to be considered when a BPM or key constraint is active (NULL columns are excluded by the SQL filter, then re-considered as Tier 1 candidates by the post-fallback tier filter). The scanner populates these columns from embedded `TBPM` / `TKEY` (ID3v2) or `BPM` / `KEY` / `INITIALKEY` (Vorbis) tags. `ignoreList` is a round-trip cursor — the server returns an updated list with the just-picked index appended, and clients echo it back on the next call to deduplicate across a session. The server trims older entries automatically when the list exceeds ~half the candidate-pool size; clients don't need to prune it themselves. Capped at 500 entries. requestBody: content: application/json: schema: allOf: - type: object properties: minRating: { type: integer, minimum: 0, maximum: 10 } ignoreList: type: array maxItems: 500 items: { type: integer, minimum: 0 } description: | Indices of recently-picked tracks to skip. Echo back the `ignoreList` from the previous response unchanged; the server appends + trims. bpmRanges: type: array maxItems: 16 description: | Primary BPM windows. Multiple ranges are OR-ed in SQL — clients typically send three (normal ± tolerance, half tempo, double tempo) for octave-equivalent continuity. Tracks whose `bpm` is NULL are excluded by this filter. items: type: object required: [min, max] properties: min: { type: number } max: { type: number } bpmRangesWide: type: array maxItems: 16 description: | Optional widened BPM windows used only by step 2 / step 4 of the waterfall when the tight `bpmRanges` returned zero rows. Same shape as `bpmRanges`. items: type: object required: [min, max] properties: min: { type: number } max: { type: number } requireBpm: type: boolean description: | When true, exclude tracks with NULL `bpm`. Ignored when `bpmRanges` is set (the range filter already requires non-null). musicalKeys: type: array maxItems: 24 description: | Camelot codes (`1A`..`12B`). Each code is expanded server-side to all spellings the DB might contain ("A minor" / "Am" / "Amin" / `8A`). Sending only unrecognised codes returns 403; an empty / omitted array disables the filter. items: { type: string } requireMusicalKey: type: boolean description: | When true, exclude tracks with NULL `musical_key`. Implicit when `musicalKeys` is non-empty. artists: type: array maxItems: 100 description: | Canonical library artist names — typically the output of `GET /api/v1/lastfm/similar-artists`. When set, the waterfall prioritises tracks credited to any of these artists via primary, featured (`track_artists`), or album-level (`album_artists`) credit. Falls through to non-similar picks only after every BPM/key combination on the similar pool is empty. items: { type: string } ignoreArtists: type: array maxItems: 100 description: | Recently-played artists to exclude (Auto-DJ cooldown). Symmetric V18 widening: a cooldown on "Foo" also drops "Foo feat. Bar" and tracks on albums credited to Foo. The waterfall has a drop-cooldown fallback step so a user who blacklisted the entire library still gets a pick from the similar pool. items: { type: string } genres: type: array maxItems: 200 description: | Genre filter list (V35). Names are case-insensitive and matched against `track_genres` (the M2M store that replaced `tracks.genre` in V34). Pair with `genreMode` to choose whitelist vs blacklist. Empty / omitted → no filtering. This filter is an ALWAYS-ON base condition — the waterfall never relaxes it, so an over-narrow filter can produce a 400 even when other constraints would pass. items: { type: string, minLength: 1, maxLength: 200 } genreMode: type: string enum: [whitelist, blacklist] default: whitelist description: | `whitelist` (default) plays only tracks with at least one matching genre; tracks with no `track_genres` rows are BLOCKED. `blacklist` skips tracks with any matching genre; tracks with no `track_genres` rows are ALLOWED. - { $ref: "#/components/schemas/IgnoreVPaths" } responses: "200": description: One random song that satisfies the (possibly relaxed) constraints. content: application/json: schema: type: object properties: songs: type: array items: { $ref: "#/components/schemas/Metadata" } ignoreList: type: array items: { type: integer } description: | Updated cursor: previous request's `ignoreList` with the just-picked index appended, plus any server-side trimming. Echo back unchanged on the next call. "400": { description: "No songs match the criteria, even after the waterfall fully drops them." } "401": { $ref: "#/components/responses/Unauthorized" } "403": { description: "Joi validation failed on the request body (e.g. bpmRange min > max, or `musicalKeys` contained no recognised Camelot codes)." } /api/v1/albums/browse: get: tags: [Library] summary: Browse all albums (Velvet) responses: "200": description: Albums and series. content: application/json: schema: type: object properties: albums: type: array items: allOf: - { $ref: "#/components/schemas/Album" } - type: object properties: id: { type: integer } track_count: { type: integer } displayName: { type: string } series: type: array items: { type: object } "401": { $ref: "#/components/responses/Unauthorized" } /api/v1/files/art: get: tags: [Library] summary: Get the album art filename for a track parameters: - name: fp in: query required: true schema: { type: string } responses: "200": description: Album art filename. content: application/json: schema: type: object properties: file: type: string description: Filename in the album-art cache (serve via `/album-art/{file}`). "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } # ── Search ─────────────────────────────────────────────────────────────── /api/v1/db/search: post: tags: [Search] summary: Full-text search description: | Searches artist names, album names, track titles, and filepaths. Each category returns up to 30 matches. Three algorithms selectable via the `algorithm` request body field (default `combo`): - `basic` — LIKE only, no FTS5 involved. Infix substring match, alphabetical order. The pre-V31 behaviour, preserved as an escape hatch. - `fts5` — strict FTS5 MATCH. No LIKE fallback even on parse failure or SQLite-level error; the affected category just returns an empty array. Debug mode. - `combo` — FTS5 primary with per-category LIKE fallback when MATCH fails to parse or errors. The default — what every client gets unless it opts out. FTS5 semantics differ from LIKE in two user-visible ways: prefix matching only (e.g. `unny` does NOT match `Funny`, but `fun` does), and unicode61 diacritic folding (e.g. `ros` matches `Sigur Rós`). BM25 ranking replaces alphabetical ordering — terms with higher in-row frequency rank first. Per-category fallback in `combo` mode fires on SQLITE_ERROR (a malformed FTS5 MATCH expression that survived the JS parser) or when the parser refuses to build an expression (no alphanumeric tokens, single sub-2-char positive). Zero rows from a clean MATCH does NOT trigger fallback — that is a real "no match" result, not an error. requestBody: required: true content: application/json: schema: allOf: - type: object required: [search] properties: search: { type: string } noArtists: { type: boolean } noAlbums: { type: boolean } noTitles: { type: boolean } noFiles: { type: boolean } algorithm: type: string enum: [basic, fts5, combo] default: combo description: | Search algorithm. Unknown values return 403 via the Joi validation middleware. When SQLite was compiled without FTS5 support, both `fts5` and `combo` are silently coerced to `basic` for the lifetime of the process; a single warning is logged at first request. - { $ref: "#/components/schemas/IgnoreVPaths" } responses: "200": description: | Search results — four parallel arrays, one per category. Every item shares the same three-key shape (`name`, `album_art_file`, `filepath`); the response is byte-identical across all three algorithms by construction (both paths route through the same shape* callbacks in `src/api/search.js`). content: application/json: schema: type: object properties: artists: type: array items: { $ref: "#/components/schemas/SearchArtistOrAlbumItem" } description: Matched artists. `filepath` is always `false`. albums: type: array items: { $ref: "#/components/schemas/SearchArtistOrAlbumItem" } description: Matched albums. `filepath` is always `false`. title: type: array items: { $ref: "#/components/schemas/SearchTrackItem" } description: Tracks whose title matched. `name` is rendered as `"<artist> - <title>"` when an artist is known, otherwise just the title. `filepath` is the library-prefixed path used by `/media/<library>/<rest>` streaming. files: type: array items: { $ref: "#/components/schemas/SearchTrackItem" } description: Tracks whose filepath matched. `name` is the library-prefixed path (same value as `filepath`). "401": { $ref: "#/components/responses/Unauthorized" } "403": description: | Joi validation failed. Returned for unknown `algorithm` values (anything outside `basic` / `fts5` / `combo`), missing `search`, or any other schema violation. mStream's error middleware maps `Joi.ValidationError` to 403, not 400 — a server-wide convention since long before this endpoint. content: application/json: schema: type: object properties: error: { type: string } # ── Stats ──────────────────────────────────────────────────────────────── /api/v1/db/rated: get: &ratedOp tags: [Stats] summary: Get tracks the user has rated description: Tracks rated > 0, sorted highest first. responses: "200": description: Rated tracks. content: application/json: schema: type: array items: { $ref: "#/components/schemas/Metadata" } "401": { $ref: "#/components/responses/Unauthorized" } post: <<: *ratedOp requestBody: content: application/json: schema: { $ref: "#/components/schemas/IgnoreVPaths" } /api/v1/db/rate-song: post: tags: [Stats] summary: Rate a track requestBody: required: true content: application/json: schema: type: object required: [filepath, rating] properties: filepath: { type: string } rating: type: integer minimum: 0 maximum: 10 nullable: true description: 0–10, or `null` to clear the rating. responses: "200": { $ref: "#/components/responses/EmptySuccess" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } /api/v1/db/recent/added: post: tags: [Stats] summary: Recently added tracks requestBody: required: true content: application/json: schema: allOf: - type: object required: [limit] properties: limit: { type: integer, minimum: 1 } - { $ref: "#/components/schemas/IgnoreVPaths" } responses: "200": description: Tracks. content: application/json: schema: type: array items: { $ref: "#/components/schemas/Metadata" } "401": { $ref: "#/components/responses/Unauthorized" } /api/v1/db/stats/recently-played: post: tags: [Stats] summary: Recently played tracks requestBody: required: true content: application/json: schema: allOf: - type: object required: [limit] properties: limit: { type: integer, minimum: 1 } - { $ref: "#/components/schemas/IgnoreVPaths" } responses: "200": description: Tracks. content: application/json: schema: type: array items: { $ref: "#/components/schemas/Metadata" } "401": { $ref: "#/components/responses/Unauthorized" } /api/v1/db/stats/most-played: post: tags: [Stats] summary: Most played tracks requestBody: required: true content: application/json: schema: allOf: - type: object required: [limit] properties: limit: { type: integer, minimum: 1 } - { $ref: "#/components/schemas/IgnoreVPaths" } responses: "200": description: Tracks. content: application/json: schema: type: array items: { $ref: "#/components/schemas/Metadata" } "401": { $ref: "#/components/responses/Unauthorized" } /api/v1/db/stats/log-play: post: tags: [Stats] summary: Log a play description: Increments play-count and updates last-played for a track. requestBody: required: true content: application/json: schema: type: object required: [filePath] properties: filePath: { type: string } responses: "200": { $ref: "#/components/responses/EmptySuccess" } "401": { $ref: "#/components/responses/Unauthorized" } /api/v1/db/stats/reset-play-counts: post: tags: [Stats] summary: Reset play counts for all tracks responses: "200": { $ref: "#/components/responses/EmptySuccess" } "401": { $ref: "#/components/responses/Unauthorized" } /api/v1/db/stats/reset-recently-played: post: tags: [Stats] summary: Clear recently-played history responses: "200": { $ref: "#/components/responses/EmptySuccess" } "401": { $ref: "#/components/responses/Unauthorized" } /api/v1/user/wrapped: get: tags: [Stats] summary: Spotify-style Wrapped stats (Velvet) description: Aggregated listening statistics for a period. parameters: - name: period in: query schema: type: string enum: [weekly, monthly, quarterly, half-yearly, yearly] default: monthly - name: offset in: query description: 0 = current period, 1 = previous, etc. schema: type: integer default: 0 responses: "200": description: Wrapped payload. content: application/json: schema: type: object properties: period_label: { type: string } total_plays: { type: integer } total_listening_ms: { type: integer } unique_songs: { type: integer } pause_count: { type: integer } skip_rate: { type: number } library_coverage_pct: { type: number } completion_rate: { type: number } new_discoveries: { type: integer } top_songs: type: array items: { $ref: "#/components/schemas/Metadata" } top_artists: type: array items: { type: object } listening_by_hour: type: array items: { type: integer } listening_by_weekday: type: array items: { type: integer } top_listening_day: { type: string } personality: { type: string } longest_session: { type: integer } avg_session_length_ms: { type: number } fun_facts: { type: array, items: { type: string } } radio: { type: object } podcast: { type: object } "401": { $ref: "#/components/responses/Unauthorized" } /api/v1/user/wrapped/periods: get: tags: [Stats] summary: Available Wrapped periods (Velvet) responses: "200": description: Periods. content: application/json: schema: type: array items: type: object properties: period: { type: string } offset: { type: integer } label: { type: string } "401": { $ref: "#/components/responses/Unauthorized" } /api/v1/wrapped/play-start: post: tags: [Stats] summary: Log the start of a play (Velvet) requestBody: required: true content: application/json: schema: type: object required: [filePath] properties: filePath: { type: string } sessionId: { type: string } source: { type: string } responses: "200": description: Event id (or null if not tracked). content: application/json: schema: type: object properties: eventId: type: string nullable: true "401": { $ref: "#/components/responses/Unauthorized" } /api/v1/wrapped/play-stop: post: tags: [Stats] summary: Log a play stop (Velvet) requestBody: required: true content: application/json: schema: type: object required: [eventId, playedMs] properties: eventId: { type: string } playedMs: { type: integer } responses: "200": { $ref: "#/components/responses/EmptySuccess" } /api/v1/wrapped/play-end: post: tags: [Stats] summary: Log a natural play end (Velvet) requestBody: required: true content: application/json: schema: type: object required: [eventId, playedMs] properties: eventId: { type: string } playedMs: { type: integer } responses: "200": { $ref: "#/components/responses/EmptySuccess" } /api/v1/wrapped/play-skip: post: tags: [Stats] summary: Log an explicit skip (Velvet) requestBody: required: true content: application/json: schema: type: object required: [eventId, playedMs] properties: eventId: { type: string } playedMs: { type: integer } responses: "200": { $ref: "#/components/responses/EmptySuccess" } /api/v1/wrapped/pause: post: tags: [Stats] summary: Increment pause count (Velvet) requestBody: required: true content: application/json: schema: type: object required: [eventId] properties: eventId: { type: string } responses: "200": { $ref: "#/components/responses/EmptySuccess" } # ── Playlists ──────────────────────────────────────────────────────────── /api/v1/playlist/load: post: tags: [Playlists] summary: Load a playlist description: Returns the playlist's tracks with full metadata. requestBody: required: true content: application/json: schema: type: object required: [playlistname] properties: playlistname: { type: string } responses: "200": description: Tracks with a stable per-entry `id` for the playlist-tracks row. content: application/json: schema: type: array items: allOf: - { $ref: "#/components/schemas/Metadata" } - type: object properties: id: { oneOf: [ { type: integer }, { type: string } ] } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } /api/v1/playlist/delete: post: tags: [Playlists] summary: Delete a playlist requestBody: required: true content: application/json: schema: type: object required: [playlistname] properties: playlistname: { type: string } responses: "200": { $ref: "#/components/responses/EmptySuccess" } "401": { $ref: "#/components/responses/Unauthorized" } /api/v1/playlist/add-song: post: tags: [Playlists] summary: Add a song to a playlist description: Creates the playlist if it doesn't exist. requestBody: required: true content: application/json: schema: type: object required: [song, playlist] properties: song: { type: string, description: "Virtual path." } playlist: { type: string } responses: "200": { $ref: "#/components/responses/EmptySuccess" } "401": { $ref: "#/components/responses/Unauthorized" } /api/v1/playlist/remove-song: post: tags: [Playlists] summary: Remove a track from a playlist description: Uses the per-entry `id` returned by `POST /playlist/load`. requestBody: required: true content: application/json: schema: type: object required: [id] properties: id: oneOf: - { type: integer } - { type: string } responses: "200": { $ref: "#/components/responses/EmptySuccess" } "401": { $ref: "#/components/responses/Unauthorized" } /api/v1/playlist/new: post: tags: [Playlists] summary: Create an empty playlist requestBody: required: true content: application/json: schema: type: object required: [title] properties: title: { type: string } responses: "200": { $ref: "#/components/responses/EmptySuccess" } "400": description: Playlist already exists. content: application/json: schema: { $ref: "#/components/schemas/Error" } "401": { $ref: "#/components/responses/Unauthorized" } /api/v1/playlist/save: post: tags: [Playlists] summary: Create or overwrite a playlist with a list of tracks requestBody: required: true content: application/json: schema: type: object required: [title, songs] properties: title: { type: string } songs: type: array items: { type: string, description: "Virtual path." } live: type: boolean description: Whether this is a live-synced queue. responses: "200": { $ref: "#/components/responses/EmptySuccess" } "401": { $ref: "#/components/responses/Unauthorized" } /api/v1/playlist/rename: post: tags: [Playlists] summary: Rename a playlist requestBody: required: true content: application/json: schema: type: object required: [oldName, newName] properties: oldName: { type: string } newName: { type: string } responses: "200": { $ref: "#/components/responses/EmptySuccess" } "400": description: New name already in use. content: application/json: schema: { $ref: "#/components/schemas/Error" } "401": { $ref: "#/components/responses/Unauthorized" } /api/v1/playlist/getall: get: tags: [Playlists] summary: List the user's playlists responses: "200": description: Playlists. content: application/json: schema: type: array items: type: object properties: name: { type: string } "401": { $ref: "#/components/responses/Unauthorized" } # ── Smart Playlists ────────────────────────────────────────────────────── /api/v1/smart-playlists: get: tags: [Smart Playlists] summary: List smart playlists (Velvet) responses: "200": description: Smart playlists. content: application/json: schema: type: object properties: playlists: type: array items: type: object properties: id: { type: integer } name: { type: string } filters: { type: object } sort: { type: string } limit_n: { type: integer, nullable: true } post: tags: [Smart Playlists] summary: Create a smart playlist (Velvet) requestBody: required: true content: application/json: schema: type: object required: [name] properties: name: { type: string } filters: { type: object } sort: { type: string } limit: { type: integer } responses: "200": description: Created. content: application/json: schema: type: object properties: id: { type: integer } /api/v1/smart-playlists/{id}: parameters: - name: id in: path required: true schema: { type: integer } put: tags: [Smart Playlists] summary: Update a smart playlist (Velvet) requestBody: required: true content: application/json: schema: type: object properties: name: { type: string } filters: { type: object } sort: { type: string } limit: { type: integer } responses: "200": { $ref: "#/components/responses/EmptySuccess" } delete: tags: [Smart Playlists] summary: Delete a smart playlist (Velvet) responses: "200": { $ref: "#/components/responses/EmptySuccess" } /api/v1/smart-playlists/run: post: tags: [Smart Playlists] summary: Execute a smart-playlist query (Velvet) requestBody: required: true content: application/json: schema: type: object required: [filters] properties: filters: { type: object } sort: { type: string } limit: { type: integer } responses: "200": description: Matching tracks. content: application/json: schema: type: object properties: songs: type: array items: { $ref: "#/components/schemas/Metadata" } /api/v1/smart-playlists/count: post: tags: [Smart Playlists] summary: Count tracks matching a smart-playlist query (Velvet) requestBody: required: true content: application/json: schema: type: object required: [filters] properties: filters: { type: object } responses: "200": description: Count. content: application/json: schema: type: object properties: count: { type: integer } # ── Shared Playlists ───────────────────────────────────────────────────── /shared/{playlistId}: get: tags: [Shared Playlists] summary: Shared-playlist viewer page description: Public HTML page for viewing a shared playlist. security: [] parameters: - name: playlistId in: path required: true schema: { type: string } responses: "200": description: HTML. content: text/html: schema: { type: string } "404": { $ref: "#/components/responses/NotFound" } /api/v1/shared/{playlistId}: get: tags: [Shared Playlists] summary: Fetch shared-playlist data description: Returns the playlist contents and a restricted JWT that can only play those tracks. security: [] parameters: - name: playlistId in: path required: true schema: { type: string } responses: "200": description: Playlist data and token. content: application/json: schema: type: object properties: token: { type: string } playlist: type: array items: { type: string } "404": { $ref: "#/components/responses/NotFound" } /api/v1/share: post: tags: [Shared Playlists] summary: Create a shareable link requestBody: required: true content: application/json: schema: type: object required: [playlist] properties: playlist: type: array items: { type: string } time: type: integer description: Expiry in days. Omit for an eternal link. responses: "200": description: Share info. content: application/json: schema: type: object properties: playlistId: { type: string } playlist: type: array items: { type: string } user: { type: string } expires: type: integer nullable: true token: { type: string } "401": { $ref: "#/components/responses/Unauthorized" } /api/v1/share/list: get: tags: [Shared Playlists] summary: List the user's shared playlists (Velvet) responses: "200": description: Share records. content: application/json: schema: type: array items: type: object properties: playlistId: { type: string } songCount: { type: integer } expires: type: integer nullable: true createdAt: { type: string } /api/v1/share/{id}: delete: tags: [Shared Playlists] summary: Delete a shared playlist (Velvet) parameters: - name: id in: path required: true schema: { type: string } responses: "200": { $ref: "#/components/responses/EmptySuccess" } # ── File Explorer ──────────────────────────────────────────────────────── /api/v1/file-explorer: post: tags: [File Explorer] summary: Browse a directory within the user's libraries description: Pass an empty string or `~` to list vpaths / home. requestBody: required: true content: application/json: schema: type: object required: [directory] properties: directory: type: string sort: type: boolean default: true pullMetadata: type: boolean default: false responses: "200": description: Directory listing. content: application/json: schema: type: object properties: path: { type: string } directories: { type: array, items: { type: object } } files: { type: array, items: { type: object } } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } /api/v1/file-explorer/recursive: post: tags: [File Explorer] summary: Recursively list all audio files under a directory requestBody: required: true content: application/json: schema: type: object required: [directory] properties: directory: { type: string } responses: "200": description: Array of virtual paths. content: application/json: schema: type: array items: { type: string } "401": { $ref: "#/components/responses/Unauthorized" } /api/v1/file-explorer/mkdir: post: tags: [File Explorer] summary: Create a directory description: Requires per-user mkdir permission. Recursive. requestBody: required: true content: application/json: schema: type: object required: [directory] properties: directory: { type: string } responses: "200": { $ref: "#/components/responses/EmptySuccess" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } /api/v1/file-explorer/upload: post: tags: [File Explorer] summary: Upload a file description: | Multipart form upload via busboy. Target directory goes in the `data-location` header. Requires per-user upload permission. Filenames are sanitized to prevent path traversal. parameters: - name: data-location in: header required: true schema: { type: string } description: Target directory as virtual path. requestBody: required: true content: multipart/form-data: schema: type: object properties: file: type: string format: binary responses: "200": { $ref: "#/components/responses/EmptySuccess" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } /api/v1/file-explorer/m3u: post: tags: [File Explorer] summary: Parse an M3U playlist file description: Returns resolved paths for each entry, sanitized against directory traversal. requestBody: required: true content: application/json: schema: type: object required: [path] properties: path: { type: string } responses: "200": description: Parsed entries. content: application/json: schema: type: object properties: files: type: array items: type: object properties: type: { type: string } name: { type: string } path: { type: string } "401": { $ref: "#/components/responses/Unauthorized" } # ── Download ───────────────────────────────────────────────────────────── /api/v1/download/m3u: post: tags: [Download] summary: Download an M3U playlist + all its tracks as a ZIP requestBody: required: true content: application/json: schema: type: object required: [path] properties: path: { type: string } responses: "200": description: ZIP archive. content: application/zip: schema: { type: string, format: binary } "401": { $ref: "#/components/responses/Unauthorized" } /api/v1/download/directory: post: tags: [Download] summary: Download a directory as a ZIP requestBody: required: true content: application/json: schema: type: object required: [directory] properties: directory: { type: string } responses: "200": description: ZIP archive. content: application/zip: schema: { type: string, format: binary } "401": { $ref: "#/components/responses/Unauthorized" } /api/v1/download/shared: get: tags: [Download] summary: Download all files in a shared playlist description: Requires a shared-playlist token (issued by `GET /api/v1/shared/{playlistId}`). responses: "200": description: ZIP archive. content: application/zip: schema: { type: string, format: binary } "401": { $ref: "#/components/responses/Unauthorized" } /api/v1/download/zip: post: tags: [Download] summary: Download an arbitrary list of files as a ZIP requestBody: required: true content: application/x-www-form-urlencoded: schema: type: object required: [fileArray] properties: fileArray: type: string description: JSON-stringified array of virtual paths. responses: "200": description: ZIP archive. content: application/zip: schema: { type: string, format: binary } "401": { $ref: "#/components/responses/Unauthorized" } # ── Transcode ──────────────────────────────────────────────────────────── /transcode/{filepath}: get: tags: [Transcode] summary: Transcode audio on the fly description: | Streams a transcoded version of the audio file. Streaming begins before transcoding finishes. Results are cached for the song's duration plus 2 minutes. parameters: - name: filepath in: path required: true description: Virtual path (may contain `/`). schema: { type: string } - name: codec in: query schema: type: string enum: [mp3, opus, aac] - name: bitrate in: query schema: type: string enum: ["64k", "96k", "128k", "192k"] responses: "200": description: Transcoded audio stream. headers: Content-Type: { schema: { type: string } } Content-Length: { schema: { type: integer } } Accept-Ranges: { schema: { type: string } } content: audio/*: schema: { type: string, format: binary } "401": { $ref: "#/components/responses/Unauthorized" } "503": description: ffmpeg not available. content: application/json: schema: { $ref: "#/components/schemas/Error" } # ── Album Art ──────────────────────────────────────────────────────────── /album-art/{file}: get: tags: [Album Art] summary: Serve a cached album-art file security: [] parameters: - name: file in: path required: true schema: { type: string } - name: compress in: query description: '`zl` = large thumbnail, `zs` = small thumbnail. Omit for full-size.' schema: type: string enum: [zl, zs] responses: "200": description: Image. content: image/*: schema: { type: string, format: binary } "404": { $ref: "#/components/responses/NotFound" } /api/v1/album-art/search: post: tags: [Album Art] summary: Search external services for album art description: Queries MusicBrainz, iTunes, and Deezer. requestBody: required: true content: application/json: schema: type: object properties: artist: { type: string } album: { type: string } responses: "200": description: Search results. content: application/json: schema: type: object properties: results: type: array items: type: object properties: service: { type: string, enum: [musicbrainz, itunes, deezer] } url: { type: string } label: { type: string } ffmpegAvailable: { type: boolean } /api/v1/album-art/set-from-url: post: tags: [Album Art] summary: Download album art from a URL and apply it requestBody: required: true content: application/json: schema: type: object required: [filepath, url] properties: filepath: { type: string } url: { type: string, format: uri } writeToFolder: { type: boolean, default: false } writeToFile: type: boolean default: false description: Embed into the ID3 tag (needs ffmpeg and write permission). responses: "200": description: Success. content: application/json: schema: type: object properties: ok: { type: boolean, enum: [true] } "400": { $ref: "#/components/responses/Forbidden" } /api/v1/album-art/upload: post: tags: [Album Art] summary: Upload album art as base64 requestBody: required: true content: application/json: schema: type: object required: [filepath, image] properties: filepath: { type: string } image: type: string description: Base64-encoded image. writeToFolder: { type: boolean, default: false } writeToFile: { type: boolean, default: false } responses: "200": description: Success. content: application/json: schema: type: object properties: ok: { type: boolean, enum: [true] } /api/v1/album-art/ffmpeg-status: get: tags: [Album Art] summary: Check ffmpeg availability responses: "200": description: Availability. content: application/json: schema: type: object properties: available: { type: boolean } # ── Waveform ───────────────────────────────────────────────────────────── /api/v1/db/waveform: get: tags: [Waveform] summary: Get waveform peaks for a track description: 800 bars, 0–255 each. Generated on demand and cached. parameters: - name: filepath in: query required: true schema: { type: string } responses: "200": description: Waveform data. content: application/json: schema: type: object properties: waveform: type: array items: { type: integer, minimum: 0, maximum: 255 } minItems: 800 maxItems: 800 "401": { $ref: "#/components/responses/Unauthorized" } # ── Lyrics ────────────────────────────────────────────────────────────── /api/v1/lyrics: get: tags: [Lyrics] summary: Resolve lyrics for a track (Velvet UI shape) description: | Returns lyrics in the shape the Velvet UI expects. Lookup precedence (all scoped to the caller's accessible libraries): 1. `filepath` query param — exact match on `tracks.filepath` (or `<vpath>/<relpath>` joined form). 2. `artist` + `title` — case-insensitive substring match. 3. Filename fallback: if `title` looks like `"Artist - Song.mp3"` we strip the extension and split on ` - `. 4. Nothing matched → `{ notFound: true }`. Data comes from (in order): embedded tag columns, LRCLib cache hit, or empty (with a background fetch enqueued if LRCLib is enabled). See `getLyricsBySongId` on the Subsonic API for the ms-offset variant. parameters: - name: filepath in: query schema: { type: string } description: "`<vpath>/<relpath>` or just `<relpath>`." - name: artist in: query schema: { type: string } - name: title in: query schema: { type: string } - name: duration in: query schema: { type: integer, minimum: 0 } description: Seconds. Used to pick the exact-match LRCLib result if needed. responses: "200": description: Lyrics (synced or plain) or a `notFound` marker. content: application/json: schema: oneOf: - type: object required: [synced, lines] properties: synced: { type: boolean, enum: [true] } lines: type: array items: type: object required: [time, text] properties: time: { type: number, description: "Seconds (float)." } text: { type: string } - type: object required: [synced, lines] properties: synced: { type: boolean, enum: [false] } lines: type: array items: type: object required: [time, text] properties: time: type: number nullable: true description: "Always null on the unsynced variant." text: { type: string } - type: object required: [notFound] properties: notFound: { type: boolean, enum: [true] } "401": { $ref: "#/components/responses/Unauthorized" } # ── User API Keys ─────────────────────────────────────────────────────── /api/v1/user/api-keys: get: tags: [User API Keys] summary: List the caller's own Subsonic API keys description: | Returns an array (not a wrapped object) of keys the caller has minted. Key strings are never returned — those are view-once at creation time; after that only id/name/timestamps are visible. responses: "200": description: Keys minted by this user. Key values NOT returned. content: application/json: schema: type: array items: type: object properties: id: { type: integer } name: { type: string } created_at: { type: string, format: date-time } last_used: { type: string, format: date-time, nullable: true } "401": { $ref: "#/components/responses/Unauthorized" } post: tags: [User API Keys] summary: Mint a new Subsonic API key for the caller description: | The key value is returned exactly once in this response — after this request completes, the server only stores a reference and the key cannot be re-displayed. Clients that can't send a JWT cookie (DSub, Ultrasonic, Substreamer, etc.) paste the key into their `password`/`apiKey` field. requestBody: required: true content: application/json: schema: type: object required: [name] properties: name: type: string minLength: 1 maxLength: 100 description: Free-form label so the user can tell keys apart. Required. responses: "200": description: One-time view of the freshly-minted key. content: application/json: schema: type: object required: [key, name] properties: key: { type: string } name: { type: string } "401": { $ref: "#/components/responses/Unauthorized" } "403": { description: Validation error (missing/blank `name`). } /api/v1/user/api-keys/{id}: delete: tags: [User API Keys] summary: Revoke a Subsonic API key parameters: - name: id in: path required: true schema: { type: integer } responses: "200": { description: Key revoked. } "401": { $ref: "#/components/responses/Unauthorized" } "404": { description: No such key (or not owned by caller). } # ── Cue Points ─────────────────────────────────────────────────────────── /api/v1/db/cuepoints: get: tags: [Cue Points] summary: List cue points for a track (Velvet) parameters: - name: fp in: query required: true schema: { type: string } responses: "200": description: Cue points. content: application/json: schema: type: object properties: cuepoints: type: array items: type: object properties: id: { type: integer } no: { type: integer } title: { type: string, nullable: true } t: { type: number, description: "Position in seconds." } color: { type: string, nullable: true } post: tags: [Cue Points] summary: Create a cue point (Velvet) requestBody: required: true content: application/json: schema: type: object required: [filepath, position] properties: filepath: { type: string } position: { type: number, description: "Seconds from start." } label: { type: string } color: { type: string } responses: "200": description: Created. content: application/json: schema: type: object properties: id: { type: integer } /api/v1/db/cuepoints/{id}: parameters: - name: id in: path required: true schema: { type: integer } put: tags: [Cue Points] summary: Update a cue point (Velvet) requestBody: required: true content: application/json: schema: type: object properties: position: { type: number } label: { type: string } color: { type: string } responses: "200": description: Success. content: application/json: schema: type: object properties: ok: { type: boolean, enum: [true] } delete: tags: [Cue Points] summary: Delete a cue point (Velvet) responses: "200": description: Success. content: application/json: schema: type: object properties: ok: { type: boolean, enum: [true] } # ── User Settings ──────────────────────────────────────────────────────── /api/v1/user/settings: get: tags: [User Settings] summary: Get the user's UI preferences (Velvet) responses: "200": description: Prefs and optional queue. content: application/json: schema: type: object properties: prefs: type: object additionalProperties: { type: string } queue: type: array items: { type: string } post: tags: [User Settings] summary: Save UI preferences / queue (Velvet) requestBody: required: true content: application/json: schema: type: object properties: prefs: type: object additionalProperties: { type: string } queue: type: array items: { type: string } responses: "200": description: Success. content: application/json: schema: type: object properties: ok: { type: boolean, enum: [true] } # ── Last.fm ────────────────────────────────────────────────────────────── /api/v1/lastfm/status: get: tags: [Scrobbler - Last.fm] summary: Probe Last.fm availability for this user description: | Tells the webapp Auto-DJ panel whether Last.fm features are actually usable: the server-side API key (`hasApiKey`) gates every `artist.getSimilar` call, and `linkedUser` reports whether the current user has stored credentials for personal scrobbling. Both fields drive UI visibility — the similar- artists toggle renders disabled when `hasApiKey === false`. Returned values are read-only state, no auth side-effects. responses: "200": description: Last.fm availability + this user's link state. content: application/json: schema: type: object properties: serverEnabled: type: boolean description: | Whether the operator has configured a Last.fm API key (`lastFM.apiKey` in config). Without this, `similar-artists` returns empty and the webapp Auto-DJ "Similar artists" toggle should be disabled. hasApiKey: type: boolean description: | Duplicate of `serverEnabled` kept for clients reading the original velvet shape. linkedUser: type: string nullable: true description: | The user's stored Last.fm username, or `null` if they haven't linked an account via `POST /api/v1/lastfm/connect`. Used only for personal scrobbling — Auto-DJ similar-artists doesn't depend on this. /api/v1/lastfm/connect: post: tags: [Scrobbler - Last.fm] summary: Link Last.fm account (Velvet) requestBody: required: true content: application/json: schema: type: object required: [lastfmUser, lastfmPassword] properties: lastfmUser: { type: string } lastfmPassword: { type: string } responses: "200": description: Linked. content: application/json: schema: type: object properties: ok: { type: boolean, enum: [true] } /api/v1/lastfm/disconnect: post: tags: [Scrobbler - Last.fm] summary: Unlink Last.fm account responses: "200": description: Unlinked. content: application/json: schema: type: object properties: ok: { type: boolean, enum: [true] } /api/v1/lastfm/test-login: post: tags: [Scrobbler - Last.fm] summary: Validate Last.fm credentials before saving requestBody: required: true content: application/json: schema: type: object required: [username, password] properties: username: { type: string } password: { type: string } responses: "200": { $ref: "#/components/responses/EmptySuccess" } "401": { $ref: "#/components/responses/Unauthorized" } /api/v1/lastfm/similar-artists: get: tags: [Scrobbler - Last.fm] summary: Get similar artists (filtered to local library) description: | Calls Last.fm's `artist.getSimilar`, then intersects the result with the local library — returns only the artists that actually exist in the user's tracks, using each one's canonical library spelling. Pair with `POST /api/v1/db/random-songs`'s `artists` body field for Auto-DJ similar-artists picks. Matching is fuzzy: case-insensitive, diacritic-folded (`Sigur Ros` ≡ `Sigur Rós`), `&`/`and` interchangeable, dots/slashes stripped (`M.I.A.` ≡ `MIA`, `AC/DC` ≡ `ACDC`). So a Last.fm result of "Beyoncé" matches a library row tagged "Beyonce", and the response contains the library's spelling. Returns `{ artists: [] }` (NOT a 4xx) when any of: - `artist` query param is missing - no Last.fm API key configured in `lastFM.apiKey` - Last.fm HTTP call failed or returned malformed JSON - Last.fm has no similar artists for this name - none of the similar artists exist in the local library Results are cached per-artist for 24 hours (LRU, 500-entry cap) to stay friendly to Last.fm's rate limit. parameters: - name: artist in: query required: true schema: { type: string } description: | Source artist name. `feat. X` / `ft. X` / `featuring X` / `vs. X` suffixes are stripped before the Last.fm call. responses: "200": description: Library artists similar to the input (may be empty). content: application/json: schema: type: object properties: artists: type: array description: | Canonical library artist names — suitable for use directly in `/api/v1/db/random-songs` `artists`. items: { type: string } /api/v1/lastfm/scrobble-by-metadata: post: tags: [Scrobbler - Last.fm] summary: Scrobble a play by metadata requestBody: required: true content: application/json: schema: type: object required: [track] properties: artist: { type: string } album: { type: string } track: { type: string } responses: "200": description: Scrobbled or skipped if user isn't linked. content: application/json: schema: type: object properties: scrobble: type: boolean nullable: true /api/v1/lastfm/scrobble-by-filepath: post: tags: [Scrobbler - Last.fm] summary: Scrobble a play by filepath requestBody: required: true content: application/json: schema: type: object required: [filePath] properties: filePath: { type: string } responses: "200": description: Scrobbled or skipped if user isn't linked. content: application/json: schema: type: object properties: scrobble: type: boolean nullable: true # ── ListenBrainz ───────────────────────────────────────────────────────── /api/v1/listenbrainz/status: get: tags: [Scrobbler - ListenBrainz] summary: ListenBrainz integration status responses: "200": description: Status. content: application/json: schema: type: object properties: serverEnabled: { type: boolean } linked: { type: boolean } /api/v1/listenbrainz/connect: post: tags: [Scrobbler - ListenBrainz] summary: Link a ListenBrainz account requestBody: required: true content: application/json: schema: type: object required: [lbToken] properties: lbToken: { type: string } responses: "200": description: Linked. content: application/json: schema: type: object properties: ok: { type: boolean, enum: [true] } /api/v1/listenbrainz/disconnect: post: tags: [Scrobbler - ListenBrainz] summary: Unlink ListenBrainz responses: "200": description: Unlinked. content: application/json: schema: type: object properties: ok: { type: boolean, enum: [true] } /api/v1/listenbrainz/playing-now: post: tags: [Scrobbler - ListenBrainz] summary: Announce now-playing to ListenBrainz requestBody: required: true content: application/json: schema: type: object required: [filePath] properties: filePath: { type: string } responses: "200": description: Success. content: application/json: schema: type: object properties: ok: { type: boolean, enum: [true] } /api/v1/listenbrainz/scrobble-by-filepath: post: tags: [Scrobbler - ListenBrainz] summary: Scrobble a completed track to ListenBrainz requestBody: required: true content: application/json: schema: type: object required: [filePath] properties: filePath: { type: string } responses: "200": description: Success. content: application/json: schema: type: object properties: ok: { type: boolean, enum: [true] } # ── Discogs ────────────────────────────────────────────────────────────── /api/v1/discogs/coverart: get: tags: [Discogs] summary: Search Discogs for album art parameters: - name: artist in: query schema: { type: string } - name: title in: query schema: { type: string } - name: album in: query schema: { type: string } - name: year in: query schema: { type: integer } responses: "200": description: Choices. content: application/json: schema: type: object properties: choices: type: array items: type: object properties: releaseId: { type: string } releaseTitle: { type: string } year: { type: integer } thumbB64: { type: string } coverImage: { type: string } /api/v1/deezer/search: get: tags: [Discogs] summary: Search Deezer (part of the Discogs art-lookup flow) parameters: - name: q in: query required: true schema: { type: string } responses: "200": description: Deezer response. content: application/json: schema: type: object properties: data: type: array items: { type: object } /api/v1/discogs/embed: post: tags: [Discogs] summary: Embed album art from Discogs / Deezer / URL requestBody: required: true content: application/json: schema: type: object required: [filepath] properties: filepath: { type: string } releaseId: { type: string } coverUrl: { type: string } responses: "200": description: Success. content: application/json: schema: type: object properties: aaFile: { type: string, description: "Filename of the cached art." } # ── Server Playback (Rust audio proxy) ─────────────────────────────────── /api/v1/server-playback/pause: post: tags: [Server Playback] summary: Pause playback responses: { "200": { description: Proxied response from the Rust audio server. } } /api/v1/server-playback/resume: post: tags: [Server Playback] summary: Resume playback responses: { "200": { description: Proxied response. } } /api/v1/server-playback/stop: post: tags: [Server Playback] summary: Stop and clear queue responses: { "200": { description: Proxied response. } } /api/v1/server-playback/next: post: tags: [Server Playback] summary: Next track responses: { "200": { description: Proxied response. } } /api/v1/server-playback/previous: post: tags: [Server Playback] summary: Previous track responses: { "200": { description: Proxied response. } } /api/v1/server-playback/loop: post: tags: [Server Playback] summary: Toggle loop mode responses: { "200": { description: Proxied response. } } /api/v1/server-playback/seek: post: tags: [Server Playback] summary: Seek requestBody: content: application/json: schema: type: object properties: position: { type: number, description: "Seconds." } responses: { "200": { description: Proxied response. } } /api/v1/server-playback/volume: post: tags: [Server Playback] summary: Set volume requestBody: content: application/json: schema: type: object properties: volume: { type: number, minimum: 0, maximum: 1 } responses: { "200": { description: Proxied response. } } /api/v1/server-playback/shuffle: post: tags: [Server Playback] summary: Shuffle queue responses: { "200": { description: Proxied response. } } /api/v1/server-playback/status: get: tags: [Server Playback] summary: Get playback state responses: { "200": { description: Proxied response. } } /api/v1/server-playback/queue: get: tags: [Server Playback] summary: Get the current queue description: Absolute paths are translated back to virtual paths. responses: "200": description: Queue. content: application/json: schema: type: object properties: queue: type: array items: { type: string } /api/v1/server-playback/play: post: tags: [Server Playback] summary: Clear queue and play a file requestBody: required: true content: application/json: schema: type: object required: [file] properties: file: { type: string } responses: "200": { description: Proxied response. } "400": { $ref: "#/components/responses/Forbidden" } "503": description: Rust audio server unavailable. content: application/json: schema: { $ref: "#/components/schemas/Error" } /api/v1/server-playback/queue/add: post: tags: [Server Playback] summary: Add one file to the queue requestBody: required: true content: application/json: schema: type: object required: [file] properties: file: { type: string } responses: { "200": { description: Proxied response. } } /api/v1/server-playback/queue/add-many: post: tags: [Server Playback] summary: Add multiple files to the queue requestBody: required: true content: application/json: schema: type: object required: [files] properties: files: type: array items: { type: string } responses: { "200": { description: Proxied response. } } /api/v1/server-playback/queue/play-index: post: tags: [Server Playback] summary: Jump to a queue index requestBody: required: true content: application/json: schema: type: object required: [index] properties: index: { type: integer } responses: { "200": { description: Proxied response. } } /api/v1/server-playback/queue/remove: post: tags: [Server Playback] summary: Remove one track from the queue requestBody: required: true content: application/json: schema: type: object required: [index] properties: index: { type: integer } responses: { "200": { description: Proxied response. } } /api/v1/server-playback/queue/clear: post: tags: [Server Playback] summary: Clear the queue responses: { "200": { description: Proxied response. } } /server-remote: get: tags: [Server Playback] summary: Server-audio webapp description: Static HTML for the server-audio player mode. security: [] responses: "200": description: HTML. content: text/html: schema: { type: string } "503": description: Rust audio server unavailable. # ── Jukebox ────────────────────────────────────────────────────────────── /api/v1/jukebox/push-to-client: post: tags: [Jukebox] summary: Send a command to a connected remote description: Commands include `next`, `previous`, `playPause`, `play`. requestBody: required: true content: application/json: schema: type: object required: [code, command] properties: code: { type: string, description: "Jukebox code." } command: { type: string } file: { type: string } responses: "200": { $ref: "#/components/responses/EmptySuccess" } /api/v1/jukebox/update-playlist: post: tags: [Jukebox] summary: Cache the current playlist for a jukebox code requestBody: required: true content: application/json: schema: type: object required: [code, playlist] properties: code: { type: string } playlist: { type: array, items: { type: object } } responses: "200": { $ref: "#/components/responses/EmptySuccess" } /api/v1/jukebox/update-now-playing: post: tags: [Jukebox] summary: Cache the now-playing state for a jukebox code requestBody: required: true content: application/json: schema: type: object required: [code, nowPlaying] properties: code: { type: string } nowPlaying: type: object properties: title: { type: string, nullable: true } artist: { type: string, nullable: true } album: { type: string, nullable: true } albumArt: { type: string, nullable: true } filepath: { type: string } playing: { type: boolean } index: { type: integer } currentTime: { type: number } duration: { type: number } responses: "200": { $ref: "#/components/responses/EmptySuccess" } /api/v1/jukebox/does-code-exist: post: tags: [Jukebox] summary: Check if a jukebox code is active security: [] requestBody: required: true content: application/json: schema: type: object required: [code] properties: code: { type: string } responses: "200": description: Status. content: application/json: schema: type: object properties: status: { type: boolean } token: { type: string } /api/v1/jukebox/get-playlist: get: tags: [Jukebox] summary: Fetch the cached playlist for a code security: [] parameters: - name: code in: query required: true schema: { type: string } responses: "200": description: Playlist. content: application/json: schema: type: object properties: playlist: type: array items: { type: object } /api/v1/jukebox/get-now-playing: get: tags: [Jukebox] summary: Fetch the cached now-playing state security: [] parameters: - name: code in: query required: true schema: { type: string } responses: "200": description: Now-playing. content: application/json: schema: type: object properties: nowPlaying: type: object nullable: true /remote/{remoteId}: get: tags: [Jukebox] summary: Serve the remote-control webapp security: [] parameters: - name: remoteId in: path required: true schema: { type: string } responses: "200": description: HTML. content: text/html: schema: { type: string } # ── YouTube DL ─────────────────────────────────────────────────────────── /api/v1/ytdl: post: tags: [YouTube DL] summary: Start a YouTube download description: | Downloads the video's audio track, converts it to the requested format, and drops it into the target directory. Requires upload permission. Returns immediately — track progress via `GET /api/v1/ytdl/downloads`. requestBody: required: true content: application/json: schema: type: object required: [directory, url] properties: directory: { type: string } url: { type: string, format: uri } outputCodec: type: string enum: [mp3, opus, aac] default: mp3 metadata: type: object properties: title: { type: string } artist: { type: string } album: { type: string } year: { type: integer } responses: "200": description: Download enqueued. content: application/json: schema: type: object properties: message: { type: string } /api/v1/ytdl/metadata: get: tags: [YouTube DL] summary: Fetch YouTube video metadata parameters: - name: url in: query required: true schema: { type: string, format: uri } responses: "200": description: Metadata. content: application/json: schema: type: object properties: title: { type: string, nullable: true } artist: { type: string, nullable: true } album: { type: string, nullable: true } year: { type: integer, nullable: true } thumbnail: { type: string, nullable: true } /api/v1/ytdl/info: get: tags: [YouTube DL] summary: Velvet-adapter for metadata fetch parameters: - name: url in: query required: true schema: { type: string, format: uri } responses: "200": description: Metadata (field `thumb` not `thumbnail`). content: application/json: schema: type: object properties: title: { type: string, nullable: true } artist: { type: string, nullable: true } album: { type: string, nullable: true } thumb: { type: string, nullable: true } /api/v1/ytdl/downloads: get: tags: [YouTube DL] summary: List in-progress and recent downloads responses: "200": description: Downloads. content: application/json: schema: type: object properties: downloads: type: array items: type: object properties: pid: { type: integer } url: { type: string } directory: { type: string } outputCodec: { type: string } status: { type: string } startTime: { type: integer } /api/v1/ytdl/download: post: tags: [YouTube DL] summary: Synchronous YouTube download (Velvet) description: Blocks up to 5 minutes waiting for the download to finish. requestBody: required: true content: application/json: schema: type: object required: [url] properties: url: { type: string, format: uri } title: { type: string } artist: { type: string } album: { type: string } format: type: string enum: [mp3, opus, aac] responses: "200": description: Download complete. content: application/json: schema: type: object properties: filePath: { type: string } vpath: { type: string } # ── Admin - Scanner ────────────────────────────────────────────────────── /api/v1/admin/db/params: get: tags: [Admin - Scanner] summary: Get scanner parameters responses: "200": description: Current scanner settings. content: application/json: schema: { $ref: "#/components/schemas/ScanOptions" } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/db/params/scan-interval: post: tags: [Admin - Scanner] summary: Set scan interval requestBody: required: true content: application/json: schema: type: object required: [scanInterval] properties: scanInterval: { type: integer, minimum: 0, description: "Hours. 0 disables scheduled rescans." } responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/db/params/skip-img: post: tags: [Admin - Scanner] summary: Set whether album art extraction is skipped requestBody: required: true content: application/json: schema: type: object required: [skipImg] properties: skipImg: { type: boolean } responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/db/params/boot-scan-delay: post: tags: [Admin - Scanner] summary: Set boot-scan delay requestBody: required: true content: application/json: schema: type: object required: [bootScanDelay] properties: bootScanDelay: { type: integer, minimum: 0, description: "Seconds." } responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/db/params/compress-image: post: tags: [Admin - Scanner] summary: Set whether album art thumbnails are generated requestBody: required: true content: application/json: schema: type: object required: [compressImage] properties: compressImage: { type: boolean } responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/db/params/scan-commit-interval: post: tags: [Admin - Scanner] summary: Set scanner commit cadence description: Number of files between DB commits during a scan. Lower = shorter write-lock holds. requestBody: required: true content: application/json: schema: type: object required: [scanCommitInterval] properties: scanCommitInterval: type: integer minimum: 1 description: | Tracks scanned per SQLite COMMIT. Soft-capped at 1000 server-side: values above 1000 are accepted but clamped to 1000 (with a warning logged) rather than rejected, so a typo in the admin UI doesn't 400. responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/db/params/analyze-bpm: post: tags: [Admin - Scanner] summary: Toggle scanner-time BPM + musical-key detection (stratum-dsp) description: | Enables or disables stratum-dsp analysis during scans. When true (default), the Rust scanner runs BPM + musical-key extraction on the same mono PCM buffer it decodes for the waveform cache, populating `tracks.bpm` / `tracks.musical_key` / `tracks.bpm_source = 'stratum'` for any file without tag-sourced values. Tag-sourced BPM/key always wins regardless of this flag — toggling off does not overwrite an existing TBPM/TKEY value. Audiobook-genre tracks and tracks outside the [30s, 30min] duration window are skipped by other gates in the scanner. Rust-only feature. The JS fallback scanner accepts the `analyzeBpm` field in its config but does not run analysis. To backfill BPM/key on a previously-scanned library after enabling this flag, trigger a force-rescan from the admin panel — the fast-path mtime check skips unchanged files otherwise. requestBody: required: true content: application/json: schema: type: object required: [analyzeBpm] properties: analyzeBpm: { type: boolean } responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/db/params/auto-album-art: post: tags: [Admin - Scanner] summary: Toggle auto album art lookup requestBody: required: true content: application/json: schema: type: object required: [autoAlbumArt] properties: autoAlbumArt: { type: boolean } responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/db/params/album-art-write-to-folder: post: tags: [Admin - Scanner] summary: Write fetched cover art as `cover.jpg` in the album folder requestBody: required: true content: application/json: schema: type: object required: [albumArtWriteToFolder] properties: albumArtWriteToFolder: { type: boolean } responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/db/params/album-art-write-to-file: post: tags: [Admin - Scanner] summary: Embed fetched cover art in audio files requestBody: required: true content: application/json: schema: type: object required: [albumArtWriteToFile] properties: albumArtWriteToFile: { type: boolean } responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/db/params/album-art-services: post: tags: [Admin - Scanner] summary: Select which services to query for album art requestBody: required: true content: application/json: schema: type: object required: [albumArtServices] properties: albumArtServices: type: array items: type: string enum: [musicbrainz, itunes, deezer] responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/db/force-compress-images: post: tags: [Admin - Scanner] summary: Re-run the album-art thumbnail compressor over the whole library responses: "200": description: Job status. content: application/json: schema: type: object properties: started: { type: boolean } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/db/scan/all: post: tags: [Admin - Scanner] summary: Scan all libraries responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/db/scan/force-rescan: post: tags: [Admin - Scanner] summary: Rescan all libraries ignoring mtimes responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/db/scan/stats: get: tags: [Admin - Scanner] summary: Total track count in the DB responses: "200": description: Count. content: application/json: schema: type: object properties: fileCount: { type: integer } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/db/scan/progress: get: tags: [Admin - Scanner] summary: Per-library scan progress (Velvet) responses: "200": description: Progress entries — one per running scan. content: application/json: schema: type: array items: type: object properties: vpath: { type: string } pct: { type: number } scanned: { type: integer } expected: { type: integer, nullable: true } currentFile: { type: string, nullable: true } countingFound: { type: boolean } "405": { $ref: "#/components/responses/AdminLocked" } # ── Admin - Users ──────────────────────────────────────────────────────── /api/v1/admin/users: get: tags: [Admin - Users] summary: List all users responses: "200": description: Users keyed by username. content: application/json: schema: type: object additionalProperties: { $ref: "#/components/schemas/User" } "405": { $ref: "#/components/responses/AdminLocked" } put: tags: [Admin - Users] summary: Create a user requestBody: required: true content: application/json: schema: type: object required: [username, password, vpaths] properties: username: { type: string } password: { type: string } vpaths: type: array items: { type: string } admin: { type: boolean, default: false } allowMkdir: { type: boolean, default: true } allowUpload: { type: boolean, default: true } responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } delete: tags: [Admin - Users] summary: Delete a user requestBody: required: true content: application/json: schema: type: object required: [username] properties: username: { type: string } responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/users/password: post: tags: [Admin - Users] summary: Change a user's password requestBody: required: true content: application/json: schema: type: object required: [username, password] properties: username: { type: string } password: { type: string } responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/users/lastfm: post: tags: [Admin - Users] summary: Set Last.fm credentials for a user description: Note — the field names contain a typo (`lasftfm*`) that is preserved for backwards compatibility. requestBody: required: true content: application/json: schema: type: object required: [username, lasftfmUser, lasftfmPassword] properties: username: { type: string } lasftfmUser: { type: string } lasftfmPassword: { type: string } responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/users/vpaths: post: tags: [Admin - Users] summary: Update a user's library access list requestBody: required: true content: application/json: schema: type: object required: [username, vpaths] properties: username: { type: string } vpaths: type: array items: { type: string } responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/users/access: post: tags: [Admin - Users] summary: Update a user's role / permission flags requestBody: required: true content: application/json: schema: type: object required: [username, admin, allowMkdir, allowUpload] properties: username: { type: string } admin: { type: boolean } allowMkdir: { type: boolean } allowUpload: { type: boolean } allowFileModify: { type: boolean, default: true } responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } # ── Admin - Libraries ──────────────────────────────────────────────────── /api/v1/admin/directories: get: tags: [Admin - Libraries] summary: List all libraries description: | Returns one of two shapes depending on caller: - Legacy (admin UI): `{ [vpath]: { root, type } }` - Velvet UI: array of `{ name, root, type }` when fetched via the Velvet route. responses: "200": description: Libraries. content: application/json: schema: oneOf: - type: object additionalProperties: type: object properties: root: { type: string } type: { type: string } - type: array items: { $ref: "#/components/schemas/Library" } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/directory: put: tags: [Admin - Libraries] summary: Add a library requestBody: required: true content: application/json: schema: type: object required: [directory, vpath] properties: directory: { type: string, description: "Absolute filesystem path." } vpath: type: string pattern: "[a-zA-Z0-9-]+" autoAccess: { type: boolean, default: false } isAudioBooks: { type: boolean, default: false } responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } delete: tags: [Admin - Libraries] summary: Remove a library requestBody: required: true content: application/json: schema: type: object required: [vpath] properties: vpath: type: string pattern: "[a-zA-Z0-9-]+" responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } # ── Admin - Config ─────────────────────────────────────────────────────── /api/v1/admin/lock-api: post: tags: [Admin - Config] summary: Lock or unlock the admin API requestBody: required: true content: application/json: schema: type: object required: [lock] properties: lock: { type: boolean } responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/config: get: tags: [Admin - Config] summary: Get the server configuration responses: "200": description: Config. content: application/json: schema: type: object properties: address: { type: string } port: { type: integer } noUpload: { type: boolean } noMkdir: { type: boolean } noFileModify: { type: boolean } writeLogs: { type: boolean } secret: type: string description: Last 4 characters only (the full secret is never returned). ssl: { type: object } storage: { type: object } maxRequestSize: { type: string } autoBootServerAudio: { type: boolean } rustPlayerPort: { type: integer } ui: { type: string, enum: [default, velvet] } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/config/max-request-size: post: tags: [Admin - Config] summary: Set max request body size requestBody: required: true content: application/json: schema: type: object required: [maxRequestSize] properties: maxRequestSize: type: string pattern: "[0-9]+(KB|MB)" example: "50MB" responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/config/ui: post: tags: [Admin - Config] summary: Switch UI requestBody: required: true content: application/json: schema: type: object required: [ui] properties: ui: type: string enum: [default, velvet] responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/config/port: post: tags: [Admin - Config] summary: Change server port description: Triggers a server restart. requestBody: required: true content: application/json: schema: type: object required: [port] properties: port: { type: integer } responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/config/address: post: tags: [Admin - Config] summary: Change listen address requestBody: required: true content: application/json: schema: type: object required: [address] properties: address: type: string format: ipv4 responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/config/noupload: post: tags: [Admin - Config] summary: Enable/disable uploads server-wide requestBody: required: true content: application/json: schema: type: object required: [noUpload] properties: noUpload: { type: boolean } responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/config/nomkdir: post: tags: [Admin - Config] summary: Enable/disable directory creation server-wide requestBody: required: true content: application/json: schema: type: object required: [noMkdir] properties: noMkdir: { type: boolean } responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/config/nofilemodify: post: tags: [Admin - Config] summary: Enable/disable file modification server-wide requestBody: required: true content: application/json: schema: type: object required: [noFileModify] properties: noFileModify: { type: boolean } responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/config/write-logs: post: tags: [Admin - Config] summary: Enable/disable writing logs to disk requestBody: required: true content: application/json: schema: type: object required: [writeLogs] properties: writeLogs: { type: boolean } responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/config/auto-boot-server-audio: post: tags: [Admin - Config] summary: Toggle auto-start of the Rust audio server requestBody: required: true content: application/json: schema: type: object required: [autoBootServerAudio] properties: autoBootServerAudio: { type: boolean } responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/config/rust-player-port: post: tags: [Admin - Config] summary: Set the Rust audio server port requestBody: required: true content: application/json: schema: type: object required: [rustPlayerPort] properties: rustPlayerPort: { type: integer, minimum: 1, maximum: 65535 } responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/config/secret: post: tags: [Admin - Config] summary: Regenerate the JWT secret description: Invalidates every existing token. requestBody: required: true content: application/json: schema: type: object required: [strength] properties: strength: type: integer minimum: 1 description: Byte length of the generated secret. responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } # ── Admin - Transcode ──────────────────────────────────────────────────── /api/v1/admin/transcode: get: tags: [Admin - Transcode] summary: Get transcode configuration responses: "200": description: Config + availability. content: application/json: schema: type: object properties: defaultCodec: { type: string } defaultBitrate: { type: string } downloaded: { type: boolean, description: "Whether ffmpeg has been downloaded." } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/transcode/default-codec: post: tags: [Admin - Transcode] summary: Set default transcode codec requestBody: required: true content: application/json: schema: type: object required: [defaultCodec] properties: defaultCodec: { type: string, enum: [mp3, opus, aac] } responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/transcode/default-bitrate: post: tags: [Admin - Transcode] summary: Set default transcode bitrate requestBody: required: true content: application/json: schema: type: object required: [defaultBitrate] properties: defaultBitrate: { type: string, enum: ["64k", "96k", "128k", "192k"] } responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/transcode/download: post: tags: [Admin - Transcode] summary: Download the bundled ffmpeg binary responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } # ── Admin - DLNA ───────────────────────────────────────────────────────── /api/v1/admin/dlna: get: tags: [Admin - DLNA] summary: DLNA configuration responses: "200": description: DLNA settings. content: application/json: schema: type: object properties: mode: { type: string, enum: [disabled, same-port, separate-port] } port: { type: integer } name: { type: string } uuid: { type: string } browse: { type: string, enum: [flat, dirs, artist, album, genre] } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/dlna/browse: post: tags: [Admin - DLNA] summary: Set the DLNA default browse structure requestBody: required: true content: application/json: schema: type: object required: [browse] properties: browse: { type: string, enum: [flat, dirs, artist, album, genre] } responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/dlna/mode: post: tags: [Admin - DLNA] summary: Enable/disable DLNA requestBody: required: true content: application/json: schema: type: object required: [mode] properties: mode: { type: string, enum: [disabled, same-port, separate-port] } port: { type: integer, minimum: 1, maximum: 65535 } responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } # ── Admin - SSL ────────────────────────────────────────────────────────── /api/v1/admin/ssl: post: tags: [Admin - SSL] summary: Upload SSL certificate and key requestBody: required: true content: application/json: schema: type: object required: [cert, key] properties: cert: { type: string, description: "Path to cert file." } key: { type: string, description: "Path to key file." } responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } delete: tags: [Admin - SSL] summary: Remove SSL certificates responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } # ── Admin - Logs ───────────────────────────────────────────────────────── /api/v1/admin/logs/download: get: tags: [Admin - Logs] summary: Download server logs as a ZIP responses: "200": description: ZIP. content: application/zip: schema: { type: string, format: binary } "405": { $ref: "#/components/responses/AdminLocked" } # ── Admin - Shared ─────────────────────────────────────────────────────── /api/v1/admin/db/shared: get: tags: [Admin - Shared] summary: List every shared playlist on the server responses: "200": description: Shared playlists. content: application/json: schema: type: array items: { type: object } "405": { $ref: "#/components/responses/AdminLocked" } delete: tags: [Admin - Shared] summary: Delete a shared playlist by id requestBody: required: true content: application/json: schema: type: object required: [id] properties: id: { type: string } responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/db/shared/expired: delete: tags: [Admin - Shared] summary: Delete all expired shared playlists responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/db/shared/eternal: delete: tags: [Admin - Shared] summary: Delete all non-expiring shared playlists responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } # ── Admin - File Explorer ──────────────────────────────────────────────── /api/v1/admin/file-explorer: post: tags: [Admin - File Explorer] summary: Browse the entire filesystem (admin only) requestBody: required: true content: application/json: schema: type: object required: [directory] properties: directory: { type: string, description: "Absolute path, or `~` for home." } joinDirectory: { type: string } responses: "200": description: Listing. content: application/json: schema: type: object properties: path: { type: string } directories: { type: array, items: { type: object } } files: { type: array, items: { type: object } } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/file-explorer/win-drives: get: tags: [Admin - File Explorer] summary: List Windows drive letters description: Returns an empty object on non-Windows hosts. responses: "200": description: Drive letters. content: application/json: schema: type: array items: { type: string, example: "C:\\" } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/directory/follow-symlinks: post: tags: [Admin - Libraries] summary: Set the per-library followSymlinks flag (V21) description: | When `true`, the scanner follows symbolic links found INSIDE this library (a symlink entry is treated as the file/directory it points at). When `false` (default), nested symlink entries are skipped so scanned content stays strictly within the library's physical tree. The library root itself is always followed. Takes effect on the next scheduled or manual scan of this library — this endpoint does NOT trigger a rescan. requestBody: required: true content: application/json: schema: type: object required: [vpath, followSymlinks] properties: vpath: type: string pattern: "[a-zA-Z0-9-]+" followSymlinks: { type: boolean } responses: "200": { description: Flag updated. } "403": { description: Validation error. } "404": { description: Library not found. } "405": { $ref: "#/components/responses/AdminLocked" } # ── Admin - Subsonic ──────────────────────────────────────────────────── /api/v1/admin/subsonic: get: tags: [Admin - Subsonic] summary: Current Subsonic config snapshot responses: "200": description: Subsonic mode + port. content: application/json: schema: type: object properties: mode: { type: string, enum: [disabled, same-port, separate-port] } port: { type: integer } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/subsonic/mode: post: tags: [Admin - Subsonic] summary: Change Subsonic mode (hot-reload) requestBody: required: true content: application/json: schema: type: object required: [mode] properties: mode: { type: string, enum: [disabled, same-port, separate-port] } port: { type: integer, description: "Only required for separate-port." } responses: "200": { description: Updated. Server reboots to apply. } "403": { description: Validation error. } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/subsonic/stats: get: tags: [Admin - Subsonic] summary: Implemented methods, now-playing, lyrics-cache stats responses: "200": description: Stats for the admin panel card. content: application/json: schema: type: object properties: methodsImplemented: { type: integer } methods: type: array items: { type: string } methodStatuses: type: array items: type: object properties: name: { type: string } status: { type: string, enum: [full, stub] } fullCount: { type: integer } stubCount: { type: integer } nowPlaying: type: array items: type: object properties: username: { type: string } trackId: { type: integer } title: { type: string, nullable: true } artist: { type: string, nullable: true } album: { type: string, nullable: true } sinceMs: { type: integer } lyrics: type: object description: LRCLib cache state + toggles. properties: lrclibEnabled: { type: boolean } writeSidecarEnabled: { type: boolean } cache: type: object properties: hit: { type: integer } miss: { type: integer } error: { type: integer } pending: { type: integer } other: { type: integer } total: { type: integer } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/subsonic/test: get: tags: [Admin - Subsonic] summary: Ping self via the configured Subsonic endpoint description: | Mints a throwaway API key, hits `/rest/ping` over HTTP, revokes the key, returns the round-trip result. Useful for the "test connection" button in the admin panel. responses: "200": description: Result of the probe. content: application/json: schema: type: object properties: ok: { type: boolean } latencyMs: { type: integer } serverVersion: { type: string } reason: { type: string, description: "Only on failure." } url: { type: string } /api/v1/admin/subsonic/jukebox: get: tags: [Admin - Subsonic] summary: Live jukebox status (Rust audio backend) description: | Proxies to the bundled rust-server-audio process for its `/status` and `/queue` endpoints. Returns `{available:false, reason}` if the backend isn't running (autoBootServerAudio disabled or boot failed). responses: "200": description: Status snapshot. content: application/json: schema: oneOf: - type: object required: [available] properties: available: { type: boolean, enum: [false] } reason: { type: string } - type: object required: [available] properties: available: { type: boolean, enum: [true] } playing: { type: boolean } paused: { type: boolean } position: { type: number, description: "Seconds." } duration: { type: number, description: "Seconds." } volume: { type: number, minimum: 0, maximum: 1 } currentFile: { type: string } queueLength: { type: integer } queueIndex: { type: integer } shuffle: { type: boolean } loopMode: { type: string, enum: [none, one, all] } /api/v1/admin/subsonic/token-auth-attempts: get: tags: [Admin - Subsonic] summary: Recent token-auth rejects description: | mStream stores PBKDF2 hashes so can't support Subsonic token auth (`t=md5(password+salt)`). Clients that try it get an explicit error; each attempt is logged to a ring buffer (process-local, max 50) so the admin can see "DSub at 10.0.0.5 keeps trying, mint them a key." responses: "200": description: Recent attempts, most recent first. content: application/json: schema: type: object properties: attempts: type: array items: type: object properties: username: { type: string, nullable: true } client: { type: string, nullable: true } at: { type: integer, description: ms epoch. } ua: { type: string, nullable: true } delete: tags: [Admin - Subsonic] summary: Clear the token-auth-attempts ring buffer responses: "200": { description: Cleared. } /api/v1/admin/subsonic/mint-key: post: tags: [Admin - Subsonic] summary: Create a Subsonic API key on behalf of another user requestBody: required: true content: application/json: schema: type: object required: [username, name] properties: username: { type: string } name: type: string minLength: 1 maxLength: 100 description: Key label. Required; distinguishes multiple keys on the same user. responses: "200": description: The freshly-minted key (view-once). content: application/json: schema: type: object required: [key, name, username] properties: key: { type: string } username: { type: string } name: { type: string } "404": { description: User not found. } "403": { description: Validation error (missing `username` or `name`). } /api/v1/admin/users/subsonic-password: post: tags: [Admin - Subsonic] summary: Overwrite a user's password (admin-driven password reset for Subsonic clients) requestBody: required: true content: application/json: schema: type: object required: [username, password] properties: username: { type: string } password: { type: string, minLength: 1 } responses: "200": { description: Password updated. } /api/v1/admin/subsonic/lyrics-cache/purge: post: tags: [Admin - Subsonic] summary: Purge the lyrics_cache table requestBody: content: application/json: schema: type: object properties: mode: type: string enum: [full, retry] default: full description: | `full` drops every row. `retry` drops only `error` and `pending` rows (keeps hits) — use after a network outage. responses: "200": description: Count of rows deleted. content: application/json: schema: type: object properties: removed: { type: integer } mode: { type: string } "403": { description: Validation error (invalid mode). } /api/v1/admin/subsonic/lyrics-cache/enabled: post: tags: [Admin - Subsonic] summary: Toggle LRCLib external-lookup fallback description: | When disabled, no new LRCLib fetches fire (existing cached hits keep serving — clear them via `/purge`). On the enabled→disabled edge queued-but-not-yet-running jobs are cancelled; in-flight requests complete but their result is dropped. requestBody: required: true content: application/json: schema: type: object required: [enabled] properties: enabled: { type: boolean } responses: "200": description: Toggle persisted; cache rows untouched. content: application/json: schema: type: object properties: enabled: { type: boolean } cancelledJobs: { type: integer, description: Queue drops when disabling. } /api/v1/admin/subsonic/lyrics-cache/write-sidecar: post: tags: [Admin - Subsonic] summary: Toggle LRCLib→sidecar write-back description: | When enabled, a successful LRCLib hit ALSO writes a sibling `.lrc` (synced) or `.txt` (plain) next to the audio file. Never clobbers an existing sibling. Silent no-op on read-only filesystems. requestBody: required: true content: application/json: schema: type: object required: [enabled] properties: enabled: { type: boolean } responses: "200": description: Toggle persisted. content: application/json: schema: type: object properties: writeSidecar: { type: boolean } # ── Subsonic REST API (handled under /rest/*) ──────────────────────────── # The Subsonic API uses its own auth scheme (u/p, u/t/s, or apiKey) and # its own envelope format, so the 61 methods aren't enumerated as mStream # JSON paths here. Spec: # https://opensubsonic.netlify.app/docs/endpoints/ # See the admin panel "API Surface" card for the live list of implemented # methods with FULL/STUB status per method. /rest/{method}: get: tags: [Subsonic API] summary: Subsonic / OpenSubsonic dispatch description: | Dispatch on `method` (e.g. `ping`, `getArtists`, `getAlbumList2`, `stream`, `getLyricsBySongId`). Full list at <https://opensubsonic.netlify.app/docs/endpoints/>. **Auth is Subsonic-native, NOT mStream JWT**: pass `u=<username>` and one of (`p=<plaintext|enc:HEX>`, `t=<md5(pw+salt)>` + `s=<salt>`, or `apiKey=<opaque>`). JWT cookies are ignored on `/rest/*`. `.view` suffix (e.g. `/rest/ping.view`) is also accepted for pre-1.4.0 compatibility. parameters: - name: method in: path required: true schema: { type: string } - name: f in: query schema: { type: string, enum: [json, xml, jsonp], default: xml } - name: u in: query schema: { type: string } - name: p in: query schema: { type: string } - name: t in: query schema: { type: string } - name: s in: query schema: { type: string } - name: apiKey in: query schema: { type: string } responses: "200": description: Subsonic envelope (see `subsonic-response` schema in upstream spec). security: [] # uses its own auth params post: tags: [Subsonic API] summary: Same as GET (Subsonic allows either method on every endpoint) parameters: - name: method in: path required: true schema: { type: string } responses: "200": { description: Subsonic envelope. } security: [] # ── Admin - Discogs ────────────────────────────────────────────────────── /api/v1/admin/discogs/config: get: tags: [Admin - Discogs] summary: Get Discogs configuration responses: "200": description: Config. content: application/json: schema: type: object properties: enabled: { type: boolean } allowArtUpdate: { type: boolean } allowId3Edit: { type: boolean } apiKey: { type: string } apiSecret: { type: string, description: "Masked." } "405": { $ref: "#/components/responses/AdminLocked" } post: tags: [Admin - Discogs] summary: Update Discogs configuration requestBody: required: true content: application/json: schema: type: object properties: enabled: { type: boolean } allowArtUpdate: { type: boolean } apiKey: { type: string } apiSecret: { type: string } responses: "200": description: Success. content: application/json: schema: type: object properties: ok: { type: boolean, enum: [true] } "405": { $ref: "#/components/responses/AdminLocked" } # ── Admin - ID3 tags (Velvet) ──────────────────────────────────────────── /api/v1/admin/tags/write: post: tags: [Admin - Scanner] summary: Write ID3 tags to an audio file (Velvet) description: Requires the `allowFileModify` permission. requestBody: required: true content: application/json: schema: type: object required: [filepath] properties: filepath: { type: string } title: { type: string } artist: { type: string } album: { type: string } year: { type: integer } genre: { type: string } track: { type: integer } disk: { type: integer } responses: "200": description: Success. content: application/json: schema: type: object properties: ok: { type: boolean, enum: [true] } "403": { $ref: "#/components/responses/Forbidden" } # ── Federation ─────────────────────────────────────────────────────────── /api/v1/admin/federation/enable: post: tags: [Federation] summary: Enable/disable federation description: Rebuilds the Syncthing config with a 5s debounce. requestBody: required: true content: application/json: schema: type: object required: [enable] properties: enable: { type: boolean } responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/federation/invite/accept: post: tags: [Federation] summary: Accept a federation invite description: Stub — not yet implemented. requestBody: required: true content: application/json: schema: type: object required: [url, vpaths, invite, accessAll] properties: url: { type: string, format: uri } vpaths: type: array items: { type: string } invite: { type: string } accessAll: { type: boolean } responses: "200": { $ref: "#/components/responses/EmptySuccess" } /api/v1/federation/invite/generate: post: tags: [Federation] summary: Generate a federation invite token requestBody: required: true content: application/json: schema: type: object properties: vpaths: type: array items: { type: string } url: { type: string, format: uri } responses: "200": description: Invite token. content: application/json: schema: type: object properties: token: { type: string } /api/v1/federation/stats: get: tags: [Federation] summary: Federation device info responses: "200": description: Device + UI address. content: application/json: schema: type: object properties: deviceId: { type: string } uiAddress: { type: string } /api/v1/syncthing-proxy/{path}: parameters: - name: path in: path required: true schema: { type: string } description: Passed through. Use an empty string to address the Syncthing root. get: &syncProxy tags: [Federation] summary: Proxy to Syncthing UI description: Any HTTP method is accepted and passed through to the bundled Syncthing instance. responses: "200": { description: Proxied response. } post: *syncProxy put: *syncProxy delete: *syncProxy patch: *syncProxy # ── Velvet stubs (not yet implemented) ─────────────────────────────────── /api/v1/radio/stations: get: tags: [Library] summary: Radio stations (stub) description: Radio isn't implemented yet — always returns `[]`. responses: "200": description: Empty. content: application/json: schema: type: array items: { type: object } /api/v1/radio/enabled: get: tags: [Library] summary: Radio enabled flag (stub) responses: "200": description: Status. content: application/json: schema: type: object properties: enabled: { type: boolean, enum: [false] } /api/v1/radio/schedules: get: tags: [Library] summary: Radio schedules (stub) responses: "200": description: Empty. content: application/json: schema: type: array items: { type: object } /api/v1/podcast/feeds: get: tags: [Library] summary: Podcast feeds (stub) description: Podcasts aren't implemented yet. responses: "200": description: Empty. content: application/json: schema: type: array items: { type: object } # ────────────────────────────────────────────────────────────────────── # Torrent — user-facing # ────────────────────────────────────────────────────────────────────── /api/v1/torrent/preflight: get: tags: [Torrent] summary: Per-user torrent-feature readiness check description: | Read-only "should the UI even enable the submit button?" check. Resolves the user-supplied file-explorer path to a vpath, checks the user's `allow_torrent` flag (when whitelist mode is active), and checks that the active torrent client has a verified path mapping for the vpath. parameters: - in: query name: path required: false schema: { type: string } description: "Library-prefixed file-explorer path the user is currently browsing." responses: "200": description: Preflight result. content: application/json: schema: type: object properties: active: { type: boolean } clientType: { type: string, enum: [disabled, transmission, qbittorrent, deluge] } displayName: { type: string, nullable: true } noUpload: { type: boolean } whitelistMode: { type: boolean } userAllowed: { type: boolean } vpath: { type: string, nullable: true } subPath: { type: string, nullable: true } vpathConfirmed: { type: boolean } vpathConfidence: { type: string, enum: [verified, inferred, pending, unconfirmed], nullable: true } daemonPath: { type: string, nullable: true } reason: { type: string, nullable: true } /api/v1/torrent/auto-detect: post: tags: [Torrent] summary: Extract artist / album / year from a .torrent file description: | Runs the 3-tier extraction pipeline. Tier 1 parses the torrent's name field; Tier 2 walks the file list for music-shape signals; Tier 3 (when a vpath is provided and the active client is reachable) adds the torrent paused, downloads ~256 KB of one audio file, and reads embedded tags. requestBody: required: true content: multipart/form-data: schema: type: object required: [torrentFile] properties: torrentFile: { type: string, format: binary } vpath: { type: string } responses: "200": description: | Pipeline result. `ok: false` accompanies `confidence: 'none'` and is a legitimate response shape — UI should ask the user to fill the metadata fields manually. content: application/json: schema: type: object properties: ok: { type: boolean } metadata: { $ref: "#/components/schemas/TorrentMetadata" } confidence: { type: string, enum: [high, low, none] } method: { type: string } sourceName: { type: string } fileShape: { $ref: "#/components/schemas/TorrentFileShape" } tier3: type: object nullable: true properties: ok: { type: boolean } reason: { type: string } format: { type: string } "400": description: Invalid torrent / no source. content: { application/json: { schema: { $ref: "#/components/schemas/TorrentErrorEnvelope" }}} "403": description: User not whitelisted, or torrent feature disabled. content: { application/json: { schema: { $ref: "#/components/schemas/TorrentErrorEnvelope" }}} /api/v1/torrent/add: post: tags: [Torrent] summary: Submit a torrent to the active client description: | Hands off a .torrent file or magnet URI to the configured daemon. Gates checked, in order: feature-active → user whitelist → required fields → per-user vpath authorization → vpath access cache → info-hash compute → daemon add → SQL UPSERT of the managed_torrents row. requestBody: required: true content: multipart/form-data: schema: type: object required: [vpath, directoryName] properties: vpath: { type: string } subPath: { type: string } directoryName: { type: string, maxLength: 200 } magnet: { type: string } torrentFile: { type: string, format: binary } responses: "200": description: Torrent accepted by the daemon. content: application/json: schema: type: object properties: ok: { type: boolean, enum: [true] } infoHash: { type: string } name: { type: string } clientType: { type: string } downloadPath: { type: string } isDuplicate: type: boolean nullable: true "400": description: Invalid input. content: { application/json: { schema: { $ref: "#/components/schemas/TorrentErrorEnvelope" }}} "403": description: User not whitelisted, vpath forbidden, or client disabled. content: { application/json: { schema: { $ref: "#/components/schemas/TorrentErrorEnvelope" }}} "409": description: Vpath access cache says the daemon can't reach the library. content: { application/json: { schema: { $ref: "#/components/schemas/TorrentErrorEnvelope" }}} "412": description: No vpath access cache row — admin needs to run auto-detect. content: { application/json: { schema: { $ref: "#/components/schemas/TorrentErrorEnvelope" }}} "500": description: info-hash mismatch or persistence_failed. content: { application/json: { schema: { $ref: "#/components/schemas/TorrentErrorEnvelope" }}} "502": description: Daemon rejected the add or was unreachable. content: { application/json: { schema: { $ref: "#/components/schemas/TorrentErrorEnvelope" }}} "503": description: Active torrent client has no saved credentials. content: { application/json: { schema: { $ref: "#/components/schemas/TorrentErrorEnvelope" }}} /api/v1/torrent/seed-existing: post: tags: [Torrent] summary: Pre-check whether a torrent's files already exist on disk description: | User-facing variant of the admin seed-existing route. The player's torrent tab calls this BEFORE `POST /torrent/add` for every .torrent upload: a `seeded` outcome short-circuits the download entirely (the daemon registers the existing files as a seed); `already_in_daemon` warns the user they've added this hash before; `partial_match` returns a list of vpaths where some-but-not-all files live so the UI can prompt the operator to point `/torrent/add` at the matching directory. Per-user vpath scoping: the optional `vpaths` request field is intersected with `req.user.vpaths`; the empty intersection short-circuits to `no_match` without touching the daemon. Absolute server filesystem paths are STRIPPED from the response — see `UserSeedResult` schema for the contract. requestBody: required: true content: multipart/form-data: schema: type: object required: [torrentFile] properties: torrentFile: type: string format: binary vpaths: type: string description: | Optional JSON-encoded array of vpath names. Each name is intersected with the caller's `req.user.vpaths`; non-matching entries are silently dropped. Defaults to the full user-allowed set when omitted. example: '["music"]' responses: "200": description: Per-torrent result. See `UserSeedResult`. content: application/json: schema: { $ref: "#/components/schemas/UserSeedResult" } "400": description: Bad multipart body or missing torrentFile. content: { application/json: { schema: { $ref: "#/components/schemas/TorrentErrorEnvelope" }}} "403": description: Torrent feature disabled or caller not on the whitelist. content: { application/json: { schema: { $ref: "#/components/schemas/TorrentErrorEnvelope" }}} "503": description: Active client has no saved credentials. content: { application/json: { schema: { $ref: "#/components/schemas/TorrentErrorEnvelope" }}} /api/v1/torrent/path-templates: get: tags: [Torrent] summary: Per-vpath path templates the player should apply responses: "200": description: User-scoped templates. content: application/json: schema: type: object properties: vpaths: type: object additionalProperties: type: object properties: template: type: string nullable: true supportedVars: type: array items: { type: string, enum: [ARTIST, ALBUM, YEAR, GENRE, ALBUMARTIST] } suggestedTemplate: { type: string } # ────────────────────────────────────────────────────────────────────── # Admin - Torrent # ────────────────────────────────────────────────────────────────────── /api/v1/admin/torrent: get: tags: [Admin - Torrent] summary: Read torrent feature config responses: "200": description: Config snapshot. content: application/json: schema: type: object properties: client: { type: string, enum: [disabled, transmission, qbittorrent, deluge] } enabledFor: { type: string, enum: [all, whitelist] } transmission: { $ref: "#/components/schemas/TorrentClientCreds" } qbittorrent: { $ref: "#/components/schemas/TorrentClientCreds" } deluge: { $ref: "#/components/schemas/TorrentClientCreds" } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/torrent/client: post: tags: [Admin - Torrent] summary: Select the active torrent client requestBody: required: true content: application/json: schema: type: object required: [client] properties: client: { type: string, enum: [disabled, transmission, qbittorrent, deluge] } responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/torrent/enabled-for: post: tags: [Admin - Torrent] summary: Set per-user gating policy requestBody: required: true content: application/json: schema: type: object required: [enabledFor] properties: enabledFor: { type: string, enum: [all, whitelist] } responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/torrent/transmission/test: post: tags: [Admin - Torrent] summary: Probe Transmission credentials without saving requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/TorrentClientCreds" } responses: "200": description: | Always HTTP 200. `ok: true` means probe succeeded; `ok: false` + `message` means credentials wrong / daemon unreachable / etc. content: application/json: schema: type: object properties: ok: { type: boolean } version: { type: string } rpcVersion: { type: string } error: { type: string } message: { type: string } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/torrent/transmission/connect: post: tags: [Admin - Torrent] summary: Probe + persist Transmission credentials requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/TorrentClientCreds" } responses: "200": description: Same shape as /test. content: application/json: schema: type: object properties: ok: { type: boolean } version: { type: string } rpcVersion: { type: string } error: { type: string } message: { type: string } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/torrent/transmission/disconnect: post: tags: [Admin - Torrent] summary: Clear saved Transmission credentials responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/torrent/qbittorrent/test: post: tags: [Admin - Torrent] summary: Probe qBittorrent credentials without saving requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/TorrentClientCreds" } responses: "200": description: Same shape as Transmission /test. content: application/json: schema: type: object properties: ok: { type: boolean } version: { type: string } error: { type: string } message: { type: string } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/torrent/qbittorrent/connect: post: tags: [Admin - Torrent] summary: Probe + persist qBittorrent credentials requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/TorrentClientCreds" } responses: "200": description: Same shape as /test. content: application/json: schema: type: object properties: ok: { type: boolean } version: { type: string } error: { type: string } message: { type: string } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/torrent/qbittorrent/disconnect: post: tags: [Admin - Torrent] summary: Clear saved qBittorrent credentials responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/torrent/deluge/test: post: tags: [Admin - Torrent] summary: Probe Deluge credentials without saving requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/TorrentClientCreds" } responses: "200": description: Same shape as Transmission /test. content: application/json: schema: type: object properties: ok: { type: boolean } version: { type: string } error: { type: string } message: { type: string } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/torrent/deluge/connect: post: tags: [Admin - Torrent] summary: Probe + persist Deluge credentials requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/TorrentClientCreds" } responses: "200": description: Same shape as /test. content: application/json: schema: type: object properties: ok: { type: boolean } version: { type: string } error: { type: string } message: { type: string } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/torrent/deluge/disconnect: post: tags: [Admin - Torrent] summary: Clear saved Deluge credentials responses: "200": { $ref: "#/components/responses/EmptySuccess" } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/torrent/status: get: tags: [Admin - Torrent] summary: Live connectivity probe against the active client responses: "200": description: Always HTTP 200 — disconnected is a normal UI state. content: application/json: schema: type: object properties: connected: { type: boolean } configured: { type: boolean } clientType: { type: string } version: { type: string, nullable: true } rpcVersion: { type: string, nullable: true } reason: { type: string, nullable: true } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/torrent/list: get: tags: [Admin - Torrent] summary: All torrents the daemon currently knows about description: | Cross-joined against managed_torrents so each row is flagged with managedByMstream. Never 500s — daemon-unreachable responds with {torrents: [], error: '<reason>'}. responses: "200": description: Torrent rows. content: application/json: schema: type: object properties: torrents: type: array items: { $ref: "#/components/schemas/TorrentItem" } error: type: string nullable: true clientType: { type: string } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/torrent/seed-existing: post: tags: [Admin - Torrent] summary: Check if a torrent's files exist on disk; if so, add for seeding description: | One torrent per request. The route enumerates `info.files` (or the single-file equivalent), checks each entry's size against the on-disk copy under `<vpathRoot>/<info.name>/`, and — when every file matches — hands the torrent to the daemon `paused: false` so it does its own SHA-1 verification and starts seeding without re-downloading. The UI uploads N torrents in parallel via Promise.allSettled. Every response is HTTP 200; per-torrent failures (invalid metainfo, daemon errors, partial matches) surface as outcome strings, not HTTP error codes. requestBody: required: true content: multipart/form-data: schema: type: object required: [torrentFile] properties: torrentFile: type: string format: binary vpaths: type: string description: | Optional JSON-encoded array of vpath names to check, in priority order. First all-match wins. Defaults to every library when omitted/empty. example: '["music","testlib"]' responses: "200": description: Per-torrent result. content: application/json: schema: { $ref: "#/components/schemas/SeedResult" } "400": description: Bad multipart body or missing torrentFile. content: { application/json: { schema: { $ref: "#/components/schemas/TorrentErrorEnvelope" }}} "412": description: No active torrent client or no saved credentials. content: { application/json: { schema: { $ref: "#/components/schemas/TorrentErrorEnvelope" }}} "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/torrent/{infoHash}: delete: tags: [Admin - Torrent] summary: Remove an mStream-managed torrent from the daemon (files kept) description: | Drops the daemon-side torrent record without deleting on-disk data, then removes the managed_torrents row. Refuses to touch torrents NOT in managed_torrents (those were added directly through the daemon's own UI). A daemon-side failure does NOT block the SQL cleanup — the row is dropped regardless so the UI matches reality. parameters: - in: path name: infoHash required: true schema: { type: string, pattern: "^[a-f0-9]{40}$" } responses: "200": description: Removed. content: application/json: schema: type: object properties: ok: { type: boolean, enum: [true] } infoHash: { type: string } clientType: { type: string } managedRowsDropped: { type: integer } daemonRemoveOk: { type: boolean } daemonRemoveError: type: string nullable: true "400": description: info_hash not 40-char lowercase hex. content: { application/json: { schema: { $ref: "#/components/schemas/TorrentErrorEnvelope" }}} "404": description: Hash not in managed_torrents. content: { application/json: { schema: { $ref: "#/components/schemas/TorrentErrorEnvelope" }}} "412": description: No saved credentials for the row's client_type. content: { application/json: { schema: { $ref: "#/components/schemas/TorrentErrorEnvelope" }}} "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/torrent/vpath-access: get: tags: [Admin - Torrent] summary: Per-(active client, vpath) path-mapping cache responses: "200": description: Cache rows keyed by vpath name. content: application/json: schema: type: object properties: clientType: { type: string } error: { type: string } vpaths: type: object additionalProperties: { $ref: "#/components/schemas/VpathAccessRow" } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/torrent/vpath-access/auto-detect: post: tags: [Admin - Torrent] summary: Re-run the path-probe pipeline for one or all vpaths requestBody: required: false content: application/json: schema: type: object properties: vpathName: { type: string, description: "Omit to sweep every library." } responses: "200": description: Updated cache rows. content: application/json: schema: type: object properties: clientType: { type: string } vpaths: type: object additionalProperties: { $ref: "#/components/schemas/VpathAccessRow" } "404": description: Named vpath doesn't exist. content: { application/json: { schema: { $ref: "#/components/schemas/TorrentErrorEnvelope" }}} "409": description: No torrent client selected. content: { application/json: { schema: { $ref: "#/components/schemas/TorrentErrorEnvelope" }}} "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/torrent/vpath-access/manual: post: tags: [Admin - Torrent] summary: Set the daemon path for a vpath by hand requestBody: required: true content: application/json: schema: type: object required: [vpathName, daemonPath] properties: vpathName: { type: string } daemonPath: { type: string } responses: "200": description: Verified; cache row updated. content: application/json: schema: type: object properties: ok: { type: boolean, enum: [true] } clientType: { type: string } daemonPath: { type: string } confidence: { type: string } "404": description: Vpath unknown. content: { application/json: { schema: { $ref: "#/components/schemas/TorrentErrorEnvelope" }}} "409": description: No active client. content: { application/json: { schema: { $ref: "#/components/schemas/TorrentErrorEnvelope" }}} "412": description: No saved credentials. content: { application/json: { schema: { $ref: "#/components/schemas/TorrentErrorEnvelope" }}} "422": description: Daemon couldn't verify the supplied path. Row persisted with source=manual + confidence=unconfirmed. content: { application/json: { schema: { $ref: "#/components/schemas/TorrentErrorEnvelope" }}} "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/torrent/path-templates: get: tags: [Admin - Torrent] summary: List per-vpath path templates responses: "200": description: Templates plus the variable allowlist + sample metadata for the live preview. content: application/json: schema: type: object properties: vpaths: type: object additionalProperties: type: object properties: template: type: string nullable: true supportedVars: type: array items: { type: string, enum: [ARTIST, ALBUM, YEAR, GENRE, ALBUMARTIST] } suggestedTemplate: { type: string } sampleMetadata: { $ref: "#/components/schemas/TorrentMetadata" } "405": { $ref: "#/components/responses/AdminLocked" } /api/v1/admin/torrent/path-templates/{vpath}: put: tags: [Admin - Torrent] summary: Set (or clear) the path template for one library description: | Validates the template at save time: parse + variable allowlist + brace balance + control-char rejection + leading-/ rejection. Then sample-resolves against the fixed sample metadata and re-validates the resolved path (no .., no absolute paths, no drive letters, no ~/$HOME). Sending null or empty/whitespace clears the template back to NULL. parameters: - in: path name: vpath required: true schema: { type: string } requestBody: required: true content: application/json: schema: type: object properties: template: type: string maxLength: 500 nullable: true responses: "200": description: Saved or cleared. content: application/json: schema: type: object properties: ok: { type: boolean, enum: [true] } vpath: { type: string } template: type: string nullable: true samplePath: { type: string } "400": description: Template rejected (unknown_variable, traversal, absolute_template, etc.). content: { application/json: { schema: { $ref: "#/components/schemas/TorrentErrorEnvelope" }}} "404": description: Vpath unknown. content: { application/json: { schema: { $ref: "#/components/schemas/TorrentErrorEnvelope" }}} "405": { $ref: "#/components/responses/AdminLocked" }