blastoise/docs/api-reference.md

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

Default local 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

  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:

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

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:

  • 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:

{
  "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:

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

Response 200:

{
  "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:

{
  "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:

  • 401 not 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:

  • 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:

{
  "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:

  • 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:

{
  "mode": "repeat-all"
}

Valid modes:

  • once
  • repeat-all
  • repeat-one
  • shuffle

Response:

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

Channel WebSocket

WS /api/channels/:channelId/ws

Connect to live channel state.

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_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:

[
  {
    "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:

  • 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:

{
  "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:

  • 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:

?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:

  • 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:

{
  "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:

  • 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:

{
  "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/: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.