Files
platform/MUSIC_API.md
T
pastilhasandClaude Opus 4.8 10d7b6a425 music indexer: generate video poster thumbnails
Each indexed video gets a compressed poster (a frame grab ~10% in, capped at
30s, scaled ≤600px q5 like covers), written to cache/<rel>/posters/<file>.jpg
and recorded as `poster` on the meta.videos entry. The posters dir is wiped and
regenerated on each rebuild so orphans (removed videos) don't linger. New
`postersSaved` status counter.

Served by a new sidecar route GET /api/music/poster?path=<rel>&file=<video>
(image/jpeg, ETag=<v>, 304, 404 when none) — path-safe via basename.

Verified end-to-end on a real .mp4: video-only album → manifest {tracks:0,
videos:1}, meta.poster set, 14 KB poster on disk. MUSIC_API.md documents the
poster field + endpoint + postersSaved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 14:38:15 +00:00

12 KiB
Raw Blame History

Music API (/api/music/*)

Platform API the mobile app uses to stream music and sync a server-built library index, so the app no longer pre-downloads whole tracks or walks/ID3-parses the library on-device.

  • Source of truth for the code: src/servers/sidecar/music/index.ts (the officer-music sidecar owns all of this; the platform /api/music/* route is a transparent auth-ing proxy).
  • Music root: ~/Music on the server. All path values are home-relative (e.g. Music/Albums/AC-DC/[1980] Back in Black/01 Hells Bells.mp3), identical to /api/file-browser/raw.
  • <rel>: an album folder path relative to the Music root (e.g. Albums/AC-DC/[1980] Back in Black).

Auth

Every endpoint is behind the standard user auth. Two ways to pass the JWT:

  • Header: Authorization: Bearer <jwt> (normal fetches).
  • Query: ?token=<jwt> — for media elements / native players that can't set headers (audio, images).

401 = no token · 403 = invalid/expired token.


1. Playback — stream a track

GET /api/music/stream?path=<home-relative>&token=<jwt>

Byte-range streaming so the player can seek without downloading the whole file.

Case Status Headers
No Range 200 Content-Type, Content-Length, Accept-Ranges: bytes, X-Audio-Duration
With Range: bytes=… 206 Content-Range, Content-Length, Accept-Ranges: bytes, Content-Type, X-Audio-Duration
  • X-Audio-Duration: track duration in seconds (ffprobe-derived). Read this to set the player's duration up front — it's the fix for AVPlayer reporting an indefinite duration on progressively-streamed VBR MP3s. No need to scan the file.
  • Errors: 400 invalid/missing path · 404 not found · 416 bad range.
curl -H "Authorization: Bearer $JWT" -H "Range: bytes=0-1023" \
  "$BASE/api/music/stream?path=Music/Albums/AC-DC/[1980]%20Back%20in%20Black/01%20Hells%20Bells.mp3" -D -

2. Library index — the synced cache

The server maintains a cache tree that mirrors the library, one entry per album folder. The app syncs this instead of walking + ID3-parsing the library itself.

Each album has a version stamp v (hash of the album's source files' names/sizes/mtimes + its cover). v changes iff the album's content changed → it's the whole basis of the diff: unchanged v ⇒ skip.

2.1 Manifest — one call, whole library

GET /api/music/manifest
{
  "version": 1,
  "generatedAt": 1785034701973,          // ms; when the index was last built
  "albums": {
    "Albums/AC-DC/[1980] Back in Black": { "v": "50856380f1ca8f9", "cover": true, "tracks": 10 },
    "DJ Sets/Dave Clarke":              { "v": "a1b2c3d4e5f6a7b", "cover": false, "tracks": 3 },
    "Albums/Metallica/[1989] Live Shit": { "v": "beefbeefbeefbee", "cover": true, "tracks": 0, "videos": 2 },
    "Albums/AC-DC":                     { "v": "c0ffee1234567890", "cover": true, "tracks": 0, "disco": true }
    // …
  }
}

404 if the index has never been built (see §3). Entries with tracks: 0 are container folders (e.g. an artist folder). An entry with disco: true is an artist folder that has a discography — fetch its grouping via /discography (§2.4). videos: N (optional) counts video files (concerts, clips) that live directly in that artist/album folder — their per-file metadata is in that folder's meta.json (§2.2). A folder may have any mix of tracks, videos, and disco.

2.2 Album metadata

GET /api/music/meta?path=<rel>

Returns the album's meta.json. Sends ETag: <v>; a request with If-None-Match: <v> returns 304.

{
  "path": "Albums/AC-DC/[1980] Back in Black",
  "cover": "cover.jpg",                    // present only if a cover exists
  "tracks": [
    {
      "file": "01 Hells Bells.mp3",        // filename within the album folder
      "title": "Hells Bells",
      "artist": "AC/DC",
      "albumArtist": "AC/DC",
      "album": "Back in Black",
      "track": "1",
      "year": "1980",
      "durationSec": 312
    }
    // …
  ],
  "videos": [                              // present only for folders that contain video files
    {
      "file": "1989 - Seattle.mp4",        // filename within the folder
      "title": "Live Shit: Seattle",       // from the container title tag, if any
      "durationSec": 8130,
      "width": 1280,
      "height": 720,
      "poster": "posters/1989 - Seattle.mp4.jpg"  // present when a poster was generated (see §2.3.1)
    }
    // …
  ]
}

All track/video fields except file are optional (absent when the tag/stream info is missing). videos is omitted entirely when the folder has none. To stream a track or video: GET /api/music/stream?path=Music/<rel>/<file> (byte-range; works for .mp4).

2.3 Cover

GET /api/music/cover?path=<rel>

Compressed JPEG (≤600px on the long edge, ~3080 KB). Sends ETag: <v>; If-None-Match: <v>304. Only meaningful when the manifest entry has "cover": true.

2.3.1 Video poster

GET /api/music/poster?path=<rel>&file=<video filename>

A compressed frame grab for a video (≤600px, same treatment as covers), taken ~10% into the clip. file is the video's filename within <rel> (URL-encode it). Sends ETag: <v>; If-None-Match: <v>304; 404 when the video has no poster. Only request it when that video's meta.videos[] entry has a poster field.

2.4 Discography (artist album grouping)

For artist folders (manifest entry with "disco": true), this returns a map of album folder → release type, so the player can split an artist's album list into sections (Studio, Live, Compilation, Single, EP…).

GET /api/music/discography?path=<artist rel>      e.g. path=Albums/AC-DC

Sends ETag: <v>; If-None-Match: <v>304.

{
  "artist": "Anthrax",
  "albums": {
    "[1984] Fistful Of Metal": "Studio",
    "[1985] Armed And Dangerous": "EP",
    "[1994] The Island Years": "Live",
    "[1991] Attack Of The Killer B's": "Compilation"
    // …
  }
}
  • Keys are album folder names ([year] title) — they map 1:1 to the artist's album folders, i.e. the last path segment of that album's manifest <rel>. Group the artist's albums by looking each up here.
  • Types are a normalized set: Studio, Live, Compilation, Single, EP, Soundtrack, Remix, DJ-Mix, Demo, Mixtape, Bootleg, Other (unknown values pass through as-is). The player defines section order.
  • An album folder not present here has no classification → put it in an "Other"/uncategorized section.
  • Source of truth is each artist's _discography.md (author-maintained); this JSON is derived from it and re-generated whenever that file changes (its v bumps independently of the albums' meta/cover).

3. Building / refreshing the index

The index is built on demand (nothing is pre-built or scheduled). A build is incremental — albums whose v is unchanged are skipped — and it prunes albums removed from the library.

3.1 Trigger

POST /api/music/reindex          → returns IndexStatus (running: true)
GET  /api/music/reindex/status   → IndexStatus snapshot

IndexStatus:

{
  "running": true,
  "startedAt": 1785034701973, "finishedAt": null,
  "foldersScanned": 45, "albumsBuilt": 12, "albumsSkipped": 3,
  "tracksIndexed": 320, "videosIndexed": 4, "coversSaved": 12, "postersSaved": 4, "discographies": 3,
  "currentPath": "Albums/AC-DC/[1980] Back in Black",
  "error": null
}

3.2 Live progress — SSE

GET /api/music/reindex/stream
  • Triggers a build if none is running. Pass ?trigger=0 to watch only (subscribe without starting one).
  • Emits event: progress (an IndexStatus) throttled to ~200 ms, then a single event: done (an IndexReport) and closes the stream.
event: progress
data: {"running":true,"foldersScanned":45,"tracksIndexed":320,"albumsBuilt":12,"albumsSkipped":3,"coversSaved":12,"currentPath":"Albums/AC-DC/[1980] Back in Black", …}

event: done
data: {"albums":15,"built":12,"skipped":3,"foldersScanned":45,"tracksIndexed":320,"coversSaved":12,"elapsedSec":37.2,"error":null}

IndexReport (the done payload):

{ "albums": 15, "built": 12, "skipped": 3, "foldersScanned": 45,
  "tracksIndexed": 320, "coversSaved": 12, "discographies": 3, "elapsedSec": 37.2, "error": null }

First build of a large library takes a few minutes; re-runs are near-instant (unchanged albums skip via v).


Keep the last manifest.albums you synced. On resync:

  1. GET /api/music/manifest.
  2. For each <rel> in the new manifest:
    • new, or v differs from your stored copy → fetch GET /meta?path=<rel> (+ GET /cover?path=<rel> if cover:true, + GET /discography?path=<rel> if disco:true); store them under your local <rel>/.
    • v unchangedskip (no download).
  3. For each <rel> you have locally that's absent from the new manifest → delete it.
  4. Save the new manifest as your baseline.

Optionally trigger a fresh server build first via GET /reindex/stream (and show progress from its progress/done events) so the manifest reflects the latest library before you diff.

Result: a resync after adding one album = 1 manifest fetch + that one album's meta + cover. Nothing else moves.


Per-user state — Favorites & Currently-playing

Unlike everything above (library data served by the sidecar), these are per-user and served by the platform straight from Postgres — same /api/music prefix and same auth. Keys are opaque paths the app supplies; the server never interprets them:

kind key
track home-path — Music/<rel>/<file> (also the /stream path & queue id)
album music-rel — Albums/AC-DC/[1980] Back in Black
artist music-rel — Albums/AC-DC

Favorites

  • GET /api/music/favorites → grouped keys, newest first:
    { "tracks": ["Music/…/01 Hells Bells.mp3"], "albums": ["Albums/AC-DC/[1980] Back in Black"], "artists": ["Albums/AC-DC"] }
    
  • POST /api/music/favorites { "kind": "track|album|artist", "key": "…" }{ ok: true }. Idempotent (a repeat add is a no-op).
  • DELETE /api/music/favorites?kind=<kind>&key=<key>{ ok: true } (no-op if not set). Key passed as a query param (URL-encode it).
  • 400 { error: "kind and key required" } on a bad/missing kind or empty key.

Currently-playing (resume)

One snapshot per user — persist while playing (throttled) and on pause / track-change / close; read it on launch to offer "resume".

  • GET /api/music/now-playing → the snapshot or null:
    { "homePath": "Music/…/01 Hells Bells.mp3", "dir": "Music/Albums/AC-DC/[1980] Back in Black",
      "title": "Hells Bells", "artist": "AC/DC", "album": "Back in Black",
      "durationSec": 312.5, "positionSec": 140, "updatedAt": "2026-07-27T11:27:54.441Z" }
    
    dir is the folder to rebuild the album queue from (empty for a cross-album queue → resume the single track).
  • PUT /api/music/now-playing { homePath (required), dir?, title?, artist?, album?, durationSec?, positionSec? }{ ok: true } (upsert). Omitted fields default to ""/0.
  • DELETE /api/music/now-playing{ ok: true } (clear, e.g. on stop).
  • 400 { error: "homePath required" } if homePath is missing/empty.

Notes

  • Covers are server-compressed (≤600px / q5) — sync them as-is; no client-side resizing needed.
  • Durations are exact (ffprobe) in both X-Audio-Duration and meta.json's durationSec (seconds).
  • Playback still goes through /stream — the index is metadata + covers only. (Server-managed offline audio files is a separate, later feature.)
  • Errors are plain HTTP: 503 if the music sidecar isn't connected, 502 if it's unreachable.