25 KiB
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:
Render metadata from the API, but play audio by track.id.
GET /api/tracks/:trackId is the canonical audio URL.
Base URL
Reference server:
HTTP: http://mhsgroove.peterino.com:3001
WS: ws://mhsgroove.peterino.com:3001
Local development server:
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
- Call
GET /api/statusto learn server features. - Call
GET /api/auth/meto get the current user or create a guest session if guests are enabled. - Call
GET /api/libraryto load track metadata. - Call
GET /api/channelsto find the default or saved channel. - Connect to
WS /api/channels/:id/ws. - On channel state, load
/api/tracks/:track.id, seek tocurrentTimestamp, and play or pause according topaused.
The server advances channel time by comparing:
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:
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.allowGuestsis true, many authenticated endpoints will automatically create a guest user and returnSet-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.
controlpermission is required for synced playback controls and queue mutation.- Server
defaultPermissionscan grant channel permissions globally. - Guests never receive effective
controlpermission.
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
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
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:.
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.
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.
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
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.
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:
{
"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:
{
"username": "test",
"password": "testuser"
}
Rules:
usernameis required and must be at least 3 characters.passwordis required and must be at least 6 characters.- Username must be unique.
Response 200:
{
"user": {
"id": 1,
"username": "test",
"isAdmin": true
}
}
Sets blastoise_session.
Common errors:
400username/password missing, too short, or username taken500signup failed
POST /api/auth/login
Start a session for an existing user.
Body:
{
"username": "test",
"password": "testuser"
}
Response 200:
{
"user": {
"id": 1,
"username": "test",
"isAdmin": false
}
}
Sets blastoise_session.
Common errors:
401invalid username or password500login failed
POST /api/auth/logout
Delete the current session and clear the session cookie.
Response:
{
"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:
{
"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:
{
"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:
{
"kicked": 2
}
Common errors:
401not authenticated
Admin Endpoints
GET /api/admin/users
List all non-guest users.
Requires global admin permission.
Response:
[
{
"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:
{
"resourceType": "channel",
"resourceId": "default",
"permission": "control"
}
Use resourceId: null for a global resource-type permission.
Response:
{
"success": true
}
DELETE /api/admin/users/:userId/permissions
Revoke a permission.
Requires global admin permission.
Body:
{
"resourceType": "channel",
"resourceId": "default",
"permission": "control"
}
Response:
{
"success": true
}
Channel Endpoints
GET /api/channels
List channels.
Requires an authenticated session, or creates a guest session if guests are enabled.
Response:
[
{
"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:
{
"name": "Late Night",
"description": "Quiet queue",
"trackIds": ["sha256:abc123"]
}
Rules:
nameis required.namemust be 64 characters or less.descriptionis optional.trackIdsis optional. Unknown or unavailable track IDs are ignored.
Response 201:
{
"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:
{
"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:
{
"name": "New Channel Name"
}
Response:
{
"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:
{
"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:
{
"index": 3
}
Response:
{
"success": true
}
POST /api/channels/:channelId/seek
Seek the channel to a timestamp in the current track.
Requires control permission.
Body:
{
"timestamp": 45.5
}
Response:
{
"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:
{
"set": ["sha256:a", "sha256:b", "sha256:c"]
}
Add tracks to the end:
{
"add": ["sha256:d", "sha256:e"]
}
Insert tracks at an index:
{
"add": ["sha256:d"],
"insertAt": 2
}
Remove by queue position:
{
"remove": [3, 4]
}
Move queue positions to a target index:
{
"move": [5, 6],
"to": 1
}
Response:
{
"success": true,
"queueLength": 23
}
Queue behavior:
settakes priority over all other operations.movetakes priority afterset.- If neither
setnormoveis present, the server appliesremovefirst, thenadd. - Queue operations use positions, not track IDs, for removal and movement.
- Unknown track IDs are ignored.
- Queue changes are broadcast over WebSocket with
queueincluded.
POST /api/channels/:channelId/mode
Set playback mode.
Requires control permission.
Body:
{
"mode": "repeat-all"
}
Valid modes:
oncerepeat-allrepeat-oneshuffle
Response:
{
"success": true,
"playbackMode": "repeat-all"
}
Channel WebSocket
WS /api/channels/:channelId/ws
Connect to live channel state.
Reference server example URL:
ws://mhsgroove.peterino.com:3001/api/channels/default/ws
Local example URL:
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:
{
"action": "switch",
"channelId": "k8s2m1qp"
}
Switching does not require control permission.
Pause:
{
"action": "pause"
}
Unpause:
{
"action": "unpause"
}
Seek:
{
"action": "seek",
"timestamp": 45.5
}
Jump:
{
"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:
{
"type": "channel_list",
"channels": []
}
Switch confirmation:
{
"type": "switched",
"channelId": "k8s2m1qp"
}
Channel state:
{
"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:
{
"type": "error",
"message": "Channel not found"
}
Kick:
{
"type": "kick",
"reason": "Kicked by another session"
}
Toast:
{
"type": "toast",
"message": "Added: Song",
"toastType": "info"
}
Scan progress:
{
"type": "scan_progress",
"scanning": true,
"processed": 10,
"total": 42
}
Fetch progress:
{
"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_queuedfetch_completefetch_errorfetch_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:
[
{
"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:
GET /api/tracks/sha256%3Aabc123
Response headers:
Accept-Ranges: bytes
Content-Length: 12345678
Content-Type: audio/mpeg
Range requests are supported:
Range: bytes=0-999999
Partial response:
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:
multipart/form-data
field name: file
Accepted extensions:
.mp3 .ogg .flac .wav .m4a .aac .opus .wma .mp4
Response:
{
"success": true,
"filename": "uploaded_file.mp3"
}
Common errors:
400no file or invalid audio format409file already exists500upload 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:
{
"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:
{
"name": "Favorites",
"description": "Tracks I keep returning to"
}
Response 201: Playlist
Common errors:
400invalid JSON or missing name401unauthorized403guests 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:
?token=share-token
Response:
{
"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:
{
"name": "New Name",
"description": "New description",
"isPublic": true
}
All fields are optional. Sent string fields are trimmed.
Response:
{
"ok": true
}
DELETE /api/playlists/:playlistId
Delete a playlist.
Requires owner or admin.
Response:
{
"ok": true
}
PATCH /api/playlists/:playlistId/tracks
Modify playlist tracks.
Requires owner or admin.
Set the whole playlist:
{
"set": ["sha256:a", "sha256:b"]
}
Add tracks to the end:
{
"add": ["sha256:c"]
}
Insert tracks at an index:
{
"add": ["sha256:c"],
"insertAt": 1
}
Remove by playlist position:
{
"remove": [0, 2]
}
Move positions to a target index:
{
"move": [3, 4],
"to": 1
}
Response:
{
"ok": true
}
Rules:
settakes priority over every other operation.movetakes priority afterset.- Otherwise the server applies
removefirst, thenadd. - 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:
{
"shareToken": "abc123def456"
}
DELETE /api/playlists/:playlistId/share
Remove playlist sharing.
Requires owner or admin.
Response:
{
"ok": true
}
GET /api/playlists/shared/:token
Get a playlist by share token.
Response:
{
"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:
{
"url": "https://example.com/watch?v=..."
}
Single-item response:
{
"type": "single",
"id": "x9ab12cd",
"title": "Song Title",
"queueType": "fast"
}
Playlist response:
{
"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:
400URL missing or invalid403guests cannot fetch, feature disabled, or playlist downloads disabled503yt-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:
{
"playlistTitle": "Playlist Title",
"items": [
{
"url": "https://example.com/watch?v=...",
"title": "Track Title"
}
]
}
Response:
{
"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:
{
"fastQueue": [],
"slowQueue": [],
"slowQueueNextIn": 120
}
DELETE /api/fetch/:itemId
Cancel a slow queue item owned by the current user.
Response:
{
"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:
{
"message": "Cancelled 3 items",
"cancelled": 3
}
Static Routes
These are not required for custom frontends, but they exist in the bundled web app.
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:
/listen/sha256%3Aabc123
Playback Sync Algorithm
Clients should implement the synced player like this:
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:
type TrackId = string;
interface CachedTrack {
blob: Blob;
contentType: string;
}
Useful behavior:
- Store complete track blobs in IndexedDB using
track.id. - Use
/api/tracks/:trackIdfor full downloads. - Use
Rangerequests against/api/tracks/:trackIdto 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:
- Login/signup/guest session via
/api/auth/me,/login, and/signup. - Library list via
/api/library. - Channel list via
/api/channels. - WebSocket connection to
/api/channels/:id/ws. - Audio playback from
/api/tracks/:trackId. - Drift correction when local audio differs from server time by 2 seconds or more.
- Queue rendering that accepts sparse
queueupdates.
Everything else is additive.