# 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.