saving
This commit is contained in:
parent
a910ec195f
commit
629deaab3f
179
AGENTS.md
179
AGENTS.md
|
|
@ -1,75 +1,198 @@
|
||||||
# MusicRoom
|
# 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
|
## Architecture
|
||||||
|
|
||||||
|
### Server
|
||||||
|
|
||||||
The server does NOT decode or play audio. It tracks time:
|
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`
|
- When `currentTimestamp >= track.duration`, advance to next track, reset `startedAt`
|
||||||
- A 1s `setInterval` checks if tracks need advancing and broadcasts state every 30s
|
- 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<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 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
|
## Content-Addressed Tracks
|
||||||
|
|
||||||
All tracks are identified by a **content hash** (SHA-256 of first 64KB), not by filename:
|
All tracks are identified by a **content hash** (`sha256:` prefix + first 64KB hash):
|
||||||
- `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)
|
|
||||||
|
|
||||||
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)
|
- Deduplication (same file with different names = same track)
|
||||||
- Renaming files without breaking playlists
|
- Renaming files without breaking playlists
|
||||||
- Reliable client-side caching by content hash
|
- Reliable client-side caching by content hash
|
||||||
|
|
||||||
The client must use `track.id` for:
|
The client uses `track.id` for:
|
||||||
- Caching tracks in IndexedDB (`TrackStorage.set(track.id, blob)`)
|
- Caching tracks in IndexedDB (`TrackStorage.set(trackId, blob)`)
|
||||||
- Fetching audio (`/api/tracks/:id`)
|
- Fetching audio (`/api/tracks/:trackId`)
|
||||||
- Checking cache status
|
- 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
|
## Routes
|
||||||
|
|
||||||
```
|
```
|
||||||
GET / → Serves public/index.html
|
GET / → Serves public/index.html
|
||||||
GET /api/streams → List active streams (id, name, trackCount)
|
GET /api/channels → List all channels with listener counts
|
||||||
GET /api/streams/:id → Current stream state (track, currentTimestamp, streamName)
|
POST /api/channels → Create a new channel
|
||||||
WS /api/streams/:id/ws → WebSocket: pushes state on connect, every 30s, and on track change
|
GET /api/channels/:id → Get channel state
|
||||||
GET /api/tracks/:id → Serve audio file by content hash with Range request support
|
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/library → List all tracks with id, filename, title, duration
|
||||||
|
GET /api/playlists → List user playlists
|
||||||
|
POST /api/playlists → Create playlist
|
||||||
```
|
```
|
||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|
||||||
|
### Server
|
||||||
- **server.ts** — Bun entrypoint. HTTP routes and WebSocket handlers.
|
- **server.ts** — Bun entrypoint. HTTP routes and WebSocket handlers.
|
||||||
- **stream.ts** — `Stream` class. Playlist, current index, time tracking, broadcasting.
|
- **channel.ts** — `Channel` class. Playlist, current index, time tracking, broadcasting.
|
||||||
- **library.ts** — `Library` class. Scans music directory, computes content hashes, caches metadata.
|
- **library.ts** — `Library` class. Scans music directory, computes content hashes.
|
||||||
- **db.ts** — SQLite database for users, sessions, playlists, tracks.
|
- **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
|
```ts
|
||||||
interface Track {
|
interface Track {
|
||||||
id: string; // Content hash (primary key)
|
id: string; // Content hash (sha256:...)
|
||||||
filename: string; // Original filename
|
filename: string; // Original filename
|
||||||
title: string; // Display title
|
title: string; // Display title
|
||||||
duration: number;
|
duration: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stream.getState() returns:
|
// Channel.getState() returns:
|
||||||
{ track: Track | null, currentTimestamp: number, streamName: string, paused: boolean }
|
{
|
||||||
|
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:
|
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
|
2. Same track, drift < 2s → ignore
|
||||||
3. Same track, drift >= 2s → seek to server timestamp
|
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
|
## 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`.
|
||||||
|
|
|
||||||
|
|
@ -7,28 +7,41 @@ export interface Track {
|
||||||
duration: number;
|
duration: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StreamConfig {
|
export interface ChannelConfig {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
description?: string;
|
||||||
tracks: Track[];
|
tracks: Track[];
|
||||||
|
createdBy?: number | null;
|
||||||
|
isDefault?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Stream {
|
export type WsData = { channelId: string; userId: number | null };
|
||||||
|
|
||||||
|
export class Channel {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
description: string;
|
||||||
playlist: Track[];
|
playlist: Track[];
|
||||||
currentIndex: number = 0;
|
currentIndex: number = 0;
|
||||||
startedAt: number = Date.now();
|
startedAt: number = Date.now();
|
||||||
clients: Set<ServerWebSocket<{ streamId: string }>> = new Set();
|
clients: Set<ServerWebSocket<WsData>> = new Set();
|
||||||
paused: boolean = false;
|
paused: boolean = false;
|
||||||
pausedAt: number = 0;
|
pausedAt: number = 0;
|
||||||
|
createdBy: number | null;
|
||||||
|
createdAt: number;
|
||||||
|
isDefault: boolean;
|
||||||
private lastPlaylistBroadcast: number = 0;
|
private lastPlaylistBroadcast: number = 0;
|
||||||
private playlistDirty: boolean = false;
|
private playlistDirty: boolean = false;
|
||||||
|
|
||||||
constructor(config: StreamConfig) {
|
constructor(config: ChannelConfig) {
|
||||||
this.id = config.id;
|
this.id = config.id;
|
||||||
this.name = config.name;
|
this.name = config.name;
|
||||||
|
this.description = config.description || "";
|
||||||
this.playlist = config.tracks;
|
this.playlist = config.tracks;
|
||||||
|
this.createdBy = config.createdBy ?? null;
|
||||||
|
this.createdAt = Date.now();
|
||||||
|
this.isDefault = config.isDefault ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
get currentTrack(): Track | null {
|
get currentTrack(): Track | null {
|
||||||
|
|
@ -63,9 +76,13 @@ export class Stream {
|
||||||
const state: Record<string, unknown> = {
|
const state: Record<string, unknown> = {
|
||||||
track: this.currentTrack,
|
track: this.currentTrack,
|
||||||
currentTimestamp: this.currentTimestamp,
|
currentTimestamp: this.currentTimestamp,
|
||||||
streamName: this.name,
|
channelName: this.name,
|
||||||
|
channelId: this.id,
|
||||||
|
description: this.description,
|
||||||
paused: this.paused,
|
paused: this.paused,
|
||||||
currentIndex: this.currentIndex,
|
currentIndex: this.currentIndex,
|
||||||
|
listenerCount: this.clients.size,
|
||||||
|
isDefault: this.isDefault,
|
||||||
};
|
};
|
||||||
if (includePlaylist) {
|
if (includePlaylist) {
|
||||||
state.playlist = this.playlist;
|
state.playlist = this.playlist;
|
||||||
|
|
@ -123,13 +140,17 @@ export class Stream {
|
||||||
}
|
}
|
||||||
const msg = JSON.stringify(this.getState(includePlaylist));
|
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) {
|
for (const ws of this.clients) {
|
||||||
ws.send(msg);
|
ws.send(msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
addClient(ws: ServerWebSocket<{ streamId: string }>) {
|
addClient(ws: ServerWebSocket<WsData>) {
|
||||||
this.clients.add(ws);
|
this.clients.add(ws);
|
||||||
|
console.log(`[Channel] "${this.name}" added client, now ${this.clients.size} clients`);
|
||||||
|
|
||||||
// Always send full state with playlist on connect
|
// Always send full state with playlist on connect
|
||||||
ws.send(JSON.stringify(this.getState(true)));
|
ws.send(JSON.stringify(this.getState(true)));
|
||||||
|
|
@ -137,7 +158,20 @@ export class Stream {
|
||||||
this.lastPlaylistBroadcast = Date.now();
|
this.lastPlaylistBroadcast = Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
removeClient(ws: ServerWebSocket<{ streamId: string }>) {
|
removeClient(ws: ServerWebSocket<WsData>) {
|
||||||
this.clients.delete(ws);
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3,6 +3,6 @@
|
||||||
"musicDir": "./music",
|
"musicDir": "./music",
|
||||||
"allowGuests": true,
|
"allowGuests": true,
|
||||||
"defaultPermissions": {
|
"defaultPermissions": {
|
||||||
"stream": ["listen", "control"]
|
"channel": ["listen", "control"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
BIN
musicroom.db
BIN
musicroom.db
Binary file not shown.
|
|
@ -1,9 +0,0 @@
|
||||||
{
|
|
||||||
"streams": [
|
|
||||||
{
|
|
||||||
"id": "main",
|
|
||||||
"name": "Main Stream",
|
|
||||||
"tracks": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,30 +1,23 @@
|
||||||
// MusicRoom - Stream Sync module
|
// MusicRoom - Channel Sync module
|
||||||
// WebSocket connection and server synchronization
|
// WebSocket connection and server synchronization
|
||||||
|
|
||||||
(function() {
|
(function() {
|
||||||
const M = window.MusicRoom;
|
const M = window.MusicRoom;
|
||||||
|
|
||||||
// Load available streams and connect to first one
|
// Load available channels and connect to first one
|
||||||
M.loadStreams = async function() {
|
M.loadChannels = async function() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/streams");
|
const res = await fetch("/api/channels");
|
||||||
const streams = await res.json();
|
const channels = await res.json();
|
||||||
if (streams.length === 0) {
|
if (channels.length === 0) {
|
||||||
M.$("#track-title").textContent = "No streams available";
|
M.$("#track-title").textContent = "No channels available";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (streams.length > 1) {
|
M.channels = channels;
|
||||||
const sel = document.createElement("select");
|
M.renderChannelList();
|
||||||
for (const s of streams) {
|
// Connect to first (default) channel
|
||||||
const opt = document.createElement("option");
|
const defaultChannel = channels.find(c => c.isDefault) || channels[0];
|
||||||
opt.value = s.id;
|
M.connectChannel(defaultChannel.id);
|
||||||
opt.textContent = s.name;
|
|
||||||
sel.appendChild(opt);
|
|
||||||
}
|
|
||||||
sel.onchange = () => M.connectStream(sel.value);
|
|
||||||
M.$("#stream-select").appendChild(sel);
|
|
||||||
}
|
|
||||||
M.connectStream(streams[0].id);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
M.$("#track-title").textContent = "Server unavailable";
|
M.$("#track-title").textContent = "Server unavailable";
|
||||||
M.$("#status").textContent = "Local (offline)";
|
M.$("#status").textContent = "Local (offline)";
|
||||||
|
|
@ -33,20 +26,93 @@
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Connect to a stream via WebSocket
|
// Create a new channel
|
||||||
M.connectStream = function(id) {
|
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 = `
|
||||||
|
<span class="channel-name">${ch.name}</span>
|
||||||
|
<span class="listener-count">${ch.listenerCount} 👤</span>
|
||||||
|
`;
|
||||||
|
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) {
|
if (M.ws) {
|
||||||
const oldWs = M.ws;
|
const oldWs = M.ws;
|
||||||
M.ws = null;
|
M.ws = null;
|
||||||
oldWs.onclose = null;
|
oldWs.onclose = null;
|
||||||
oldWs.close();
|
oldWs.close();
|
||||||
}
|
}
|
||||||
M.currentStreamId = id;
|
M.currentChannelId = id;
|
||||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
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) => {
|
M.ws.onmessage = (e) => {
|
||||||
const data = JSON.parse(e.data);
|
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
|
// Handle library updates
|
||||||
if (data.type === "track_added") {
|
if (data.type === "track_added") {
|
||||||
M.showToast(`"${data.track.title}" is now available`);
|
M.showToast(`"${data.track.title}" is now available`);
|
||||||
|
|
@ -72,7 +138,7 @@
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Normal stream update
|
// Normal channel state update
|
||||||
M.handleUpdate(data);
|
M.handleUpdate(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -83,7 +149,7 @@
|
||||||
M.updateUI();
|
M.updateUI();
|
||||||
// Auto-reconnect if user wants to be synced
|
// Auto-reconnect if user wants to be synced
|
||||||
if (M.wantSync) {
|
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) {
|
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) {
|
if (!data.track) {
|
||||||
M.$("#track-title").textContent = "No tracks";
|
M.$("#track-title").textContent = "No tracks";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
M.$("#stream-name").textContent = data.streamName || "";
|
M.$("#channel-name").textContent = data.channelName || "";
|
||||||
M.serverTimestamp = data.currentTimestamp;
|
M.serverTimestamp = data.currentTimestamp;
|
||||||
M.serverTrackDuration = data.track.duration;
|
M.serverTrackDuration = data.track.duration;
|
||||||
M.lastServerUpdate = Date.now();
|
M.lastServerUpdate = Date.now();
|
||||||
|
|
@ -148,14 +221,16 @@
|
||||||
// Try cache first
|
// Try cache first
|
||||||
const cachedUrl = await M.loadTrackBlob(M.currentTrackId);
|
const cachedUrl = await M.loadTrackBlob(M.currentTrackId);
|
||||||
M.audio.src = cachedUrl || M.getTrackUrl(M.currentTrackId);
|
M.audio.src = cachedUrl || M.getTrackUrl(M.currentTrackId);
|
||||||
}
|
M.audio.currentTime = data.currentTimestamp;
|
||||||
if (M.audio.paused) {
|
M.audio.play().catch(() => {});
|
||||||
|
} else if (M.audio.paused) {
|
||||||
M.audio.currentTime = data.currentTimestamp;
|
M.audio.currentTime = data.currentTimestamp;
|
||||||
M.audio.play().catch(() => {});
|
M.audio.play().catch(() => {});
|
||||||
} else {
|
} else {
|
||||||
// Check drift
|
// Check drift
|
||||||
const drift = Math.abs(M.audio.currentTime - data.currentTimestamp);
|
const drift = Math.abs(M.audio.currentTime - data.currentTimestamp);
|
||||||
if (drift >= 2) {
|
if (drift >= 2) {
|
||||||
|
console.log("[Sync] Correcting drift:", drift.toFixed(1), "s");
|
||||||
M.audio.currentTime = data.currentTimestamp;
|
M.audio.currentTime = data.currentTimestamp;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -42,8 +42,8 @@
|
||||||
if (M.playlist.length === 0) return;
|
if (M.playlist.length === 0) return;
|
||||||
const newIndex = (index + M.playlist.length) % M.playlist.length;
|
const newIndex = (index + M.playlist.length) % M.playlist.length;
|
||||||
|
|
||||||
if (M.synced && M.currentStreamId) {
|
if (M.synced && M.currentChannelId) {
|
||||||
const res = await fetch("/api/streams/" + M.currentStreamId + "/jump", {
|
const res = await fetch("/api/channels/" + M.currentChannelId + "/jump", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ index: newIndex })
|
body: JSON.stringify({ index: newIndex })
|
||||||
|
|
@ -72,8 +72,8 @@
|
||||||
M.wantSync = !M.wantSync;
|
M.wantSync = !M.wantSync;
|
||||||
if (M.wantSync) {
|
if (M.wantSync) {
|
||||||
// User wants to sync - try to connect
|
// User wants to sync - try to connect
|
||||||
if (M.currentStreamId) {
|
if (M.currentChannelId) {
|
||||||
M.connectStream(M.currentStreamId);
|
M.connectChannel(M.currentChannelId);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// User wants local mode - disconnect
|
// User wants local mode - disconnect
|
||||||
|
|
@ -120,8 +120,8 @@
|
||||||
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||||
const seekTime = pct * dur;
|
const seekTime = pct * dur;
|
||||||
|
|
||||||
if (M.synced && M.currentStreamId) {
|
if (M.synced && M.currentChannelId) {
|
||||||
fetch("/api/streams/" + M.currentStreamId + "/seek", {
|
fetch("/api/channels/" + M.currentChannelId + "/seek", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ timestamp: seekTime })
|
body: JSON.stringify({ timestamp: seekTime })
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,9 @@ window.MusicRoom = {
|
||||||
// Audio element
|
// Audio element
|
||||||
audio: new Audio(),
|
audio: new Audio(),
|
||||||
|
|
||||||
// WebSocket and stream state
|
// WebSocket and channel state
|
||||||
ws: null,
|
ws: null,
|
||||||
currentStreamId: null,
|
currentChannelId: null,
|
||||||
currentTrackId: null,
|
currentTrackId: null,
|
||||||
currentTitle: null,
|
currentTitle: null,
|
||||||
serverTimestamp: 0,
|
serverTimestamp: 0,
|
||||||
|
|
@ -15,6 +15,9 @@ window.MusicRoom = {
|
||||||
lastServerUpdate: 0,
|
lastServerUpdate: 0,
|
||||||
serverPaused: true,
|
serverPaused: true,
|
||||||
|
|
||||||
|
// Channels list
|
||||||
|
channels: [],
|
||||||
|
|
||||||
// Sync state
|
// Sync state
|
||||||
wantSync: true, // User intent - do they want to be synced?
|
wantSync: true, // User intent - do they want to be synced?
|
||||||
synced: false, // Actual state - are we currently synced?
|
synced: false, // Actual state - are we currently synced?
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,13 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="main-content">
|
<div id="main-content">
|
||||||
|
<div id="channels-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h3>Channels</h3>
|
||||||
|
<button id="btn-new-channel" title="New channel">+</button>
|
||||||
|
</div>
|
||||||
|
<div id="channels-list"></div>
|
||||||
|
</div>
|
||||||
<div id="library-panel">
|
<div id="library-panel">
|
||||||
<h3>Library</h3>
|
<h3>Library</h3>
|
||||||
<div id="library"></div>
|
<div id="library"></div>
|
||||||
|
|
@ -66,7 +73,7 @@
|
||||||
|
|
||||||
<div id="player-bar">
|
<div id="player-bar">
|
||||||
<div id="now-playing">
|
<div id="now-playing">
|
||||||
<div id="stream-name"></div>
|
<div id="channel-name"></div>
|
||||||
<div id="track-name" class="empty">
|
<div id="track-name" class="empty">
|
||||||
<span id="track-title">Loading...</span>
|
<span id="track-title">Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -96,7 +103,7 @@
|
||||||
<script src="core.js"></script>
|
<script src="core.js"></script>
|
||||||
<script src="utils.js"></script>
|
<script src="utils.js"></script>
|
||||||
<script src="audioCache.js"></script>
|
<script src="audioCache.js"></script>
|
||||||
<script src="streamSync.js"></script>
|
<script src="channelSync.js"></script>
|
||||||
<script src="ui.js"></script>
|
<script src="ui.js"></script>
|
||||||
<script src="playlist.js"></script>
|
<script src="playlist.js"></script>
|
||||||
<script src="controls.js"></script>
|
<script src="controls.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@
|
||||||
M.loadSelectedPlaylist("all"); // Default to All Tracks
|
M.loadSelectedPlaylist("all"); // Default to All Tracks
|
||||||
await M.loadCurrentUser();
|
await M.loadCurrentUser();
|
||||||
if (M.currentUser) {
|
if (M.currentUser) {
|
||||||
M.loadStreams();
|
M.loadChannels();
|
||||||
M.loadPlaylists();
|
M.loadPlaylists();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -133,8 +133,8 @@
|
||||||
div.innerHTML = `<span class="cache-indicator"></span><span class="track-title">${title}</span><span class="track-actions">${removeBtn}<span class="duration">${M.fmt(track.duration)}</span></span>`;
|
div.innerHTML = `<span class="cache-indicator"></span><span class="track-title">${title}</span><span class="track-actions">${removeBtn}<span class="duration">${M.fmt(track.duration)}</span></span>`;
|
||||||
|
|
||||||
div.querySelector(".track-title").onclick = async () => {
|
div.querySelector(".track-title").onclick = async () => {
|
||||||
if (M.synced && M.currentStreamId) {
|
if (M.synced && M.currentChannelId) {
|
||||||
const res = await fetch("/api/streams/" + M.currentStreamId + "/jump", {
|
const res = await fetch("/api/channels/" + M.currentChannelId + "/jump", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ index: i })
|
body: JSON.stringify({ index: i })
|
||||||
|
|
|
||||||
|
|
@ -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 - library and playlist */
|
||||||
#main-content { display: flex; gap: 1rem; flex: 1; min-height: 0; margin-bottom: 1rem; }
|
#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; }
|
#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-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; }
|
#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 */
|
||||||
#player-bar { background: #1a1a1a; border-radius: 8px; padding: 1rem; display: flex; gap: 1rem; align-items: center; }
|
#player-bar { background: #1a1a1a; border-radius: 8px; padding: 1rem; display: flex; gap: 1rem; align-items: center; }
|
||||||
#now-playing { min-width: 200px; }
|
#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; }
|
#track-name { font-size: 1rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
#player-controls { flex: 1; }
|
#player-controls { flex: 1; }
|
||||||
#progress-row { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.3rem; }
|
#progress-row { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.3rem; }
|
||||||
|
|
|
||||||
322
server.ts
322
server.ts
|
|
@ -1,7 +1,6 @@
|
||||||
import { file, serve, type ServerWebSocket } from "bun";
|
import { file, serve, type ServerWebSocket } from "bun";
|
||||||
import { parseFile } from "music-metadata";
|
import { Channel, type Track, type WsData } from "./channel";
|
||||||
import { Stream, type Track } from "./stream";
|
import { readdir } from "fs/promises";
|
||||||
import { readdir, stat } from "fs/promises";
|
|
||||||
import { join, resolve } from "path";
|
import { join, resolve } from "path";
|
||||||
import {
|
import {
|
||||||
createUser,
|
createUser,
|
||||||
|
|
@ -46,7 +45,7 @@ interface Config {
|
||||||
musicDir: string;
|
musicDir: string;
|
||||||
allowGuests: boolean;
|
allowGuests: boolean;
|
||||||
defaultPermissions: {
|
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 config: Config = await file(CONFIG_PATH).json();
|
||||||
|
|
||||||
const MUSIC_DIR = resolve(import.meta.dir, config.musicDir);
|
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");
|
const PUBLIC_DIR = join(import.meta.dir, "public");
|
||||||
|
|
||||||
console.log(`Config loaded: port=${config.port}, musicDir=${MUSIC_DIR}, allowGuests=${config.allowGuests}`);
|
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
|
// Initialize library
|
||||||
const library = new Library(MUSIC_DIR);
|
const library = new Library(MUSIC_DIR);
|
||||||
|
|
||||||
// Load track metadata (for stream initialization - converts library tracks to stream format)
|
|
||||||
async function loadTrack(filename: string): Promise<Track> {
|
|
||||||
// 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
|
// Auto-discover tracks if playlist is empty
|
||||||
async function discoverTracks(): Promise<string[]> {
|
async function discoverTracks(): Promise<string[]> {
|
||||||
try {
|
try {
|
||||||
|
|
@ -102,48 +70,63 @@ async function discoverTracks(): Promise<string[]> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize streams
|
// Generate unique channel ID
|
||||||
async function init(): Promise<Map<string, Stream>> {
|
function generateChannelId(): string {
|
||||||
|
return Math.random().toString(36).slice(2, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize channels - create default channel with full library
|
||||||
|
const channels = new Map<string, Channel>();
|
||||||
|
|
||||||
|
async function init(): Promise<void> {
|
||||||
// Scan library first
|
// Scan library first
|
||||||
await library.scan();
|
await library.scan();
|
||||||
library.startWatching();
|
library.startWatching();
|
||||||
|
|
||||||
const playlistData = await file(PLAYLIST_PATH).json();
|
// Create default channel with full library
|
||||||
const streams = new Map<string, Stream>();
|
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 defaultChannel = new Channel({
|
||||||
const allFiles = await discoverTracks();
|
id: "main",
|
||||||
|
name: "Main Channel",
|
||||||
|
description: "All tracks from the library",
|
||||||
|
tracks,
|
||||||
|
isDefault: true,
|
||||||
|
createdBy: null,
|
||||||
|
});
|
||||||
|
|
||||||
for (const cfg of playlistData.streams) {
|
channels.set("main", defaultChannel);
|
||||||
let trackFiles: string[] = cfg.tracks;
|
console.log(`Default channel created: ${tracks.length} 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;
|
await init();
|
||||||
}
|
|
||||||
|
|
||||||
const streams = await init();
|
// Broadcast to all connected clients across all channels
|
||||||
|
|
||||||
// Broadcast to all connected clients across all streams
|
|
||||||
function broadcastToAll(message: object) {
|
function broadcastToAll(message: object) {
|
||||||
const data = JSON.stringify(message);
|
const data = JSON.stringify(message);
|
||||||
for (const stream of streams.values()) {
|
let clientCount = 0;
|
||||||
for (const ws of stream.clients) {
|
for (const channel of channels.values()) {
|
||||||
|
for (const ws of channel.clients) {
|
||||||
ws.send(data);
|
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
|
// Listen for library changes and notify clients
|
||||||
|
|
@ -179,10 +162,14 @@ library.on("removed", (track) => {
|
||||||
let tickCount = 0;
|
let tickCount = 0;
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
tickCount++;
|
tickCount++;
|
||||||
for (const stream of streams.values()) {
|
for (const channel of channels.values()) {
|
||||||
const changed = stream.tick();
|
const changed = channel.tick();
|
||||||
|
if (changed) {
|
||||||
|
console.log(`[Tick] Channel "${channel.name}" advanced to track ${channel.currentIndex}`);
|
||||||
|
}
|
||||||
if (!changed && tickCount % 30 === 0) {
|
if (!changed && tickCount % 30 === 0) {
|
||||||
stream.broadcast();
|
console.log(`[Tick] Broadcasting state for channel "${channel.name}" (${channel.clients.size} clients)`);
|
||||||
|
channel.broadcast();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
@ -215,7 +202,7 @@ function userHasPermission(user: ReturnType<typeof getUser>, resourceType: strin
|
||||||
if (user.is_guest && permission === "control") return false;
|
if (user.is_guest && permission === "control") return false;
|
||||||
|
|
||||||
// Check default permissions from config
|
// Check default permissions from config
|
||||||
if (resourceType === "stream" && config.defaultPermissions.stream?.includes(permission)) {
|
if (resourceType === "channel" && config.defaultPermissions.channel?.includes(permission)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -229,12 +216,12 @@ serve({
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const path = url.pathname;
|
const path = url.pathname;
|
||||||
|
|
||||||
// WebSocket upgrade
|
// WebSocket upgrade for channels
|
||||||
if (path.match(/^\/api\/streams\/([^/]+)\/ws$/)) {
|
if (path.match(/^\/api\/channels\/([^/]+)\/ws$/)) {
|
||||||
const id = path.split("/")[3];
|
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 { 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;
|
if (ok) return undefined;
|
||||||
return new Response("WebSocket upgrade failed", { status: 500 });
|
return new Response("WebSocket upgrade failed", { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
@ -246,25 +233,112 @@ serve({
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
allowGuests: config.allowGuests,
|
allowGuests: config.allowGuests,
|
||||||
allowSignups: true,
|
allowSignups: true,
|
||||||
streamCount: streams.size,
|
channelCount: channels.size,
|
||||||
defaultPermissions: config.defaultPermissions,
|
defaultPermissions: config.defaultPermissions,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// API: list streams (requires auth or guest)
|
// API: list channels (requires auth or guest)
|
||||||
if (path === "/api/streams") {
|
if (path === "/api/channels" && req.method === "GET") {
|
||||||
const { user, headers } = getOrCreateUser(req, server);
|
const { user, headers } = getOrCreateUser(req, server);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return Response.json({ error: "Authentication required" }, { status: 401 });
|
return Response.json({ error: "Authentication required" }, { status: 401 });
|
||||||
}
|
}
|
||||||
const list = [...streams.values()].map((s) => ({
|
const list = [...channels.values()].map(c => c.getListInfo());
|
||||||
id: s.id,
|
|
||||||
name: s.name,
|
|
||||||
trackCount: s.playlist.length,
|
|
||||||
}));
|
|
||||||
return Response.json(list, { headers });
|
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)
|
// API: get library (all tracks)
|
||||||
if (path === "/api/library") {
|
if (path === "/api/library") {
|
||||||
const { user, headers } = getOrCreateUser(req, server);
|
const { user, headers } = getOrCreateUser(req, server);
|
||||||
|
|
@ -522,14 +596,14 @@ serve({
|
||||||
const permissions = getUserPermissions(user.id);
|
const permissions = getUserPermissions(user.id);
|
||||||
// Add default permissions for all users (except control for guests)
|
// Add default permissions for all users (except control for guests)
|
||||||
const effectivePermissions = [...permissions];
|
const effectivePermissions = [...permissions];
|
||||||
if (config.defaultPermissions.stream) {
|
if (config.defaultPermissions.channel) {
|
||||||
for (const perm of config.defaultPermissions.stream) {
|
for (const perm of config.defaultPermissions.channel) {
|
||||||
// Guests can never have control permission
|
// Guests can never have control permission
|
||||||
if (user.is_guest && perm === "control") continue;
|
if (user.is_guest && perm === "control") continue;
|
||||||
effectivePermissions.push({
|
effectivePermissions.push({
|
||||||
id: 0,
|
id: 0,
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
resource_type: "stream",
|
resource_type: "channel",
|
||||||
resource_id: null,
|
resource_id: null,
|
||||||
permission: perm,
|
permission: perm,
|
||||||
});
|
});
|
||||||
|
|
@ -581,19 +655,19 @@ serve({
|
||||||
}
|
}
|
||||||
|
|
||||||
// API: jump to track in playlist
|
// 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") {
|
if (jumpMatch && req.method === "POST") {
|
||||||
const streamId = jumpMatch[1];
|
const channelId = jumpMatch[1];
|
||||||
const { user } = getOrCreateUser(req, server);
|
const { user } = getOrCreateUser(req, server);
|
||||||
if (!userHasPermission(user, "stream", streamId, "control")) {
|
if (!userHasPermission(user, "channel", channelId, "control")) {
|
||||||
return new Response("Forbidden", { status: 403 });
|
return new Response("Forbidden", { status: 403 });
|
||||||
}
|
}
|
||||||
const stream = streams.get(streamId);
|
const channel = channels.get(channelId);
|
||||||
if (!stream) return new Response("Not found", { status: 404 });
|
if (!channel) return new Response("Not found", { status: 404 });
|
||||||
try {
|
try {
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
if (typeof body.index === "number") {
|
if (typeof body.index === "number") {
|
||||||
stream.jumpTo(body.index);
|
channel.jumpTo(body.index);
|
||||||
return Response.json({ success: true });
|
return Response.json({ success: true });
|
||||||
}
|
}
|
||||||
return new Response("Invalid index", { status: 400 });
|
return new Response("Invalid index", { status: 400 });
|
||||||
|
|
@ -602,20 +676,20 @@ serve({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// API: seek in stream
|
// API: seek in channel
|
||||||
const seekMatch = path.match(/^\/api\/streams\/([^/]+)\/seek$/);
|
const seekMatch = path.match(/^\/api\/channels\/([^/]+)\/seek$/);
|
||||||
if (seekMatch && req.method === "POST") {
|
if (seekMatch && req.method === "POST") {
|
||||||
const streamId = seekMatch[1];
|
const channelId = seekMatch[1];
|
||||||
const { user } = getOrCreateUser(req, server);
|
const { user } = getOrCreateUser(req, server);
|
||||||
if (!userHasPermission(user, "stream", streamId, "control")) {
|
if (!userHasPermission(user, "channel", channelId, "control")) {
|
||||||
return new Response("Forbidden", { status: 403 });
|
return new Response("Forbidden", { status: 403 });
|
||||||
}
|
}
|
||||||
const stream = streams.get(streamId);
|
const channel = channels.get(channelId);
|
||||||
if (!stream) return new Response("Not found", { status: 404 });
|
if (!channel) return new Response("Not found", { status: 404 });
|
||||||
try {
|
try {
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
if (typeof body.timestamp === "number") {
|
if (typeof body.timestamp === "number") {
|
||||||
stream.seek(body.timestamp);
|
channel.seek(body.timestamp);
|
||||||
return Response.json({ success: true });
|
return Response.json({ success: true });
|
||||||
}
|
}
|
||||||
return new Response("Invalid timestamp", { status: 400 });
|
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)
|
// API: serve audio file (requires auth or guest)
|
||||||
// Supports both filename and track ID (sha256:...)
|
// Supports both filename and track ID (sha256:...)
|
||||||
const trackMatch = path.match(/^\/api\/tracks\/(.+)$/);
|
const trackMatch = path.match(/^\/api\/tracks\/(.+)$/);
|
||||||
|
|
@ -713,17 +779,41 @@ serve({
|
||||||
|
|
||||||
websocket: {
|
websocket: {
|
||||||
open(ws: ServerWebSocket<WsData>) {
|
open(ws: ServerWebSocket<WsData>) {
|
||||||
const stream = streams.get(ws.data.streamId);
|
const channel = channels.get(ws.data.channelId);
|
||||||
if (stream) stream.addClient(ws);
|
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<WsData>) {
|
close(ws: ServerWebSocket<WsData>) {
|
||||||
const stream = streams.get(ws.data.streamId);
|
const channel = channels.get(ws.data.channelId);
|
||||||
if (stream) stream.removeClient(ws);
|
if (channel) channel.removeClient(ws);
|
||||||
},
|
},
|
||||||
message(ws: ServerWebSocket<WsData>, message: string | Buffer) {
|
message(ws: ServerWebSocket<WsData>, message: string | Buffer) {
|
||||||
const stream = streams.get(ws.data.streamId);
|
try {
|
||||||
if (!stream) {
|
const data = JSON.parse(String(message));
|
||||||
console.log("[WS] No stream found for:", ws.data.streamId);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -748,18 +838,18 @@ serve({
|
||||||
|
|
||||||
// Check default permissions or user-specific permissions
|
// Check default permissions or user-specific permissions
|
||||||
const canControl = user.is_admin
|
const canControl = user.is_admin
|
||||||
|| config.defaultPermissions.stream?.includes("control")
|
|| config.defaultPermissions.channel?.includes("control")
|
||||||
|| hasPermission(userId, "stream", ws.data.streamId, "control");
|
|| hasPermission(userId, "channel", ws.data.channelId, "control");
|
||||||
if (!canControl) {
|
if (!canControl) {
|
||||||
console.log("[WS] User lacks control permission:", user.username);
|
console.log("[WS] User lacks control permission:", user.username);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(String(message));
|
|
||||||
console.log("[WS] Control action:", data.action, "from", user.username);
|
console.log("[WS] Control action:", data.action, "from", user.username);
|
||||||
if (data.action === "pause") stream.pause();
|
if (data.action === "pause") channel.pause();
|
||||||
else if (data.action === "unpause") stream.unpause();
|
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 {}
|
} catch {}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue