diff --git a/AGENTS.md b/AGENTS.md index 99167b9..3fb151e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,75 +1,198 @@ # MusicRoom -Synchronized music streaming server built with Bun. Manages "streams" (virtual radio stations) that play through playlists sequentially. Clients connect, receive now-playing state, download audio, and sync playback locally. +Synchronized music streaming server built with Bun. Manages "channels" (virtual radio stations) that play through playlists 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() - stream.startedAt) / 1000` +- `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; + playlist: Track[]; + currentIndex: number; + startedAt: number; + paused: boolean; + clients: Set; + 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 playlists 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 & Playlist 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** (SHA-256 of first 64KB), not by filename: -- `track.id` = Content hash (primary key in database, used for caching, API requests) -- `track.filename` = Original filename (display only) -- `track.title` = Metadata title or filename without extension (display only) +All tracks are identified by a **content hash** (`sha256:` prefix + first 64KB hash): -This allows: +| 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 playlists - Reliable client-side caching by content hash -The client must use `track.id` for: -- Caching tracks in IndexedDB (`TrackStorage.set(track.id, blob)`) -- Fetching audio (`/api/tracks/:id`) -- Checking cache status +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> - per-track segment status +M.cachedTracks // Set - tracks fully cached in IndexedDB +M.trackBlobs // Map - blob URLs for cached tracks +``` ## Routes ``` GET / → Serves public/index.html -GET /api/streams → List active streams (id, name, trackCount) -GET /api/streams/:id → Current stream state (track, currentTimestamp, streamName) -WS /api/streams/:id/ws → WebSocket: pushes state on connect, every 30s, and on track change -GET /api/tracks/:id → Serve audio file by content hash with Range request support +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 +GET /api/playlists → List user playlists +POST /api/playlists → Create playlist ``` ## Files +### Server - **server.ts** — Bun entrypoint. HTTP routes and WebSocket handlers. -- **stream.ts** — `Stream` class. Playlist, current index, time tracking, broadcasting. -- **library.ts** — `Library` class. Scans music directory, computes content hashes, caches metadata. +- **channel.ts** — `Channel` class. Playlist, current index, time tracking, broadcasting. +- **library.ts** — `Library` class. Scans music directory, computes content hashes. - **db.ts** — SQLite database for users, sessions, playlists, tracks. -- **playlist.json** — Config file. Stream definitions. -- **public/** — Client files (modular JS: core.js, utils.js, audioCache.js, etc.) -- **music/** — Directory for audio files (.mp3, .ogg, .flac, .wav, .m4a, .aac). -## Key types +### 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 +- **playlist.js** — Playlist/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 (primary key) + id: string; // Content hash (sha256:...) filename: string; // Original filename title: string; // Display title duration: number; } -// Stream.getState() returns: -{ track: Track | null, currentTimestamp: number, streamName: string, paused: boolean } +// Channel.getState() returns: +{ + track: Track | null, + currentTimestamp: number, + channelName: string, + channelId: string, + description: string, + paused: boolean, + playlist: Track[], + currentIndex: number, + listenerCount: number, + isDefault: boolean +} ``` -## Client sync logic +## 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, seek to server timestamp, play +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 -Progress bar updates from `audio.currentTime` when playing, from extrapolated server time when not playing (grey vs green color). +## Debug Functions + +Available in browser console: +```js +MusicRoom.debugCacheMismatch() // Compare playlist 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` (`duration: true` for full-file scan, needed for accurate OGG durations). +Default port 3001 (override with `PORT` env var). Track durations read from file metadata on startup with `music-metadata`. diff --git a/stream.ts b/channel.ts similarity index 68% rename from stream.ts rename to channel.ts index 1e5a52c..0d77d8b 100644 --- a/stream.ts +++ b/channel.ts @@ -7,28 +7,41 @@ export interface Track { duration: number; } -export interface StreamConfig { +export interface ChannelConfig { id: string; name: string; + description?: string; tracks: Track[]; + createdBy?: number | null; + isDefault?: boolean; } -export class Stream { +export type WsData = { channelId: string; userId: number | null }; + +export class Channel { id: string; name: string; + description: string; playlist: Track[]; currentIndex: number = 0; startedAt: number = Date.now(); - clients: Set> = new Set(); + clients: Set> = new Set(); paused: boolean = false; pausedAt: number = 0; + createdBy: number | null; + createdAt: number; + isDefault: boolean; private lastPlaylistBroadcast: number = 0; private playlistDirty: boolean = false; - constructor(config: StreamConfig) { + constructor(config: ChannelConfig) { this.id = config.id; this.name = config.name; + this.description = config.description || ""; this.playlist = config.tracks; + this.createdBy = config.createdBy ?? null; + this.createdAt = Date.now(); + this.isDefault = config.isDefault ?? false; } get currentTrack(): Track | null { @@ -63,9 +76,13 @@ export class Stream { const state: Record = { track: this.currentTrack, currentTimestamp: this.currentTimestamp, - streamName: this.name, + channelName: this.name, + channelId: this.id, + description: this.description, paused: this.paused, currentIndex: this.currentIndex, + listenerCount: this.clients.size, + isDefault: this.isDefault, }; if (includePlaylist) { state.playlist = this.playlist; @@ -123,13 +140,17 @@ export class Stream { } const msg = JSON.stringify(this.getState(includePlaylist)); + const clientIds = Array.from(this.clients).map(ws => ws.data?.sessionId ?? 'unknown'); + console.log(`[Channel] "${this.name}" broadcasting to ${this.clients.size} clients: [${clientIds.join(', ')}]`); + for (const ws of this.clients) { ws.send(msg); } } - addClient(ws: ServerWebSocket<{ streamId: string }>) { + addClient(ws: ServerWebSocket) { this.clients.add(ws); + console.log(`[Channel] "${this.name}" added client, now ${this.clients.size} clients`); // Always send full state with playlist on connect ws.send(JSON.stringify(this.getState(true))); @@ -137,7 +158,20 @@ export class Stream { this.lastPlaylistBroadcast = Date.now(); } - removeClient(ws: ServerWebSocket<{ streamId: string }>) { + removeClient(ws: ServerWebSocket) { this.clients.delete(ws); + console.log(`[Channel] "${this.name}" removed client, now ${this.clients.size} clients`); + } + + getListInfo() { + return { + id: this.id, + name: this.name, + description: this.description, + trackCount: this.playlist.length, + listenerCount: this.clients.size, + isDefault: this.isDefault, + createdBy: this.createdBy, + }; } } diff --git a/config.json b/config.json index 9799fab..16bf2aa 100644 --- a/config.json +++ b/config.json @@ -3,6 +3,6 @@ "musicDir": "./music", "allowGuests": true, "defaultPermissions": { - "stream": ["listen", "control"] + "channel": ["listen", "control"] } } diff --git a/musicroom.db b/musicroom.db index dbc96e0..e862c6e 100644 Binary files a/musicroom.db and b/musicroom.db differ diff --git a/playlist.json b/playlist.json deleted file mode 100644 index 7b1f394..0000000 --- a/playlist.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "streams": [ - { - "id": "main", - "name": "Main Stream", - "tracks": [] - } - ] -} diff --git a/public/streamSync.js b/public/channelSync.js similarity index 54% rename from public/streamSync.js rename to public/channelSync.js index 1d216d0..9b8e8e5 100644 --- a/public/streamSync.js +++ b/public/channelSync.js @@ -1,30 +1,23 @@ -// MusicRoom - Stream Sync module +// MusicRoom - Channel Sync module // WebSocket connection and server synchronization (function() { const M = window.MusicRoom; - // Load available streams and connect to first one - M.loadStreams = async function() { + // Load available channels and connect to first one + M.loadChannels = async function() { try { - const res = await fetch("/api/streams"); - const streams = await res.json(); - if (streams.length === 0) { - M.$("#track-title").textContent = "No streams available"; + const res = await fetch("/api/channels"); + const channels = await res.json(); + if (channels.length === 0) { + M.$("#track-title").textContent = "No channels available"; return; } - if (streams.length > 1) { - const sel = document.createElement("select"); - for (const s of streams) { - const opt = document.createElement("option"); - opt.value = s.id; - opt.textContent = s.name; - sel.appendChild(opt); - } - sel.onchange = () => M.connectStream(sel.value); - M.$("#stream-select").appendChild(sel); - } - M.connectStream(streams[0].id); + M.channels = channels; + M.renderChannelList(); + // Connect to first (default) channel + const defaultChannel = channels.find(c => c.isDefault) || channels[0]; + M.connectChannel(defaultChannel.id); } catch (e) { M.$("#track-title").textContent = "Server unavailable"; M.$("#status").textContent = "Local (offline)"; @@ -33,20 +26,93 @@ } }; - // Connect to a stream via WebSocket - M.connectStream = function(id) { + // Create a new channel + M.createChannel = async function(name, description) { + try { + const res = await fetch("/api/channels", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, description }) + }); + if (!res.ok) { + const err = await res.json(); + M.showToast(err.error || "Failed to create channel"); + return null; + } + const channel = await res.json(); + M.showToast(`Channel "${channel.name}" created`); + return channel; + } catch (e) { + M.showToast("Failed to create channel"); + return null; + } + }; + + // New channel button handler + document.addEventListener("DOMContentLoaded", () => { + const btn = M.$("#btn-new-channel"); + if (btn) { + btn.onclick = () => { + const name = prompt("Channel name:"); + if (name && name.trim()) { + M.createChannel(name.trim()); + } + }; + } + }); + + // Render channel list in sidebar + M.renderChannelList = function() { + const container = M.$("#channels-list"); + if (!container) return; + container.innerHTML = ""; + for (const ch of M.channels || []) { + const div = document.createElement("div"); + div.className = "channel-item" + (ch.id === M.currentChannelId ? " active" : ""); + div.innerHTML = ` + ${ch.name} + ${ch.listenerCount} 👤 + `; + div.onclick = () => M.switchChannel(ch.id); + container.appendChild(div); + } + }; + + // Switch to a different channel via WebSocket + M.switchChannel = function(channelId) { + if (channelId === M.currentChannelId) return; + if (M.ws && M.ws.readyState === WebSocket.OPEN) { + M.ws.send(JSON.stringify({ action: "switch", channelId })); + } + }; + + // Connect to a channel via WebSocket + M.connectChannel = function(id) { if (M.ws) { const oldWs = M.ws; M.ws = null; oldWs.onclose = null; oldWs.close(); } - M.currentStreamId = id; + M.currentChannelId = id; const proto = location.protocol === "https:" ? "wss:" : "ws:"; - M.ws = new WebSocket(proto + "//" + location.host + "/api/streams/" + id + "/ws"); + M.ws = new WebSocket(proto + "//" + location.host + "/api/channels/" + id + "/ws"); M.ws.onmessage = (e) => { const data = JSON.parse(e.data); + // Handle channel list updates + if (data.type === "channel_list") { + console.log("[WS] Received channel_list:", data.channels.length, "channels"); + M.channels = data.channels; + M.renderChannelList(); + return; + } + // Handle channel switch confirmation + if (data.type === "switched") { + M.currentChannelId = data.channelId; + M.renderChannelList(); + return; + } // Handle library updates if (data.type === "track_added") { M.showToast(`"${data.track.title}" is now available`); @@ -72,7 +138,7 @@ } return; } - // Normal stream update + // Normal channel state update M.handleUpdate(data); }; @@ -83,7 +149,7 @@ M.updateUI(); // Auto-reconnect if user wants to be synced if (M.wantSync) { - setTimeout(() => M.connectStream(id), 3000); + setTimeout(() => M.connectChannel(id), 3000); } }; @@ -94,13 +160,20 @@ }; }; - // Handle stream state update from server + // Handle channel state update from server M.handleUpdate = async function(data) { + console.log("[WS] State update:", { + track: data.track?.title, + timestamp: data.currentTimestamp?.toFixed(1), + paused: data.paused, + currentIndex: data.currentIndex + }); + if (!data.track) { M.$("#track-title").textContent = "No tracks"; return; } - M.$("#stream-name").textContent = data.streamName || ""; + M.$("#channel-name").textContent = data.channelName || ""; M.serverTimestamp = data.currentTimestamp; M.serverTrackDuration = data.track.duration; M.lastServerUpdate = Date.now(); @@ -148,14 +221,16 @@ // Try cache first const cachedUrl = await M.loadTrackBlob(M.currentTrackId); M.audio.src = cachedUrl || M.getTrackUrl(M.currentTrackId); - } - if (M.audio.paused) { + M.audio.currentTime = data.currentTimestamp; + M.audio.play().catch(() => {}); + } else if (M.audio.paused) { M.audio.currentTime = data.currentTimestamp; M.audio.play().catch(() => {}); } else { // Check drift const drift = Math.abs(M.audio.currentTime - data.currentTimestamp); if (drift >= 2) { + console.log("[Sync] Correcting drift:", drift.toFixed(1), "s"); M.audio.currentTime = data.currentTimestamp; } } diff --git a/public/controls.js b/public/controls.js index 3e9c715..2aacf9b 100644 --- a/public/controls.js +++ b/public/controls.js @@ -42,8 +42,8 @@ if (M.playlist.length === 0) return; const newIndex = (index + M.playlist.length) % M.playlist.length; - if (M.synced && M.currentStreamId) { - const res = await fetch("/api/streams/" + M.currentStreamId + "/jump", { + if (M.synced && M.currentChannelId) { + const res = await fetch("/api/channels/" + M.currentChannelId + "/jump", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ index: newIndex }) @@ -72,8 +72,8 @@ M.wantSync = !M.wantSync; if (M.wantSync) { // User wants to sync - try to connect - if (M.currentStreamId) { - M.connectStream(M.currentStreamId); + if (M.currentChannelId) { + M.connectChannel(M.currentChannelId); } } else { // User wants local mode - disconnect @@ -120,8 +120,8 @@ const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const seekTime = pct * dur; - if (M.synced && M.currentStreamId) { - fetch("/api/streams/" + M.currentStreamId + "/seek", { + if (M.synced && M.currentChannelId) { + fetch("/api/channels/" + M.currentChannelId + "/seek", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ timestamp: seekTime }) diff --git a/public/core.js b/public/core.js index 8d910ea..83c0181 100644 --- a/public/core.js +++ b/public/core.js @@ -5,9 +5,9 @@ window.MusicRoom = { // Audio element audio: new Audio(), - // WebSocket and stream state + // WebSocket and channel state ws: null, - currentStreamId: null, + currentChannelId: null, currentTrackId: null, currentTitle: null, serverTimestamp: 0, @@ -15,6 +15,9 @@ window.MusicRoom = { lastServerUpdate: 0, serverPaused: true, + // Channels list + channels: [], + // Sync state wantSync: true, // User intent - do they want to be synced? synced: false, // Actual state - are we currently synced? diff --git a/public/index.html b/public/index.html index d6700ad..e2484a8 100644 --- a/public/index.html +++ b/public/index.html @@ -47,6 +47,13 @@
+
+
+

Channels

+ +
+
+

Library

@@ -66,7 +73,7 @@
-
+
Loading...
@@ -96,7 +103,7 @@ - + diff --git a/public/init.js b/public/init.js index acd7710..dac1754 100644 --- a/public/init.js +++ b/public/init.js @@ -29,7 +29,7 @@ M.loadSelectedPlaylist("all"); // Default to All Tracks await M.loadCurrentUser(); if (M.currentUser) { - M.loadStreams(); + M.loadChannels(); M.loadPlaylists(); } }); diff --git a/public/playlist.js b/public/playlist.js index 22bba81..4f9c161 100644 --- a/public/playlist.js +++ b/public/playlist.js @@ -133,8 +133,8 @@ div.innerHTML = `${title}${removeBtn}${M.fmt(track.duration)}`; div.querySelector(".track-title").onclick = async () => { - if (M.synced && M.currentStreamId) { - const res = await fetch("/api/streams/" + M.currentStreamId + "/jump", { + if (M.synced && M.currentChannelId) { + const res = await fetch("/api/channels/" + M.currentChannelId + "/jump", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ index: i }) diff --git a/public/styles.css b/public/styles.css index df52a15..fdccddb 100644 --- a/public/styles.css +++ b/public/styles.css @@ -17,6 +17,13 @@ h3 { font-size: 0.9rem; color: #666; margin-bottom: 0.5rem; text-transform: uppe /* Main content - library and playlist */ #main-content { display: flex; gap: 1rem; flex: 1; min-height: 0; margin-bottom: 1rem; } +#channels-panel { flex: 0 0 180px; background: #1a1a1a; border-radius: 8px; padding: 1rem; display: flex; flex-direction: column; min-height: 300px; max-height: 60vh; } +#channels-list { flex: 1; overflow-y: auto; } +#channels-list .channel-item { padding: 0.5rem 0.75rem; border-radius: 4px; cursor: pointer; font-size: 0.9rem; display: flex; justify-content: space-between; align-items: center; } +#channels-list .channel-item:hover { background: #222; } +#channels-list .channel-item.active { background: #2a4a3a; color: #4e8; } +#channels-list .channel-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +#channels-list .listener-count { font-size: 0.75rem; color: #666; flex-shrink: 0; margin-left: 0.5rem; } #library-panel, #playlist-panel { flex: 2; background: #1a1a1a; border-radius: 8px; padding: 1rem; display: flex; flex-direction: column; min-height: 300px; max-height: 60vh; } #playlists-panel { flex: 1; background: #1a1a1a; border-radius: 8px; padding: 1rem; display: flex; flex-direction: column; min-height: 300px; max-height: 60vh; min-width: 180px; max-width: 250px; } #playlists-list { flex: 1; overflow-y: auto; } @@ -50,7 +57,7 @@ h3 { font-size: 0.9rem; color: #666; margin-bottom: 0.5rem; text-transform: uppe /* Player bar */ #player-bar { background: #1a1a1a; border-radius: 8px; padding: 1rem; display: flex; gap: 1rem; align-items: center; } #now-playing { min-width: 200px; } -#stream-name { font-size: 0.75rem; color: #666; margin-bottom: 0.2rem; } +#channel-name { font-size: 0.75rem; color: #666; margin-bottom: 0.2rem; } #track-name { font-size: 1rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } #player-controls { flex: 1; } #progress-row { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.3rem; } diff --git a/server.ts b/server.ts index 6300706..e3bdb17 100644 --- a/server.ts +++ b/server.ts @@ -1,7 +1,6 @@ import { file, serve, type ServerWebSocket } from "bun"; -import { parseFile } from "music-metadata"; -import { Stream, type Track } from "./stream"; -import { readdir, stat } from "fs/promises"; +import { Channel, type Track, type WsData } from "./channel"; +import { readdir } from "fs/promises"; import { join, resolve } from "path"; import { createUser, @@ -46,7 +45,7 @@ interface Config { musicDir: string; allowGuests: boolean; defaultPermissions: { - stream?: string[]; + channel?: string[]; }; } @@ -54,7 +53,6 @@ const CONFIG_PATH = join(import.meta.dir, "config.json"); const config: Config = await file(CONFIG_PATH).json(); const MUSIC_DIR = resolve(import.meta.dir, config.musicDir); -const PLAYLIST_PATH = join(import.meta.dir, "playlist.json"); const PUBLIC_DIR = join(import.meta.dir, "public"); console.log(`Config loaded: port=${config.port}, musicDir=${MUSIC_DIR}, allowGuests=${config.allowGuests}`); @@ -62,36 +60,6 @@ console.log(`Config loaded: port=${config.port}, musicDir=${MUSIC_DIR}, allowGue // Initialize library const library = new Library(MUSIC_DIR); -// Load track metadata (for stream initialization - converts library tracks to stream format) -async function loadTrack(filename: string): Promise { - // First check if this track is in the library (has content hash) - const allTracks = library.getAllTracks(); - const libTrack = allTracks.find(t => t.filename === filename); - - if (libTrack) { - console.log(`Track: ${filename} | duration: ${libTrack.duration}s | title: ${libTrack.title} | id: ${libTrack.id.slice(0, 8)}...`); - return { - id: libTrack.id, - filename: libTrack.filename, - title: libTrack.title || filename.replace(/\.[^.]+$/, ""), - duration: libTrack.duration - }; - } - - // Fallback: load metadata directly (shouldn't happen if library is scanned first) - const filepath = join(MUSIC_DIR, filename); - try { - const metadata = await parseFile(filepath, { duration: true }); - const duration = metadata.format.duration ?? 0; - const title = metadata.common.title?.trim() || filename.replace(/\.[^.]+$/, ""); - console.log(`Track: ${filename} | duration: ${duration}s | title: ${title} | id: (no hash)`); - return { id: filename, filename, title, duration }; // Use filename as fallback ID - } catch (e) { - console.warn(`Could not read metadata for ${filename}, skipping`); - return { id: filename, filename, title: filename.replace(/\.[^.]+$/, ""), duration: 0 }; - } -} - // Auto-discover tracks if playlist is empty async function discoverTracks(): Promise { try { @@ -102,48 +70,63 @@ async function discoverTracks(): Promise { } } -// Initialize streams -async function init(): Promise> { +// Generate unique channel ID +function generateChannelId(): string { + return Math.random().toString(36).slice(2, 10); +} + +// Initialize channels - create default channel with full library +const channels = new Map(); + +async function init(): Promise { // Scan library first await library.scan(); library.startWatching(); - const playlistData = await file(PLAYLIST_PATH).json(); - const streams = new Map(); + // Create default channel with full library + const allTracks = library.getAllTracks(); + const tracks: Track[] = allTracks + .filter(t => t.duration > 0) + .map(t => ({ + id: t.id, + filename: t.filename, + title: t.title || t.filename.replace(/\.[^.]+$/, ""), + duration: t.duration, + })); - // Get all discovered files for streams - const allFiles = await discoverTracks(); - - for (const cfg of playlistData.streams) { - let trackFiles: string[] = cfg.tracks; - if (trackFiles.length === 0) { - trackFiles = allFiles; - console.log(`Stream "${cfg.id}": using all ${trackFiles.length} tracks`); - } - const tracks = await Promise.all(trackFiles.map(loadTrack)); - const validTracks = tracks.filter((t) => t.duration > 0); - if (validTracks.length === 0) { - console.warn(`Stream "${cfg.id}" has no valid tracks, skipping`); - continue; - } - const stream = new Stream({ id: cfg.id, name: cfg.name, tracks: validTracks }); - streams.set(cfg.id, stream); - console.log(`Stream "${cfg.id}": ${validTracks.length} tracks loaded`); - } - - return streams; + const defaultChannel = new Channel({ + id: "main", + name: "Main Channel", + description: "All tracks from the library", + tracks, + isDefault: true, + createdBy: null, + }); + + channels.set("main", defaultChannel); + console.log(`Default channel created: ${tracks.length} tracks`); } -const streams = await init(); +await init(); -// Broadcast to all connected clients across all streams +// Broadcast to all connected clients across all channels function broadcastToAll(message: object) { const data = JSON.stringify(message); - for (const stream of streams.values()) { - for (const ws of stream.clients) { + let clientCount = 0; + for (const channel of channels.values()) { + for (const ws of channel.clients) { ws.send(data); + clientCount++; } } + console.log(`[Broadcast] Sent to ${clientCount} clients`); +} + +// Broadcast channel list to all clients +function broadcastChannelList() { + const list = [...channels.values()].map(c => c.getListInfo()); + console.log(`[Broadcast] Sending channel_list to all clients (${list.length} channels)`); + broadcastToAll({ type: "channel_list", channels: list }); } // Listen for library changes and notify clients @@ -179,10 +162,14 @@ library.on("removed", (track) => { let tickCount = 0; setInterval(() => { tickCount++; - for (const stream of streams.values()) { - const changed = stream.tick(); + for (const channel of channels.values()) { + const changed = channel.tick(); + if (changed) { + console.log(`[Tick] Channel "${channel.name}" advanced to track ${channel.currentIndex}`); + } if (!changed && tickCount % 30 === 0) { - stream.broadcast(); + console.log(`[Tick] Broadcasting state for channel "${channel.name}" (${channel.clients.size} clients)`); + channel.broadcast(); } } }, 1000); @@ -215,7 +202,7 @@ function userHasPermission(user: ReturnType, resourceType: strin if (user.is_guest && permission === "control") return false; // Check default permissions from config - if (resourceType === "stream" && config.defaultPermissions.stream?.includes(permission)) { + if (resourceType === "channel" && config.defaultPermissions.channel?.includes(permission)) { return true; } @@ -229,12 +216,12 @@ serve({ const url = new URL(req.url); const path = url.pathname; - // WebSocket upgrade - if (path.match(/^\/api\/streams\/([^/]+)\/ws$/)) { + // WebSocket upgrade for channels + if (path.match(/^\/api\/channels\/([^/]+)\/ws$/)) { const id = path.split("/")[3]; - if (!streams.has(id)) return new Response("Stream not found", { status: 404 }); + if (!channels.has(id)) return new Response("Channel not found", { status: 404 }); const { user } = getOrCreateUser(req, server); - const ok = server.upgrade(req, { data: { streamId: id, userId: user?.id ?? null } }); + const ok = server.upgrade(req, { data: { channelId: id, userId: user?.id ?? null } }); if (ok) return undefined; return new Response("WebSocket upgrade failed", { status: 500 }); } @@ -246,25 +233,112 @@ serve({ version: "1.0.0", allowGuests: config.allowGuests, allowSignups: true, - streamCount: streams.size, + channelCount: channels.size, defaultPermissions: config.defaultPermissions, }); } - // API: list streams (requires auth or guest) - if (path === "/api/streams") { + // API: list channels (requires auth or guest) + if (path === "/api/channels" && req.method === "GET") { const { user, headers } = getOrCreateUser(req, server); if (!user) { return Response.json({ error: "Authentication required" }, { status: 401 }); } - const list = [...streams.values()].map((s) => ({ - id: s.id, - name: s.name, - trackCount: s.playlist.length, - })); + const list = [...channels.values()].map(c => c.getListInfo()); return Response.json(list, { headers }); } + // API: create channel + if (path === "/api/channels" && req.method === "POST") { + const { user } = getOrCreateUser(req, server); + if (!user) { + return Response.json({ error: "Authentication required" }, { status: 401 }); + } + if (user.is_guest) { + return Response.json({ error: "Guests cannot create channels" }, { status: 403 }); + } + try { + const { name, description, trackIds } = await req.json(); + if (!name || typeof name !== "string" || name.trim().length === 0) { + return Response.json({ error: "Name is required" }, { status: 400 }); + } + + // Build track list from trackIds or default to full library + let tracks: Track[]; + if (trackIds && Array.isArray(trackIds) && trackIds.length > 0) { + tracks = []; + for (const tid of trackIds) { + const libTrack = library.getTrack(tid); + if (libTrack) { + tracks.push({ + id: libTrack.id, + filename: libTrack.filename, + title: libTrack.title || libTrack.filename.replace(/\.[^.]+$/, ""), + duration: libTrack.duration, + }); + } + } + } else { + // Default to full library + tracks = library.getAllTracks() + .filter(t => t.duration > 0) + .map(t => ({ + id: t.id, + filename: t.filename, + title: t.title || t.filename.replace(/\.[^.]+$/, ""), + duration: t.duration, + })); + } + + const channelId = generateChannelId(); + const channel = new Channel({ + id: channelId, + name: name.trim(), + description: description || "", + tracks, + createdBy: user.id, + isDefault: false, + }); + channels.set(channelId, channel); + console.log(`[Channel] Created "${name.trim()}" (id=${channelId}) by user ${user.id}`); + broadcastChannelList(); + return Response.json(channel.getListInfo(), { status: 201 }); + } catch { + return Response.json({ error: "Invalid request" }, { status: 400 }); + } + } + + // API: delete channel + const channelDeleteMatch = path.match(/^\/api\/channels\/([^/]+)$/); + if (channelDeleteMatch && req.method === "DELETE") { + const channelId = channelDeleteMatch[1]; + const { user } = getOrCreateUser(req, server); + if (!user) { + return Response.json({ error: "Authentication required" }, { status: 401 }); + } + const channel = channels.get(channelId); + if (!channel) { + return Response.json({ error: "Channel not found" }, { status: 404 }); + } + if (channel.isDefault) { + return Response.json({ error: "Cannot delete default channel" }, { status: 403 }); + } + if (!user.is_admin && channel.createdBy !== user.id) { + return Response.json({ error: "Access denied" }, { status: 403 }); + } + channels.delete(channelId); + broadcastChannelList(); + return Response.json({ success: true }); + } + + // API: get channel state + const channelMatch = path.match(/^\/api\/channels\/([^/]+)$/); + if (channelMatch && req.method === "GET") { + const channel = channels.get(channelMatch[1]); + if (!channel) return new Response("Not found", { status: 404 }); + return Response.json(channel.getState()); + } + // API: get library (all tracks) if (path === "/api/library") { const { user, headers } = getOrCreateUser(req, server); @@ -522,14 +596,14 @@ serve({ const permissions = getUserPermissions(user.id); // Add default permissions for all users (except control for guests) const effectivePermissions = [...permissions]; - if (config.defaultPermissions.stream) { - for (const perm of config.defaultPermissions.stream) { + if (config.defaultPermissions.channel) { + for (const perm of config.defaultPermissions.channel) { // Guests can never have control permission if (user.is_guest && perm === "control") continue; effectivePermissions.push({ id: 0, user_id: user.id, - resource_type: "stream", + resource_type: "channel", resource_id: null, permission: perm, }); @@ -581,19 +655,19 @@ serve({ } // API: jump to track in playlist - const jumpMatch = path.match(/^\/api\/streams\/([^/]+)\/jump$/); + const jumpMatch = path.match(/^\/api\/channels\/([^/]+)\/jump$/); if (jumpMatch && req.method === "POST") { - const streamId = jumpMatch[1]; + const channelId = jumpMatch[1]; const { user } = getOrCreateUser(req, server); - if (!userHasPermission(user, "stream", streamId, "control")) { + if (!userHasPermission(user, "channel", channelId, "control")) { return new Response("Forbidden", { status: 403 }); } - const stream = streams.get(streamId); - if (!stream) return new Response("Not found", { status: 404 }); + const channel = channels.get(channelId); + if (!channel) return new Response("Not found", { status: 404 }); try { const body = await req.json(); if (typeof body.index === "number") { - stream.jumpTo(body.index); + channel.jumpTo(body.index); return Response.json({ success: true }); } return new Response("Invalid index", { status: 400 }); @@ -602,20 +676,20 @@ serve({ } } - // API: seek in stream - const seekMatch = path.match(/^\/api\/streams\/([^/]+)\/seek$/); + // API: seek in channel + const seekMatch = path.match(/^\/api\/channels\/([^/]+)\/seek$/); if (seekMatch && req.method === "POST") { - const streamId = seekMatch[1]; + const channelId = seekMatch[1]; const { user } = getOrCreateUser(req, server); - if (!userHasPermission(user, "stream", streamId, "control")) { + if (!userHasPermission(user, "channel", channelId, "control")) { return new Response("Forbidden", { status: 403 }); } - const stream = streams.get(streamId); - if (!stream) return new Response("Not found", { status: 404 }); + const channel = channels.get(channelId); + if (!channel) return new Response("Not found", { status: 404 }); try { const body = await req.json(); if (typeof body.timestamp === "number") { - stream.seek(body.timestamp); + channel.seek(body.timestamp); return Response.json({ success: true }); } return new Response("Invalid timestamp", { status: 400 }); @@ -624,14 +698,6 @@ serve({ } } - // API: stream state - const streamMatch = path.match(/^\/api\/streams\/([^/]+)$/); - if (streamMatch) { - const stream = streams.get(streamMatch[1]); - if (!stream) return new Response("Not found", { status: 404 }); - return Response.json(stream.getState()); - } - // API: serve audio file (requires auth or guest) // Supports both filename and track ID (sha256:...) const trackMatch = path.match(/^\/api\/tracks\/(.+)$/); @@ -713,53 +779,77 @@ serve({ websocket: { open(ws: ServerWebSocket) { - const stream = streams.get(ws.data.streamId); - if (stream) stream.addClient(ws); + const channel = channels.get(ws.data.channelId); + if (channel) { + channel.addClient(ws); + // Send channel list on connect + const list = [...channels.values()].map(c => c.getListInfo()); + ws.send(JSON.stringify({ type: "channel_list", channels: list })); + } }, close(ws: ServerWebSocket) { - const stream = streams.get(ws.data.streamId); - if (stream) stream.removeClient(ws); + const channel = channels.get(ws.data.channelId); + if (channel) channel.removeClient(ws); }, message(ws: ServerWebSocket, message: string | Buffer) { - const stream = streams.get(ws.data.streamId); - if (!stream) { - console.log("[WS] No stream found for:", ws.data.streamId); - return; - } - - // Check permission for control actions - const userId = ws.data.userId; - if (!userId) { - console.log("[WS] No userId on connection"); - return; - } - - const user = findUserById(userId); - if (!user) { - console.log("[WS] User not found:", userId); - return; - } - - // Guests can never control playback - if (user.is_guest) { - console.log("[WS] Guest cannot control playback"); - return; - } - - // Check default permissions or user-specific permissions - const canControl = user.is_admin - || config.defaultPermissions.stream?.includes("control") - || hasPermission(userId, "stream", ws.data.streamId, "control"); - if (!canControl) { - console.log("[WS] User lacks control permission:", user.username); - return; - } - try { const data = JSON.parse(String(message)); + console.log("[WS] Received message:", data.action, "from streamId:", ws.data.streamId); + + // Handle channel switching + if (data.action === "switch" && data.channelId) { + const oldChannel = channels.get(ws.data.channelId); + const newChannel = channels.get(data.channelId); + if (!newChannel) { + ws.send(JSON.stringify({ type: "error", message: "Channel not found" })); + return; + } + if (oldChannel) oldChannel.removeClient(ws); + ws.data.channelId = data.channelId; + newChannel.addClient(ws); + ws.send(JSON.stringify({ type: "switched", channelId: data.channelId })); + return; + } + + const channel = channels.get(ws.data.channelId); + if (!channel) { + console.log("[WS] No channel found for:", ws.data.channelId); + return; + } + + // Check permission for control actions + const userId = ws.data.userId; + if (!userId) { + console.log("[WS] No userId on connection"); + return; + } + + const user = findUserById(userId); + if (!user) { + console.log("[WS] User not found:", userId); + return; + } + + // Guests can never control playback + if (user.is_guest) { + console.log("[WS] Guest cannot control playback"); + return; + } + + // Check default permissions or user-specific permissions + const canControl = user.is_admin + || config.defaultPermissions.channel?.includes("control") + || hasPermission(userId, "channel", ws.data.channelId, "control"); + if (!canControl) { + console.log("[WS] User lacks control permission:", user.username); + return; + } + console.log("[WS] Control action:", data.action, "from", user.username); - if (data.action === "pause") stream.pause(); - else if (data.action === "unpause") stream.unpause(); + if (data.action === "pause") channel.pause(); + else if (data.action === "unpause") channel.unpause(); + else if (data.action === "seek" && typeof data.timestamp === "number") channel.seek(data.timestamp); + else if (data.action === "jump" && typeof data.index === "number") channel.jumpTo(data.index); } catch {} }, },