Files
Musify/docs/streaming.md

56 lines
4.2 KiB
Markdown

# 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
1. `GET /tracks/{id}/stream` (authenticated) → the API authorizes the request and returns:
```json
{ "manifestUrl": "http://<gateway>/media/Tracks/ProcessedAudios/<folder>/audio.m4a",
"ticket": "<JWT RS256>", "expiresInSeconds": 3600 }
```
The ticket carries the claim `prefix = "Tracks/ProcessedAudios/<folder>/"`.
2. The player opens the audio URL with `?t=<ticket>` appended (or the `X-Stream-Ticket` header).
3. 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/**`:
1. Reads `?t=`, validates its signature/aud/iss/exp.
2. Checks that the requested key **starts with** the ticket's `prefix`. If not → 403.
3. 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 override `ReverseProxy__Routes__media__Transforms__0__PathPattern`; the default in `appsettings.json` is the placeholder `CHANGE_ME`, and `appsettings.Development.json` uses `webapi-storage`).
- No ticket → **401**; outside the prefix → **403**; ok → **200** (and **206** for range requests, so seeking works).
- Passes through `Range`/`Accept-Ranges` and 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 `prefix` claim 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 when `Playback:AllowAnonymousListening` is on (the ticket then carries `maxBytes`, an anonymous preview limit). TODO: restrict it by track visibility.
- The prefix check is a literal `StartsWith` on the key, and the gateway rejects any `.`/`..` segment with 400. For tickets with `maxBytes` it clamps the `Range` header 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.