1528 lines
25 KiB
Markdown
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.
|