Files
Musify/docs/streaming.md
T
Admin 23e30d96fc Translate docs to English, rewrite README, drop design handoff folder
Translate all of docs/ from Spanish to English and rewrite the root
README as a short project overview instead of a technical run guide.
Remove web-player/design_handoff_musify_player, an old design-tool
export that was no longer needed.
2026-09-21 18:41:04 +02:00

56 lines
3.8 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/webapi-storage/{key}` for the filer.
- 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
- Today, the policy for who can request a ticket is "any authenticated user" (TODO: restrict it with `UserHasTrack`/visibility).
- The prefix check is a literal `StartsWith`; it would be worth hardening it against `..` path traversal, even though browsers and ASP.NET normalize `..`.
- The ticket travels in the query string (`?t=`); the gateway strips it before forwarding, so it never reaches the filer's logs.