280 lines
9.5 KiB
Markdown
280 lines
9.5 KiB
Markdown
# 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, reset `startedAt`
|
||
- A 1s `setInterval` checks 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:
|
||
|
||
```ts
|
||
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.trackCaches` Map
|
||
- Range requests fetch individual segments for seeking
|
||
|
||
### Full Track Caching
|
||
When all 20 segments are buffered:
|
||
1. Full track is downloaded via `downloadAndCacheTrack()`
|
||
2. Stored in IndexedDB via `TrackStorage`
|
||
3. Added to `M.cachedTracks` Set
|
||
4. UI indicators update to green
|
||
|
||
### Cache State (in-memory)
|
||
```js
|
||
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** — `Channel` class. Queue, current index, time tracking, broadcasting.
|
||
- **library.ts** — `Library` class. 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
|
||
|
||
```ts
|
||
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:**
|
||
```json
|
||
{ "action": "switch", "channelId": "abc123" }
|
||
{ "action": "pause" }
|
||
{ "action": "unpause" }
|
||
{ "action": "seek", "timestamp": 45.5 }
|
||
{ "action": "jump", "index": 3 }
|
||
```
|
||
|
||
**Server → Client:**
|
||
```json
|
||
{ "type": "channel_list", "channels": [...] }
|
||
{ "type": "switched", "channelId": "abc123" }
|
||
{ "track": {...}, "currentTimestamp": 45.5, ... }
|
||
```
|
||
|
||
## Client Sync Logic
|
||
|
||
On WebSocket message:
|
||
1. New track → load audio by `track.id`, seek to server timestamp, play
|
||
2. Same track, drift < 2s → ignore
|
||
3. Same track, drift >= 2s → seek to server timestamp
|
||
|
||
## Debug Functions
|
||
|
||
Available in browser console:
|
||
```js
|
||
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
|
||
|
||
## UI/UX Design Principles
|
||
|
||
### No Alerts/Prompts
|
||
Never use `alert()` or `prompt()`. Use inline editing, toasts, or modals instead.
|
||
- Rename actions → inline text input that appears in place
|
||
- Confirmations → toast notifications
|
||
- Errors → toast with error styling
|
||
|
||
### Inline Editing
|
||
Editable fields should transform in-place:
|
||
- Click edit icon → field becomes input
|
||
- Enter to save, Escape to cancel
|
||
- Blur (click away) saves changes
|
||
|
||
### Drag and Drop
|
||
Support drag-and-drop for reordering and moving items:
|
||
- Tracks in queue can be reordered via drag
|
||
- Tracks from library can be dragged to queue
|
||
- Visual feedback with drop indicators
|
||
|
||
### Context Menus
|
||
Right-click context menus for all actionable items:
|
||
- Tracks (both library and queue) have context menus
|
||
- Context menu options are consistent across views
|
||
- Disabled options are hidden, not grayed out
|
||
|
||
### Track Behavior Consistency
|
||
Tracks in Library and Queue should behave identically where applicable:
|
||
|
||
| Feature | Library | Queue |
|
||
|---------|---------|-------|
|
||
| Click to select | ✓ | ✓ |
|
||
| Shift+click range select | ✓ | ✓ |
|
||
| Right-click context menu | ✓ | ✓ |
|
||
| Drag to reorder | ✗ | ✓ |
|
||
| Cache status indicator | ✓ | ✓ |
|
||
|
||
### Context Menu Options by View
|
||
|
||
**Library tracks:**
|
||
- ▶ Play track (local mode, single track)
|
||
- ⏭ Play next (insert after current)
|
||
- + Add to queue (append to end)
|
||
|
||
**Queue tracks:**
|
||
- ▶ Play track (jump to track)
|
||
- ⏭ Play next (re-add after current)
|
||
- + Add again (duplicate at end)
|
||
- ✕ Remove from queue
|
||
|
||
### Mobile/Touch Support
|
||
- Larger touch targets (min 44px)
|
||
- No hover-dependent features (always show action buttons)
|
||
- Tab-based navigation for panels
|
||
- Sticky headers for scrollable lists
|
||
|
||
### Responsive Layout
|
||
- Desktop: side-by-side panels (Channels | Library | Queue)
|
||
- Mobile (<768px): tab-switched single panel view
|
||
- Player bar adapts: horizontal on desktop, stacked on mobile
|