4.2 KiB
Audio streaming
Goal
Play audio without the bytes passing through the API, while still controlling access. The API only issues a short-lived permission; the reverse proxy serves the bytes from SeaweedFS.
Format: a single AAC file (.m4a), served via HTTP range
Audio is transcoded into one audio.m4a (AAC, MP4 container with +faststart so the moov atom sits at the start and range-based seeking works), stored under Tracks/ProcessedAudios/{AudioFolderName}/. There's no manifest or segments: the player downloads the file via HTTP range requests (the same model Spotify uses for on-demand music).
Multi-bitrate isn't implemented yet; the path to add it is transcoding several qualities (audio_96.m4a, …) and letting the client pick one at playback start (no seamless mid-track ABR, same as Spotify).
Access control: a prefix-scoped stream ticket
Access is authorized for the track's folder prefix via a short-lived stream ticket, validated by the proxy on every request. Even though there's only one object today, the ticket is still scoped to the prefix Tracks/ProcessedAudios/<folder>/ (covering the current file and any future quality variants).
Flow
GET /tracks/{id}/stream(authenticated) → the API authorizes the request and returns:The ticket carries the claim{ "manifestUrl": "http://<gateway>/media/Tracks/ProcessedAudios/<folder>/audio.m4a", "ticket": "<JWT RS256>", "expiresInSeconds": 3600 }prefix = "Tracks/ProcessedAudios/<folder>/".- The player opens the audio URL with
?t=<ticket>appended (or theX-Stream-Ticketheader). - The StreamingGateway (YARP) validates the ticket and forwards the request to the filer; the bytes flow SeaweedFS → client (with range/seek support).
The ticket (RS256)
- Signed by the API with a private key (
StreamTicketService); validated by the gateway with the public one (TicketValidator). That way the gateway can't issue tickets, only verify them. - Claims:
sub,prefix,aud = media-gateway,iss = musify-webapi,exp(~1h TTL).
Why RS256 (asymmetric): it separates responsibilities. The issuer (API) and the verifier (gateway) don't share a secret, so even if the gateway is compromised, it can't mint permissions.
The gateway (validation + proxy)
TicketValidationMiddleware, on every request to /media/**:
- Reads
?t=, validates its signature/aud/iss/exp. - Checks that the requested key starts with the ticket's
prefix. If not → 403. - Strips the
?t=and passes the request on to YARP, which rewrites/media/{key}→/buckets/{bucket}/{key}for the filer (the bucket is set by the compose overrideReverseProxy__Routes__media__Transforms__0__PathPattern; the default inappsettings.jsonis the placeholderCHANGE_ME, andappsettings.Development.jsonuseswebapi-storage).
- No ticket → 401; outside the prefix → 403; ok → 200 (and 206 for range requests, so seeking works).
- Passes through
Range/Accept-Rangesand exposes those headers via CORS.
One ticket per track?
Yes. Each ticket is scoped to one folder (one track). Playing a different one means requesting a new ticket. It's cheap (the API just signs a JWT) and it's the safest option: every permission only unlocks what you're about to play.
- If that ever becomes a problem (very frequent track switching), the same
prefixclaim mechanism can widen the scope to a whole playlist or the whole library (Tracks/ProcessedAudios/). It's a security/convenience trade-off. The default is per track.
Open items / security notes
- Who can request a ticket is decided by
StartListeningCommand: any authenticated user, or anonymous users whenPlayback:AllowAnonymousListeningis on (the ticket then carriesmaxBytes, an anonymous preview limit). TODO: restrict it by track visibility. - The prefix check is a literal
StartsWithon the key, and the gateway rejects any./..segment with 400. For tickets withmaxBytesit clamps theRangeheader to the preview size, replaces a missing or invalid range, and answers 416 to suffix ranges or ranges that start beyond the limit. - The ticket travels in the query string (
?t=); the gateway strips it before forwarding, so it never reaches the filer's logs.