blastoise/AGENTS.md

336 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 /listen/:trackId → Serves index.html (direct track link)
GET /api/channels → List all channels with listener counts
POST /api/channels → Create a new channel
GET /api/channels/:id → Get channel state
PATCH /api/channels/:id → Rename channel
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
GET /api/playlists → List user's + shared playlists
POST /api/playlists → Create new playlist
GET /api/playlists/:id → Get playlist details
PATCH /api/playlists/:id → Update playlist (name, description, public)
DELETE /api/playlists/:id → Delete playlist
PATCH /api/playlists/:id/tracks → Modify tracks (add/remove/set)
POST /api/playlists/:id/share → Generate share token
DELETE /api/playlists/:id/share → Remove sharing
GET /api/playlists/shared/:token → Get shared playlist by token
POST /api/playlists/shared/:token → Copy shared playlist to own
```
## 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).
- **playlists.ts** — Playlist CRUD and track management.
- **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, context menus
- **playlists.js** — Playlist UI, create/edit/delete, add tracks
- **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)
- 📁 Add to Playlist... (submenu)
- 🔗 Generate listening link
**Queue tracks:**
- ▶ Play track (jump to track)
- ⏭ Play next (re-add after current)
- Add again (duplicate at end)
- 📁 Add to Playlist... (submenu)
- 🔗 Generate listening link
- ✕ Remove from queue
## Playlists
Playlists are reusable collections of tracks that can be added to the queue.
### Data Model
```ts
interface Playlist {
id: string;
name: string;
description: string;
ownerId: number;
isPublic: boolean;
shareToken: string | null;
trackIds: string[];
}
```
### UI Structure
The Playlists tab has a dual-panel layout:
- **Left panel**: List of playlists (My Playlists + Shared)
- **Right panel**: Selected playlist's track list
### Context Menu Options
**Playlist (in list):**
- ▶ Add to Queue
- ⏭ Play Next
- ✏️ Rename (owner only)
- 🌐 Make Public / 🔒 Make Private
- 🔗 Copy Share Link / Generate Share Link
- 📋 Copy to My Playlists (shared only)
- 🗑️ Delete (owner only)
**Track (in playlist):**
- ▶ Play
- Add to Queue
- ⏭ Play Next
- 🗑️ Remove from Playlist (owner only)
### 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