# Blastoise API Reference

Blastoise is a synchronized music server. The server does not play or decode
audio. It keeps channel time, queue order, user/session state, playlists, and
track files. Frontends connect over HTTP and WebSocket, then play audio locally.

The most important rule for every client is:

```text
Render metadata from the API, but play audio by track.id.
GET /api/tracks/:trackId is the canonical audio URL.
```

## Base URL

Reference server:

```text
HTTP: http://mhsgroove.peterino.com:3001
WS:   ws://mhsgroove.peterino.com:3001
```

Local development server:

```text
HTTP: http://localhost:3001
WS:   ws://localhost:3001
```

Production frontends should normally be served from the same origin as the
Blastoise server, or placed behind a reverse proxy at the same origin. The
current server does not add CORS headers, and auth is cookie-based.

For separate-origin browser apps, add CORS and credential support on the server
or proxy the API through the frontend origin.

## Client Mental Model

1. Call `GET /api/status` to learn server features.
2. Call `GET /api/auth/me` to get the current user or create a guest session if
   guests are enabled.
3. Call `GET /api/library` to load track metadata.
4. Call `GET /api/channels` to find the default or saved channel.
5. Connect to `WS /api/channels/:id/ws`.
6. On channel state, load `/api/tracks/:track.id`, seek to
   `currentTimestamp`, and play or pause according to `paused`.

The server advances channel time by comparing:

```text
currentTimestamp = (Date.now() - channel.startedAt) / 1000
```

Clients should treat server state as the source of truth for synced playback.

## Authentication

Auth uses an HttpOnly cookie named `blastoise_session`.

Same-origin browser `fetch()` calls send this cookie automatically. If you add
CORS for a separate-origin app, use:

```js
fetch(apiBase + "/api/auth/me", { credentials: "include" });
```

Native apps should store the `Set-Cookie` header and send it back as a `Cookie`
header on HTTP and WebSocket requests.

Guest behavior:

- If `config.allowGuests` is true, many authenticated endpoints will
  automatically create a guest user and return `Set-Cookie`.
- Guests can listen.
- Guests cannot control channel playback.
- Guests cannot create channels, playlists, copy shared playlists, or fetch
  tracks from URLs.

Permissions:

- Admin users can do everything.
- `control` permission is required for synced playback controls and queue
  mutation.
- Server `defaultPermissions` can grant channel permissions globally.
- Guests never receive effective `control` permission.

Error responses are mixed in the current implementation. Most API errors are
JSON like `{ "error": "Message" }`, but some control endpoints return plain
text such as `Forbidden`, `Not found`, or `Invalid JSON`. Clients should handle
both.

## Common Types

### User

```ts
interface User {
  id: number;
  username: string;
  isAdmin: boolean;
  isGuest?: boolean;
}
```

`isGuest` is included by `GET /api/auth/me`. Login and signup responses include
`id`, `username`, and `isAdmin`.

### Permission

```ts
interface Permission {
  id: number;
  user_id: number;
  resource_type: string;       // "channel" or "global"
  resource_id: string | null;
  permission: string;          // "control", "admin", etc.
}
```

### Track

Track IDs are content-addressed strings. Treat them as opaque strings that
currently start with `sha256:`.

```ts
interface Track {
  id: string;                  // canonical content hash, use for API/cache
  filename: string;            // display/fallback only
  title: string | null;
  artist?: string | null;
  album?: string | null;
  duration: number;            // seconds
  replayGainDb?: number | null;
  replayPeak?: number | null;
  available?: boolean;
}
```

Use `track.title || track.filename` for display. Use `track.id` for every API
call, queue operation, local cache key, and audio URL.

### ChannelInfo

Returned by `GET /api/channels` and `channel_list` WebSocket messages.

```ts
interface ChannelInfo {
  id: string;
  name: string;
  description: string;
  trackCount: number;
  listenerCount: number;
  listeners: string[];
  isDefault: boolean;
  createdBy: number | null;
}
```

### ChannelState

Returned by `GET /api/channels/:id` and sent over WebSocket.

```ts
interface ChannelState {
  track: Track | null;
  currentTimestamp: number;    // seconds
  channelName: string;
  channelId: string;
  description: string;
  paused: boolean;
  currentIndex: number;
  listenerCount: number;
  isDefault: boolean;
  playbackMode: "once" | "repeat-all" | "repeat-one" | "shuffle";
  queue?: Track[];             // included on WS connect, queue changes,
                               // and periodic refreshes
}
```

The `queue` field is optional. Keep the last known queue until a state message
includes a new one.

### Playlist

```ts
interface Playlist {
  id: string;
  name: string;
  description: string;
  ownerId: number;
  ownerName?: string;          // included by detail/shared endpoints
  isPublic: boolean;
  shareToken: string | null;
  trackIds: string[];
  createdAt: number;           // Unix seconds
  updatedAt: number;           // Unix seconds
}
```

Playlist track lists store `track.id` values, not embedded track objects.
Join against `/api/library` on the client when rendering playlist contents.

### QueueItem

Used by the optional URL fetch/import API.

```ts
interface QueueItem {
  id: string;
  url: string;
  title: string;
  userId: number;
  status: "queued" | "downloading" | "complete" | "error" | "cancelled";
  progress: number;            // 0..100
  queueType: "fast" | "slow";
  error?: string;
  filename?: string;
  createdAt: number;           // milliseconds for in-memory queue items
  completedAt?: number;
  playlistId?: string;
  playlistName?: string;
  position?: number;
  trackId?: string;
}
```

## Status

### GET /api/status

Public server status and feature flags.

Response:

```json
{
  "name": "MusicRoom",
  "version": "1.0.0",
  "allowGuests": true,
  "allowSignups": true,
  "channelCount": 1,
  "defaultPermissions": ["control"],
  "ytdlp": {
    "available": true,
    "enabled": true,
    "version": "2026.01.01",
    "ffmpeg": true
  }
}
```

## Auth Endpoints

### POST /api/auth/signup

Create an account and start a session. The first non-guest user becomes admin.

Body:

```json
{
  "username": "test",
  "password": "testuser"
}
```

Rules:

- `username` is required and must be at least 3 characters.
- `password` is required and must be at least 6 characters.
- Username must be unique.

Response `200`:

```json
{
  "user": {
    "id": 1,
    "username": "test",
    "isAdmin": true
  }
}
```

Sets `blastoise_session`.

Common errors:

- `400` username/password missing, too short, or username taken
- `500` signup failed

### POST /api/auth/login

Start a session for an existing user.

Body:

```json
{
  "username": "test",
  "password": "testuser"
}
```

Response `200`:

```json
{
  "user": {
    "id": 1,
    "username": "test",
    "isAdmin": false
  }
}
```

Sets `blastoise_session`.

Common errors:

- `401` invalid username or password
- `500` login failed

### POST /api/auth/logout

Delete the current session and clear the session cookie.

Response:

```json
{
  "success": true
}
```

### GET /api/auth/me

Get the current user. If guests are enabled and the request has no valid
session, this endpoint creates a guest session.

Response with user:

```json
{
  "user": {
    "id": 2,
    "username": "guest_ab12cd34",
    "isAdmin": false,
    "isGuest": true
  },
  "permissions": [
    {
      "id": 0,
      "user_id": 2,
      "resource_type": "channel",
      "resource_id": null,
      "permission": "listen"
    }
  ]
}
```

Response when no user and guests are disabled:

```json
{
  "user": null
}
```

### POST /api/auth/kick-others

Send a WebSocket `kick` message to all active connections for the current user.

Requires an authenticated user.

Response:

```json
{
  "kicked": 2
}
```

Common errors:

- `401` not authenticated

## Admin Endpoints

### GET /api/admin/users

List all non-guest users.

Requires global `admin` permission.

Response:

```json
[
  {
    "id": 1,
    "username": "admin",
    "is_admin": true,
    "is_guest": false,
    "created_at": 1760000000
  }
]
```

### POST /api/admin/users/:userId/permissions

Grant a permission.

Requires global `admin` permission.

Body:

```json
{
  "resourceType": "channel",
  "resourceId": "default",
  "permission": "control"
}
```

Use `resourceId: null` for a global resource-type permission.

Response:

```json
{
  "success": true
}
```

### DELETE /api/admin/users/:userId/permissions

Revoke a permission.

Requires global `admin` permission.

Body:

```json
{
  "resourceType": "channel",
  "resourceId": "default",
  "permission": "control"
}
```

Response:

```json
{
  "success": true
}
```

## Channel Endpoints

### GET /api/channels

List channels.

Requires an authenticated session, or creates a guest session if guests are
enabled.

Response:

```json
[
  {
    "id": "default",
    "name": "Default",
    "description": "All tracks",
    "trackCount": 42,
    "listenerCount": 3,
    "listeners": ["alice", "bob", "bob"],
    "isDefault": true,
    "createdBy": null
  }
]
```

### POST /api/channels

Create a dynamic channel.

Requires an authenticated non-guest user.

Body:

```json
{
  "name": "Late Night",
  "description": "Quiet queue",
  "trackIds": ["sha256:abc123"]
}
```

Rules:

- `name` is required.
- `name` must be 64 characters or less.
- `description` is optional.
- `trackIds` is optional. Unknown or unavailable track IDs are ignored.

Response `201`:

```json
{
  "id": "k8s2m1qp",
  "name": "Late Night",
  "description": "Quiet queue",
  "trackCount": 1,
  "listenerCount": 0,
  "listeners": [],
  "isDefault": false,
  "createdBy": 1
}
```

### GET /api/channels/:channelId

Get current channel state.

Response:

```json
{
  "track": {
    "id": "sha256:abc123",
    "filename": "song.mp3",
    "title": "Song",
    "duration": 180.5,
    "replayGainDb": -7.2,
    "replayPeak": 0.91
  },
  "currentTimestamp": 42.1,
  "channelName": "Default",
  "channelId": "default",
  "description": "All tracks",
  "paused": false,
  "currentIndex": 4,
  "listenerCount": 3,
  "isDefault": true,
  "playbackMode": "repeat-all"
}
```

This route does not include `queue`; WebSocket connect does.

### PATCH /api/channels/:channelId

Rename a channel.

Requires an authenticated non-guest user.

Body:

```json
{
  "name": "New Channel Name"
}
```

Response:

```json
{
  "success": true,
  "name": "New Channel Name"
}
```

Current implementation allows any signed-in non-guest user to rename any
channel.

### DELETE /api/channels/:channelId

Delete a dynamic channel.

Requires an authenticated user who is either admin or the channel creator.
Default channels cannot be deleted.

Response:

```json
{
  "success": true
}
```

If clients are connected to the deleted channel, the server moves them to the
default channel and sends a `switched` WebSocket message.

### POST /api/channels/:channelId/jump

Jump the channel to a queue index.

Requires `control` permission.

Body:

```json
{
  "index": 3
}
```

Response:

```json
{
  "success": true
}
```

### POST /api/channels/:channelId/seek

Seek the channel to a timestamp in the current track.

Requires `control` permission.

Body:

```json
{
  "timestamp": 45.5
}
```

Response:

```json
{
  "success": true
}
```

The server clamps the timestamp between `0` and the current track duration.

### PATCH /api/channels/:channelId/queue

Modify a channel queue.

Requires `control` permission.

Set the whole queue:

```json
{
  "set": ["sha256:a", "sha256:b", "sha256:c"]
}
```

Add tracks to the end:

```json
{
  "add": ["sha256:d", "sha256:e"]
}
```

Insert tracks at an index:

```json
{
  "add": ["sha256:d"],
  "insertAt": 2
}
```

Remove by queue position:

```json
{
  "remove": [3, 4]
}
```

Move queue positions to a target index:

```json
{
  "move": [5, 6],
  "to": 1
}
```

Response:

```json
{
  "success": true,
  "queueLength": 23
}
```

Queue behavior:

- `set` takes priority over all other operations.
- `move` takes priority after `set`.
- If neither `set` nor `move` is present, the server applies `remove` first,
  then `add`.
- Queue operations use positions, not track IDs, for removal and movement.
- Unknown track IDs are ignored.
- Queue changes are broadcast over WebSocket with `queue` included.

### POST /api/channels/:channelId/mode

Set playback mode.

Requires `control` permission.

Body:

```json
{
  "mode": "repeat-all"
}
```

Valid modes:

- `once`
- `repeat-all`
- `repeat-one`
- `shuffle`

Response:

```json
{
  "success": true,
  "playbackMode": "repeat-all"
}
```

## Channel WebSocket

### WS /api/channels/:channelId/ws

Connect to live channel state.

Reference server example URL:

```text
ws://mhsgroove.peterino.com:3001/api/channels/default/ws
```

Local example URL:

```text
ws://localhost:3001/api/channels/default/ws
```

If using `https`, use `wss`.

The server associates the socket with the current session cookie. If guests are
enabled and no session exists, the upgrade creates a guest user.

On connect, the server immediately sends a full `ChannelState` with `queue`.

### Client Messages

Switch channels:

```json
{
  "action": "switch",
  "channelId": "k8s2m1qp"
}
```

Switching does not require `control` permission.

Pause:

```json
{
  "action": "pause"
}
```

Unpause:

```json
{
  "action": "unpause"
}
```

Seek:

```json
{
  "action": "seek",
  "timestamp": 45.5
}
```

Jump:

```json
{
  "action": "jump",
  "index": 3
}
```

Control messages require a signed-in, non-guest user with `control`
permission. Unauthorized WebSocket control messages are ignored rather than
reported as errors.

### Server Messages

Channel list update:

```json
{
  "type": "channel_list",
  "channels": []
}
```

Switch confirmation:

```json
{
  "type": "switched",
  "channelId": "k8s2m1qp"
}
```

Channel state:

```json
{
  "track": {},
  "currentTimestamp": 12.3,
  "channelId": "default",
  "channelName": "Default",
  "paused": false,
  "currentIndex": 0,
  "listenerCount": 1,
  "isDefault": true,
  "playbackMode": "repeat-all",
  "queue": []
}
```

`queue` is included:

- on initial connect,
- after queue changes,
- after full queue replacement,
- roughly once per minute as a refresh.

Error:

```json
{
  "type": "error",
  "message": "Channel not found"
}
```

Kick:

```json
{
  "type": "kick",
  "reason": "Kicked by another session"
}
```

Toast:

```json
{
  "type": "toast",
  "message": "Added: Song",
  "toastType": "info"
}
```

Scan progress:

```json
{
  "type": "scan_progress",
  "scanning": true,
  "processed": 10,
  "total": 42
}
```

Fetch progress:

```json
{
  "type": "fetch_progress",
  "id": "x9ab12cd",
  "title": "Downloaded Song",
  "status": "downloading",
  "progress": 50,
  "queueType": "fast",
  "playlistId": null,
  "playlistName": null,
  "error": null
}
```

Other fetch message types include:

- `fetch_queued`
- `fetch_complete`
- `fetch_error`
- `fetch_cancelled`

## Track And Library Endpoints

### GET /api/library

List all library tracks.

Requires an authenticated session, or creates a guest session if guests are
enabled.

Response:

```json
[
  {
    "id": "sha256:abc123",
    "filename": "song.mp3",
    "title": "Song",
    "artist": "Artist",
    "album": "Album",
    "duration": 180.5,
    "replayGainDb": -7.2,
    "replayPeak": 0.91,
    "available": true
  }
]
```

### GET /api/tracks/:trackId

Serve audio by content-addressed track ID.

Requires an authenticated session, or creates a guest session if guests are
enabled.

Example:

```text
GET /api/tracks/sha256%3Aabc123
```

Response headers:

```text
Accept-Ranges: bytes
Content-Length: 12345678
Content-Type: audio/mpeg
```

Range requests are supported:

```text
Range: bytes=0-999999
```

Partial response:

```text
HTTP/1.1 206 Partial Content
Content-Range: bytes 0-999999/12345678
Accept-Ranges: bytes
Content-Length: 1000000
Content-Type: audio/mpeg
```

Standard clients should only request by `track.id`. The implementation also has
a filename fallback for older clients, but that is not part of the recommended
frontend contract.

### POST /api/upload

Upload an audio file.

Requires an authenticated session, or creates a guest session if guests are
enabled.

Body:

```text
multipart/form-data
field name: file
```

Accepted extensions:

```text
.mp3 .ogg .flac .wav .m4a .aac .opus .wma .mp4
```

Response:

```json
{
  "success": true,
  "filename": "uploaded_file.mp3"
}
```

Common errors:

- `400` no file or invalid audio format
- `409` file already exists
- `500` upload failed

After upload, the library watcher scans the file and broadcasts updates.

## Playlist Endpoints

### GET /api/playlists

List the current user's playlists plus public playlists owned by others.

Requires an authenticated session.

Response:

```json
{
  "mine": [
    {
      "id": "uuid",
      "name": "Favorites",
      "description": "",
      "ownerId": 1,
      "isPublic": false,
      "shareToken": null,
      "trackIds": ["sha256:abc123"],
      "createdAt": 1760000000,
      "updatedAt": 1760000000
    }
  ],
  "shared": []
}
```

### POST /api/playlists

Create a playlist.

Requires an authenticated non-guest user.

Body:

```json
{
  "name": "Favorites",
  "description": "Tracks I keep returning to"
}
```

Response `201`: `Playlist`

Common errors:

- `400` invalid JSON or missing name
- `401` unauthorized
- `403` guests cannot create playlists

### GET /api/playlists/:playlistId

Get playlist details.

Access is allowed when:

- the current user owns the playlist,
- the playlist is public,
- or the query string token matches the playlist share token.

Optional query:

```text
?token=share-token
```

Response:

```json
{
  "id": "uuid",
  "name": "Favorites",
  "description": "",
  "ownerId": 1,
  "ownerName": "alice",
  "isPublic": false,
  "shareToken": "abc123def456",
  "trackIds": ["sha256:abc123"],
  "createdAt": 1760000000,
  "updatedAt": 1760000000
}
```

### PATCH /api/playlists/:playlistId

Update playlist metadata.

Requires owner or admin.

Body:

```json
{
  "name": "New Name",
  "description": "New description",
  "isPublic": true
}
```

All fields are optional. Sent string fields are trimmed.

Response:

```json
{
  "ok": true
}
```

### DELETE /api/playlists/:playlistId

Delete a playlist.

Requires owner or admin.

Response:

```json
{
  "ok": true
}
```

### PATCH /api/playlists/:playlistId/tracks

Modify playlist tracks.

Requires owner or admin.

Set the whole playlist:

```json
{
  "set": ["sha256:a", "sha256:b"]
}
```

Add tracks to the end:

```json
{
  "add": ["sha256:c"]
}
```

Insert tracks at an index:

```json
{
  "add": ["sha256:c"],
  "insertAt": 1
}
```

Remove by playlist position:

```json
{
  "remove": [0, 2]
}
```

Move positions to a target index:

```json
{
  "move": [3, 4],
  "to": 1
}
```

Response:

```json
{
  "ok": true
}
```

Rules:

- `set` takes priority over every other operation.
- `move` takes priority after `set`.
- Otherwise the server applies `remove` first, then `add`.
- Operations use positions, not track IDs, for removal and movement.

### POST /api/playlists/:playlistId/share

Generate or replace a share token.

Requires owner or admin.

Response:

```json
{
  "shareToken": "abc123def456"
}
```

### DELETE /api/playlists/:playlistId/share

Remove playlist sharing.

Requires owner or admin.

Response:

```json
{
  "ok": true
}
```

### GET /api/playlists/shared/:token

Get a playlist by share token.

Response:

```json
{
  "id": "uuid",
  "name": "Favorites",
  "description": "",
  "ownerId": 1,
  "ownerName": "alice",
  "isPublic": false,
  "shareToken": "abc123def456",
  "trackIds": ["sha256:abc123"],
  "createdAt": 1760000000,
  "updatedAt": 1760000000
}
```

### POST /api/playlists/shared/:token

Copy a shared playlist into the current user's playlists.

Requires an authenticated non-guest user.

Response `201`: copied `Playlist`

Note: the current route copies directly at `POST /api/playlists/shared/:token`;
there is no `/copy` suffix in the implementation.

## URL Fetch Endpoints

These endpoints are available only when the `yt-dlp` feature is enabled and
available.

### POST /api/fetch

Check a URL and queue it for download if it is a single item.

Requires an authenticated non-guest user.

Body:

```json
{
  "url": "https://example.com/watch?v=..."
}
```

Single-item response:

```json
{
  "type": "single",
  "id": "x9ab12cd",
  "title": "Song Title",
  "queueType": "fast"
}
```

Playlist response:

```json
{
  "type": "playlist",
  "title": "Playlist Title",
  "count": 25,
  "items": [
    {
      "id": "a1b2c3d4",
      "url": "https://example.com/watch?v=...",
      "title": "Track Title"
    }
  ],
  "requiresConfirmation": true
}
```

When the response is `type: "playlist"`, call `/api/fetch/confirm` to enqueue
the items.

Common errors:

- `400` URL missing or invalid
- `403` guests cannot fetch, feature disabled, or playlist downloads disabled
- `503` yt-dlp unavailable

### POST /api/fetch/confirm

Confirm and enqueue playlist download items. The server creates a playlist for
the imported items and slowly downloads them.

Requires an authenticated non-guest user.

Body:

```json
{
  "playlistTitle": "Playlist Title",
  "items": [
    {
      "url": "https://example.com/watch?v=...",
      "title": "Track Title"
    }
  ]
}
```

Response:

```json
{
  "message": "Added 25 items to queue",
  "queueType": "slow",
  "estimatedTime": "1h 15m",
  "playlistId": "uuid",
  "playlistName": "Playlist Title",
  "items": [
    {
      "id": "x9ab12cd",
      "title": "Track Title"
    }
  ]
}
```

### GET /api/fetch

Get fetch queue status. All authenticated users can see all current queue
items.

Response:

```json
{
  "fastQueue": [],
  "slowQueue": [],
  "slowQueueNextIn": 120
}
```

### DELETE /api/fetch/:itemId

Cancel a slow queue item owned by the current user.

Response:

```json
{
  "message": "Item cancelled"
}
```

The server cannot cancel an item that is already downloading.

### DELETE /api/fetch

Cancel all queued slow queue items owned by the current user.

Response:

```json
{
  "message": "Cancelled 3 items",
  "cancelled": 3
}
```

## Static Routes

These are not required for custom frontends, but they exist in the bundled web
app.

```text
GET /                  -> public/index.html
GET /index.html        -> public/index.html
GET /listen/:trackId   -> public/index.html for direct local playback
GET /styles.css        -> public/styles.css
GET /favicon.ico       -> public/favicon.ico
GET /*.js              -> public JavaScript files
```

Direct listening links should encode the track ID:

```text
/listen/sha256%3Aabc123
```

## Playback Sync Algorithm

Clients should implement the synced player like this:

```ts
let lastState: ChannelState | null = null;
let lastStateAt = 0;

function estimatedServerTime() {
  if (!lastState?.track) return 0;
  if (lastState.paused) return lastState.currentTimestamp;
  return lastState.currentTimestamp + (performance.now() - lastStateAt) / 1000;
}

async function applyChannelState(state: ChannelState, audio: HTMLAudioElement) {
  lastState = state;
  lastStateAt = performance.now();

  if (state.queue) {
    renderQueue(state.queue, state.currentIndex);
  }

  if (!state.track) {
    audio.pause();
    audio.removeAttribute("src");
    return;
  }

  const trackId = state.track.id;
  const targetTime = state.currentTimestamp;
  const nextSrc = `/api/tracks/${encodeURIComponent(trackId)}`;

  if (!audio.src.endsWith(encodeURIComponent(trackId))) {
    audio.src = nextSrc;
    audio.currentTime = targetTime;
  } else if (Math.abs(audio.currentTime - targetTime) >= 2) {
    audio.currentTime = targetTime;
  }

  if (state.paused) {
    audio.pause();
  } else {
    await audio.play().catch(() => {
      // Browser autoplay policy: show a click-to-play affordance.
    });
  }
}
```

For blob URLs or custom caching, track the current `track.id` separately instead
of checking `audio.src`.

## Caching Contract

Caching is optional. If implemented, key everything by `track.id`.

Recommended browser cache shape:

```ts
type TrackId = string;

interface CachedTrack {
  blob: Blob;
  contentType: string;
}
```

Useful behavior:

- Store complete track blobs in IndexedDB using `track.id`.
- Use `/api/tracks/:trackId` for full downloads.
- Use `Range` requests against `/api/tracks/:trackId` to fill seekable
  segments.
- Keep a per-track segment map, for example `Map<trackId, Set<segmentIndex>>`.
- Treat a fully cached track as ready for blob URL playback.

The bundled client divides tracks into 20 virtual segments, but that number is
not part of the server contract.

## Minimal Frontend Checklist

A working synced frontend only needs:

1. Login/signup/guest session via `/api/auth/me`, `/login`, and `/signup`.
2. Library list via `/api/library`.
3. Channel list via `/api/channels`.
4. WebSocket connection to `/api/channels/:id/ws`.
5. Audio playback from `/api/tracks/:trackId`.
6. Drift correction when local audio differs from server time by 2 seconds or
   more.
7. Queue rendering that accepts sparse `queue` updates.

Everything else is additive.
