7.6 KiB
MusicRoom
Synchronized music streaming server built with Bun. Manages "channels" (virtual radio stations) that play through queues sequentially. Clients connect, receive now-playing state, download audio, and sync playback locally.
Architecture
Server
The server does NOT decode or play audio. It tracks time:
currentTimestamp = (Date.now() - channel.startedAt) / 1000- When
currentTimestamp >= track.duration, advance to next track, resetstartedAt - A 1s
setIntervalchecks if tracks need advancing and broadcasts state every 30s
Channels
Channels are virtual radio stations. A default channel is created on server start with the full library:
interface Channel {
id: string;
name: string;
description: string;
queue: Track[]; // tracks in playback order
currentIndex: number;
startedAt: number;
paused: boolean;
clients: Set<WebSocket>;
createdBy: number | null; // user ID or null for default
isDefault: boolean;
}
- Default Channel: Created on startup, plays all library tracks, cannot be deleted
- Dynamic Channels: Users can create channels via POST
/api/channels - Channel Switching: Clients switch channels via WebSocket
{ action: "switch", channelId }
Client
The player's role is simple: play an arbitrary track by ID. It does not manage queues or sync logic directly.
- Receives track ID and timestamp from server via WebSocket
- Downloads audio from
/api/tracks/:id - Syncs playback position to server timestamp
- Caches tracks locally in IndexedDB
Library & Queue Views
Both views display tracks with real-time status indicators:
- Green bar: Track is fully cached locally (in IndexedDB)
- Yellow bar: Track is not cached
The buffer bar (below progress) shows 20 segments indicating download/buffer status:
- Segments fill as audio is buffered by browser or fetched via range requests
- When all segments are buffered, the track is automatically cached to IndexedDB
Content-Addressed Tracks
All tracks are identified by a content hash (sha256: prefix + first 64KB hash):
| Field | Purpose |
|---|---|
track.id |
Content hash - primary key, used for API, caching, database |
track.filename |
Original filename - display only |
track.title |
Metadata title - display only |
Benefits:
- Deduplication (same file with different names = same track)
- Renaming files without breaking queues
- Reliable client-side caching by content hash
The client uses track.id for:
- Caching tracks in IndexedDB (
TrackStorage.set(trackId, blob)) - Fetching audio (
/api/tracks/:trackId) - Checking cache status (
M.cachedTracks.has(trackId))
Client Caching System
Segment-Based Buffering
- Tracks are divided into 20 virtual segments
- Browser's native buffering is synced to
M.trackCachesMap - Range requests fetch individual segments for seeking
Full Track Caching
When all 20 segments are buffered:
- Full track is downloaded via
downloadAndCacheTrack() - Stored in IndexedDB via
TrackStorage - Added to
M.cachedTracksSet - UI indicators update to green
Cache State (in-memory)
M.trackCaches // Map<trackId, Set<segmentIndex>> - per-track segment status
M.cachedTracks // Set<trackId> - tracks fully cached in IndexedDB
M.trackBlobs // Map<trackId, blobUrl> - blob URLs for cached tracks
Routes
GET / → Serves public/index.html
GET /api/channels → List all channels with listener counts
POST /api/channels → Create a new channel
GET /api/channels/:id → Get channel state
DELETE /api/channels/:id → Delete a channel (not default)
WS /api/channels/:id/ws → WebSocket: pushes state on connect and changes
GET /api/tracks/:id → Serve audio by content hash (supports Range)
GET /api/library → List all tracks with id, filename, title, duration
Files
Server
- server.ts — Bun entrypoint. Imports and starts the server.
- config.ts — Config types, loading, and exports (port, musicDir, etc).
- state.ts — Shared application state (channels Map, userConnections Map, library).
- init.ts — Server initialization, channel loading, tick interval, library events.
- broadcast.ts — Broadcast utilities (broadcastToAll, sendToUser, broadcastChannelList).
- websocket.ts — WebSocket open/close/message handlers.
- channel.ts —
Channelclass. Queue, current index, time tracking, broadcasting. - library.ts —
Libraryclass. Scans music directory, computes content hashes. - db.ts — SQLite database for users, sessions, tracks.
- auth.ts — Auth helpers (getUser, requirePermission, session cookies).
- ytdlp.ts — yt-dlp integration for fetching audio from URLs.
Routes (routes/)
- index.ts — Main router, dispatches to route handlers.
- helpers.ts — Shared route helpers (getOrCreateUser, userHasPermission).
- auth.ts — Auth endpoints (signup, login, logout, me, admin).
- channels.ts — Channel CRUD and control (list, create, delete, jump, seek, queue, mode).
- tracks.ts — Library listing, file upload, audio serving with range support.
- fetch.ts — yt-dlp fetch endpoints (check URL, confirm playlist, queue status).
- static.ts — Static file serving (index.html, styles.css, JS files).
Client (public/)
- core.js — Global state namespace (
window.MusicRoom) - utils.js — Helpers ($, fmt, showToast)
- audioCache.js — Track caching, segment downloads, prefetching
- channelSync.js — WebSocket connection, server sync, channel switching
- ui.js — Progress bar, buffer display, UI updates
- queue.js — Queue/library rendering, cache status
- controls.js — Play, pause, seek, volume
- auth.js — Login, signup, logout
- init.js — App initialization
- trackStorage.js — IndexedDB abstraction for track blobs
Config
- config.json — Server configuration (port, musicDir, permissions)
- music/ — Audio files (.mp3, .ogg, .flac, .wav, .m4a, .aac)
Key Types
interface Track {
id: string; // Content hash (sha256:...)
filename: string; // Original filename
title: string; // Display title
duration: number;
}
// Channel.getState() returns:
{
track: Track | null,
currentTimestamp: number,
channelName: string,
channelId: string,
description: string,
paused: boolean,
queue: Track[],
currentIndex: number,
listenerCount: number,
listeners: string[], // usernames of connected users
isDefault: boolean
}
WebSocket Messages
Client → Server:
{ "action": "switch", "channelId": "abc123" }
{ "action": "pause" }
{ "action": "unpause" }
{ "action": "seek", "timestamp": 45.5 }
{ "action": "jump", "index": 3 }
Server → Client:
{ "type": "channel_list", "channels": [...] }
{ "type": "switched", "channelId": "abc123" }
{ "track": {...}, "currentTimestamp": 45.5, ... }
Client Sync Logic
On WebSocket message:
- New track → load audio by
track.id, seek to server timestamp, play - Same track, drift < 2s → ignore
- Same track, drift >= 2s → seek to server timestamp
Debug Functions
Available in browser console:
MusicRoom.debugCacheMismatch() // Compare queue IDs vs cached IDs
MusicRoom.debugTrack(index) // Detailed cache state for track at index
MusicRoom.debugCacheStatus() // Current track cache state
MusicRoom.clearAllCaches() // Clear IndexedDB and in-memory caches
Config
Default port 3001 (override with PORT env var). Track durations read from file metadata on startup with music-metadata.
Test User
- Username: test
- Password: testuser