From adc450f14fc05982808d58c53b7b3435b2da66a4 Mon Sep 17 00:00:00 2001 From: Peter Li Date: Sun, 7 Jun 2026 19:59:32 -0700 Subject: [PATCH] reference api brief --- docs/api-reference-full.md | 1527 ++++++++++++++++++++++++++++++++++ docs/api-reference.md | 1581 +++--------------------------------- docs/buildme.md | 8 +- 3 files changed, 1649 insertions(+), 1467 deletions(-) create mode 100644 docs/api-reference-full.md diff --git a/docs/api-reference-full.md b/docs/api-reference-full.md new file mode 100644 index 0000000..87dc87b --- /dev/null +++ b/docs/api-reference-full.md @@ -0,0 +1,1527 @@ +# 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>`. +- 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. diff --git a/docs/api-reference.md b/docs/api-reference.md index 87dc87b..b58b481 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1,1527 +1,176 @@ # 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: +Blastoise is a synchronized music server. The server owns channel time and +queues; clients play audio locally. ```text -Render metadata from the API, but play audio by track.id. -GET /api/tracks/:trackId is the canonical audio URL. +Reference HTTP: http://mhsgroove.peterino.com:3001 +Reference WS: ws://mhsgroove.peterino.com:3001 +Local HTTP: http://localhost:3001 +Local WS: ws://localhost:3001 ``` -## Base URL +Auth is an HttpOnly cookie named `blastoise_session`. Same-origin browser apps +can use normal `fetch`. Separate-origin browser apps need a same-origin proxy +or CORS with credentials. Native apps must store `Set-Cookie` and send it as +`Cookie` on HTTP and WebSocket requests. -Reference server: +Full details: [api-reference-full.md](./api-reference-full.md) + +## Golden Rule + +Use `track.id` for every machine operation: ```text -HTTP: http://mhsgroove.peterino.com:3001 -WS: ws://mhsgroove.peterino.com:3001 +GET /api/tracks/:trackId ``` -Local development server: +`track.id` is a content hash like `sha256:...`. `filename` and `title` are only +for display. Queue entries, playlists, cache keys, direct links, and audio URLs +should all use `track.id`. -```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 +## Core Shapes ```ts -interface User { - id: number; - username: string; - isAdmin: boolean; - isGuest?: boolean; -} -``` +type PlaybackMode = "once" | "repeat-all" | "repeat-one" | "shuffle"; -`isGuest` is included by `GET /api/auth/me`. Login and signup responses include -`id`, `username`, and `isAdmin`. +type Track = { + id: string; filename: string; title: string | null; + artist?: string | null; album?: string | null; duration: number; + replayGainDb?: number | null; replayPeak?: number | null; available?: boolean; +}; -### 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; +type ChannelInfo = { + id: string; name: string; description: string; trackCount: number; + listenerCount: number; listeners: string[]; isDefault: boolean; createdBy: number | null; -} +}; + +type ChannelState = { + track: Track | null; currentTimestamp: number; channelName: string; + channelId: string; description: string; paused: boolean; currentIndex: number; + listenerCount: number; isDefault: boolean; playbackMode: PlaybackMode; + queue?: Track[]; +}; + +type Playlist = { + id: string; name: string; description: string; ownerId: number; + ownerName?: string; isPublic: boolean; shareToken: string | null; + trackIds: string[]; createdAt: number; updatedAt: number; +}; ``` -### ChannelState +`ChannelState.queue` is optional. It appears on WebSocket connect, queue +changes, and periodic refreshes. Keep the last known queue when omitted. -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: +## Startup ```text -ws://mhsgroove.peterino.com:3001/api/channels/default/ws +GET /api/status +GET /api/auth/me +GET /api/library +GET /api/channels +WS /api/channels/:channelId/ws ``` -Local example URL: +Choose a channel: saved channel, else `isDefault`, else first channel. -```text -ws://localhost:3001/api/channels/default/ws -``` +## Endpoints -If using `https`, use `wss`. +| Area | Endpoints | +|---|---| +| Status | `GET /api/status` | +| Auth | `POST /api/auth/signup`, `POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/me`, `POST /api/auth/kick-others` | +| Channels | `GET /api/channels`, `POST /api/channels`, `GET/PATCH/DELETE /api/channels/:id` | +| Playback control | `POST /api/channels/:id/jump`, `POST /api/channels/:id/seek`, `POST /api/channels/:id/mode` | +| Queue | `PATCH /api/channels/:id/queue` | +| Library/audio | `GET /api/library`, `GET /api/tracks/:trackId`, `POST /api/upload` | +| Playlists | `GET/POST /api/playlists`, `GET/PATCH/DELETE /api/playlists/:id`, `PATCH /api/playlists/:id/tracks` | +| Sharing | `POST/DELETE /api/playlists/:id/share`, `GET/POST /api/playlists/shared/:token` | +| URL import | `POST /api/fetch`, `POST /api/fetch/confirm`, `GET /api/fetch`, `DELETE /api/fetch/:itemId`, `DELETE /api/fetch` | -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: +Common bodies: ```json -{ - "action": "switch", - "channelId": "k8s2m1qp" -} +{ "username": "test", "password": "testuser" } +{ "name": "Channel or playlist name", "description": "optional" } +{ "mode": "repeat-all" } +{ "index": 3 } +{ "timestamp": 45.5 } ``` -Switching does not require `control` permission. - -Pause: +Queue and playlist track mutation: ```json -{ - "action": "pause" -} +{ "set": ["sha256:a", "sha256:b"] } +{ "add": ["sha256:c"], "insertAt": 2 } +{ "remove": [3, 4] } +{ "move": [5, 6], "to": 1 } ``` -Unpause: +Remove/move use positions, not track IDs. Duplicate tracks are allowed. -```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: +Audio supports range requests: ```text Range: bytes=0-999999 ``` -Partial response: +## WebSocket + +Connect to: ```text -HTTP/1.1 206 Partial Content -Content-Range: bytes 0-999999/12345678 -Accept-Ranges: bytes -Content-Length: 1000000 -Content-Type: audio/mpeg +ws://mhsgroove.peterino.com:3001/api/channels/:channelId/ws ``` -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: +Client messages: ```json -{ - "success": true, - "filename": "uploaded_file.mp3" -} +{ "action": "switch", "channelId": "abc123" } +{ "action": "pause" } +{ "action": "unpause" } +{ "action": "seek", "timestamp": 45.5 } +{ "action": "jump", "index": 3 } ``` -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: +Server messages: ```json -{ - "mine": [ - { - "id": "uuid", - "name": "Favorites", - "description": "", - "ownerId": 1, - "isPublic": false, - "shareToken": null, - "trackIds": ["sha256:abc123"], - "createdAt": 1760000000, - "updatedAt": 1760000000 - } - ], - "shared": [] -} +{ "type": "channel_list", "channels": [] } +{ "type": "switched", "channelId": "abc123" } +{ "type": "kick", "reason": "Kicked by another session" } +{ "type": "toast", "message": "Added: Song", "toastType": "info" } +{ "type": "scan_progress", "scanning": true, "processed": 1, "total": 20 } +{ "type": "fetch_progress", "id": "job", "status": "downloading", "progress": 50 } ``` -### POST /api/playlists +Any message without `type` is a `ChannelState`. -Create a playlist. +Guests can listen and switch channels, but cannot control playback or mutate +queues. Unauthorized WebSocket control messages are ignored. -Requires an authenticated non-guest user. +## Sync Algorithm -Body: +On every `ChannelState`: -```json -{ - "name": "Favorites", - "description": "Tracks I keep returning to" -} -``` +1. Store the state and `performance.now()`. +2. If `state.queue` exists, replace the local queue cache. +3. If `state.track` is null, pause and clear the player. +4. If `state.track.id` changed, set `audio.src` to `/api/tracks/:trackId` and + seek to `state.currentTimestamp`. +5. If same track and drift is `>= 2s`, seek to `state.currentTimestamp`. +6. If `state.paused`, pause. Otherwise call `audio.play()`. +7. Between WebSocket updates, estimate time as + `state.currentTimestamp + elapsedSeconds`, unless paused. -Response `201`: `Playlist` +The server is the source of truth. -Common errors: +## Gotchas -- `400` invalid JSON or missing name -- `401` unauthorized -- `403` guests cannot create playlists +- Some errors are JSON `{ "error": "..." }`; some are plain text. Handle both. +- `GET /api/channels/:id` does not include the queue. WebSocket connect does. +- `POST /api/playlists/shared/:token` copies a playlist; there is no `/copy`. +- Cache by `track.id`, never by filename. +- The server does not decode audio. Clients are synchronized local players. -### 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>`. -- 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. diff --git a/docs/buildme.md b/docs/buildme.md index a7f74fb..8f12619 100644 --- a/docs/buildme.md +++ b/docs/buildme.md @@ -4,12 +4,18 @@ This is a pasteable build brief for an LLM or coding agent. It tells the agent how to build a frontend for a Blastoise music server without needing to read the server code. -Reference the API contract in: +Reference the short API contract in: ```text docs/api-reference.md ``` +Use the full reference for edge cases: + +```text +docs/api-reference-full.md +``` + ## Paste This Prompt Into Your LLM ```text