blastoise/docs/api-reference-full.md

1528 lines
25 KiB
Markdown

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