commit d3e5c249628e650b7374435be1cbb9c38ccacf82 Author: Peterino2 Date: Thu Feb 5 09:34:49 2026 -0800 saving diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa77ef8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store + +tmp/ +library_cache.db +musicroom.db +blastoise.db +config.json diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..12704be --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,202 @@ +# MusicRoom + +Synchronized music streaming server built with Bun. Manages "channels" (virtual radio stations) that play through queues sequentially. Clients connect, receive now-playing state, download audio, and sync playback locally. + +## Architecture + +### Server + +The server does NOT decode or play audio. It tracks time: +- `currentTimestamp = (Date.now() - channel.startedAt) / 1000` +- When `currentTimestamp >= track.duration`, advance to next track, reset `startedAt` +- A 1s `setInterval` checks if tracks need advancing and broadcasts state every 30s + +### Channels + +Channels are virtual radio stations. A **default channel** is created on server start with the full library: + +```ts +interface Channel { + id: string; + name: string; + description: string; + queue: Track[]; // tracks in playback order + currentIndex: number; + startedAt: number; + paused: boolean; + clients: Set; + createdBy: number | null; // user ID or null for default + isDefault: boolean; +} +``` + +- **Default Channel**: Created on startup, plays all library tracks, cannot be deleted +- **Dynamic Channels**: Users can create channels via POST `/api/channels` +- **Channel Switching**: Clients switch channels via WebSocket `{ action: "switch", channelId }` + +### Client + +The player's role is simple: **play an arbitrary track by ID**. It does not manage queues or sync logic directly. + +- Receives track ID and timestamp from server via WebSocket +- Downloads audio from `/api/tracks/:id` +- Syncs playback position to server timestamp +- Caches tracks locally in IndexedDB + +### Library & Queue Views + +Both views display tracks with real-time status indicators: +- **Green bar**: Track is fully cached locally (in IndexedDB) +- **Yellow bar**: Track is not cached + +The buffer bar (below progress) shows 20 segments indicating download/buffer status: +- Segments fill as audio is buffered by browser or fetched via range requests +- When all segments are buffered, the track is automatically cached to IndexedDB + +## Content-Addressed Tracks + +All tracks are identified by a **content hash** (`sha256:` prefix + first 64KB hash): + +| Field | Purpose | +|-------|---------| +| `track.id` | Content hash - primary key, used for API, caching, database | +| `track.filename` | Original filename - display only | +| `track.title` | Metadata title - display only | + +Benefits: +- Deduplication (same file with different names = same track) +- Renaming files without breaking queues +- Reliable client-side caching by content hash + +The client uses `track.id` for: +- Caching tracks in IndexedDB (`TrackStorage.set(trackId, blob)`) +- Fetching audio (`/api/tracks/:trackId`) +- Checking cache status (`M.cachedTracks.has(trackId)`) + +## Client Caching System + +### Segment-Based Buffering +- Tracks are divided into 20 virtual segments +- Browser's native buffering is synced to `M.trackCaches` Map +- Range requests fetch individual segments for seeking + +### Full Track Caching +When all 20 segments are buffered: +1. Full track is downloaded via `downloadAndCacheTrack()` +2. Stored in IndexedDB via `TrackStorage` +3. Added to `M.cachedTracks` Set +4. UI indicators update to green + +### Cache State (in-memory) +```js +M.trackCaches // Map> - 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/channels → List all channels with listener counts +POST /api/channels → Create a new channel +GET /api/channels/:id → Get channel state +DELETE /api/channels/:id → Delete a channel (not default) +WS /api/channels/:id/ws → WebSocket: pushes state on connect and changes +GET /api/tracks/:id → Serve audio by content hash (supports Range) +GET /api/library → List all tracks with id, filename, title, duration +``` + +## Files + +### Server +- **server.ts** — Bun entrypoint. HTTP routes and WebSocket handlers. +- **channel.ts** — `Channel` class. Queue, current index, time tracking, broadcasting. +- **library.ts** — `Library` class. Scans music directory, computes content hashes. +- **db.ts** — SQLite database for users, sessions, tracks. + +### Client (public/) +- **core.js** — Global state namespace (`window.MusicRoom`) +- **utils.js** — Helpers ($, fmt, showToast) +- **audioCache.js** — Track caching, segment downloads, prefetching +- **channelSync.js** — WebSocket connection, server sync, channel switching +- **ui.js** — Progress bar, buffer display, UI updates +- **queue.js** — Queue/library rendering, cache status +- **controls.js** — Play, pause, seek, volume +- **auth.js** — Login, signup, logout +- **init.js** — App initialization +- **trackStorage.js** — IndexedDB abstraction for track blobs + +### Config +- **config.json** — Server configuration (port, musicDir, permissions) +- **music/** — Audio files (.mp3, .ogg, .flac, .wav, .m4a, .aac) + +## Key Types + +```ts +interface Track { + id: string; // Content hash (sha256:...) + filename: string; // Original filename + title: string; // Display title + duration: number; +} + +// Channel.getState() returns: +{ + track: Track | null, + currentTimestamp: number, + channelName: string, + channelId: string, + description: string, + paused: boolean, + queue: Track[], + currentIndex: number, + listenerCount: number, + listeners: string[], // usernames of connected users + isDefault: boolean +} +``` + +## WebSocket Messages + +**Client → Server:** +```json +{ "action": "switch", "channelId": "abc123" } +{ "action": "pause" } +{ "action": "unpause" } +{ "action": "seek", "timestamp": 45.5 } +{ "action": "jump", "index": 3 } +``` + +**Server → Client:** +```json +{ "type": "channel_list", "channels": [...] } +{ "type": "switched", "channelId": "abc123" } +{ "track": {...}, "currentTimestamp": 45.5, ... } +``` + +## Client Sync Logic + +On WebSocket message: +1. New track → load audio by `track.id`, seek to server timestamp, play +2. Same track, drift < 2s → ignore +3. Same track, drift >= 2s → seek to server timestamp + +## Debug Functions + +Available in browser console: +```js +MusicRoom.debugCacheMismatch() // Compare queue IDs vs cached IDs +MusicRoom.debugTrack(index) // Detailed cache state for track at index +MusicRoom.debugCacheStatus() // Current track cache state +MusicRoom.clearAllCaches() // Clear IndexedDB and in-memory caches +``` + +## Config + +Default port 3001 (override with `PORT` env var). Track durations read from file metadata on startup with `music-metadata`. + +## Test User + +- **Username**: test +- **Password**: testuser diff --git a/README.md b/README.md new file mode 100644 index 0000000..cd2948e --- /dev/null +++ b/README.md @@ -0,0 +1,21 @@ +# Blastoise + +Everything that I've wanted out of a networked music player. Self-host. Play your music through the server, but also cache all the tracks locally. + +But most importantly, listen to the same stuff as your friends. + +Its actually more of a file downloader than a normal music streamer. Built with bun. + +First time setup: + +```bash +bun install +``` + +to launch the server + +```bash +bun run server.ts +``` + +have a look at the config.json that gets created the first time. diff --git a/auth.ts b/auth.ts new file mode 100644 index 0000000..3f59feb --- /dev/null +++ b/auth.ts @@ -0,0 +1,67 @@ +import { validateSession, hasPermission, type User } from "./db"; + +const COOKIE_NAME = "blastoise_session"; + +export function getSessionToken(req: Request): string | null { + const cookie = req.headers.get("cookie"); + if (!cookie) return null; + + const match = cookie.match(new RegExp(`${COOKIE_NAME}=([^;]+)`)); + return match ? match[1] : null; +} + +export function getClientInfo(req: Request, server?: { requestIP?: (req: Request) => { address: string } | null }): { userAgent: string; ipAddress: string } { + const userAgent = req.headers.get("user-agent") ?? "unknown"; + const ipAddress = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() + ?? req.headers.get("x-real-ip") + ?? server?.requestIP?.(req)?.address + ?? "unknown"; + return { userAgent, ipAddress }; +} + +export function getRequestMeta(req: Request, server?: { requestIP?: (req: Request) => { address: string } | null }): { userAgent?: string; ipAddress?: string } { + const userAgent = req.headers.get("user-agent") ?? undefined; + const ipAddress = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() + ?? req.headers.get("x-real-ip") + ?? server?.requestIP?.(req)?.address + ?? undefined; + return { userAgent, ipAddress }; +} + +export function getUser(req: Request, server?: { requestIP?: (req: Request) => { address: string } | null }): User | null { + const token = getSessionToken(req); + if (!token) return null; + const { userAgent, ipAddress } = getRequestMeta(req, server); + return validateSession(token, userAgent, ipAddress); +} + +export function requireUser(req: Request, server?: { requestIP?: (req: Request) => { address: string } | null }): User { + const user = getUser(req, server); + if (!user) { + throw new Response("Unauthorized", { status: 401 }); + } + return user; +} + +export function requirePermission( + req: Request, + resourceType: string, + resourceId: string | null, + permission: string, + server?: { requestIP?: (req: Request) => { address: string } | null } +): User { + const user = requireUser(req, server); + if (!user.is_admin && !hasPermission(user.id, resourceType, resourceId, permission)) { + throw new Response("Forbidden", { status: 403 }); + } + return user; +} + +export function setSessionCookie(token: string): string { + const maxAge = 7 * 24 * 60 * 60; // 7 days + return `${COOKIE_NAME}=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}`; +} + +export function clearSessionCookie(): string { + return `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0`; +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..5804e63 --- /dev/null +++ b/bun.lock @@ -0,0 +1,57 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "blastoise", + "dependencies": { + "music-metadata": "^11.11.2", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@borewit/text-codec": ["@borewit/text-codec@0.2.1", "", {}, "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw=="], + + "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="], + + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + + "@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="], + + "@types/node": ["@types/node@25.2.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w=="], + + "bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "file-type": ["file-type@21.3.0", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "music-metadata": ["music-metadata@11.11.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "content-type": "^1.0.5", "debug": "^4.4.3", "file-type": "^21.3.0", "media-typer": "^1.1.0", "strtok3": "^10.3.4", "token-types": "^6.1.2", "uint8array-extras": "^1.5.0", "win-guid": "^0.2.1" } }, "sha512-tJx+lsDg1bGUOxojKKj12BIvccBBUcVa6oWrvOchCF0WAQ9E5t/hK35ILp1z3wWrUSYtgg57LfRbvVMkxGIyzA=="], + + "strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="], + + "token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "win-guid": ["win-guid@0.2.1", "", {}, "sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A=="], + } +} diff --git a/channel.ts b/channel.ts new file mode 100644 index 0000000..8076cf7 --- /dev/null +++ b/channel.ts @@ -0,0 +1,336 @@ +import type { ServerWebSocket } from "bun"; + +export interface Track { + id: string; // Content hash (primary key) + filename: string; // Original filename + title: string; // Display title + duration: number; +} + +export type PlaybackMode = "once" | "repeat-all" | "repeat-one" | "shuffle"; + +export interface ChannelConfig { + id: string; + name: string; + description?: string; + tracks: Track[]; + createdBy?: number | null; + isDefault?: boolean; + currentIndex?: number; + startedAt?: number; + paused?: boolean; + pausedAt?: number; + playbackMode?: PlaybackMode; +} + +export type WsData = { channelId: string; userId: number | null; username: string }; + +export type PersistenceCallback = (channel: Channel, type: "state" | "queue") => void; + +export class Channel { + id: string; + name: string; + description: string; + queue: Track[]; + currentIndex: number = 0; + startedAt: number = Date.now(); + clients: Set> = new Set(); + paused: boolean = false; + pausedAt: number = 0; + createdBy: number | null; + createdAt: number; + isDefault: boolean; + playbackMode: PlaybackMode = "repeat-all"; + private lastQueueBroadcast: number = 0; + private queueDirty: boolean = false; + private onPersist: PersistenceCallback | null = null; + + constructor(config: ChannelConfig) { + this.id = config.id; + this.name = config.name; + this.description = config.description || ""; + this.queue = config.tracks; + this.createdBy = config.createdBy ?? null; + this.createdAt = Date.now(); + this.isDefault = config.isDefault ?? false; + this.currentIndex = config.currentIndex ?? 0; + this.startedAt = config.startedAt ?? Date.now(); + this.paused = config.paused ?? false; + this.pausedAt = config.pausedAt ?? 0; + this.playbackMode = config.playbackMode ?? "repeat-all"; + } + + setPersistenceCallback(callback: PersistenceCallback) { + this.onPersist = callback; + } + + private persistState() { + this.onPersist?.(this, "state"); + } + + private persistQueue() { + this.onPersist?.(this, "queue"); + } + + get currentTrack(): Track | null { + if (this.queue.length === 0) return null; + return this.queue[this.currentIndex]; + } + + get currentTimestamp(): number { + if (this.queue.length === 0) return 0; + if (this.paused) return this.pausedAt; + return (Date.now() - this.startedAt) / 1000; + } + + tick(): boolean { + if (this.paused) return false; + const track = this.currentTrack; + if (!track) return false; + if (this.currentTimestamp >= track.duration) { + this.advance(); + return true; + } + return false; + } + + advance() { + if (this.queue.length === 0) return; + + switch (this.playbackMode) { + case "once": + // Play through once, stop at end + if (this.currentIndex < this.queue.length - 1) { + this.currentIndex++; + } else { + // At end of playlist - pause + this.paused = true; + } + break; + case "repeat-one": + // Stay on same track, just reset timestamp + break; + case "shuffle": + // Pick a random track (different from current if possible) + if (this.queue.length > 1) { + let newIndex; + do { + newIndex = Math.floor(Math.random() * this.queue.length); + } while (newIndex === this.currentIndex); + this.currentIndex = newIndex; + } + break; + case "repeat-all": + default: + this.currentIndex = (this.currentIndex + 1) % this.queue.length; + break; + } + + this.startedAt = Date.now(); + this.persistState(); + this.broadcast(); + } + + setPlaybackMode(mode: PlaybackMode) { + this.playbackMode = mode; + this.persistState(); + this.broadcast(); + } + + getState(includeQueue: boolean = false) { + const state: Record = { + track: this.currentTrack, + currentTimestamp: this.currentTimestamp, + channelName: this.name, + channelId: this.id, + description: this.description, + paused: this.paused, + currentIndex: this.currentIndex, + listenerCount: this.clients.size, + isDefault: this.isDefault, + playbackMode: this.playbackMode, + }; + if (includeQueue) { + state.queue = this.queue; + } + return state; + } + + pause() { + if (this.paused) return; + this.pausedAt = this.currentTimestamp; + this.paused = true; + this.persistState(); + this.broadcast(); + } + + unpause() { + if (!this.paused) return; + this.paused = false; + this.startedAt = Date.now() - this.pausedAt * 1000; + this.persistState(); + this.broadcast(); + } + + jumpTo(index: number) { + if (index < 0 || index >= this.queue.length) return; + this.currentIndex = index; + if (this.paused) { + this.pausedAt = 0; + } else { + this.startedAt = Date.now(); + } + this.persistState(); + this.broadcast(); + } + + seek(timestamp: number) { + const track = this.currentTrack; + if (!track) return; + const clamped = Math.max(0, Math.min(timestamp, track.duration)); + if (this.paused) { + this.pausedAt = clamped; + } else { + this.startedAt = Date.now() - clamped * 1000; + } + this.persistState(); + this.broadcast(); + } + + markQueueDirty() { + this.queueDirty = true; + } + + setQueue(tracks: Track[]) { + // Remember current track and timestamp to preserve playback position + const currentTrackId = this.currentTrack?.id; + const currentTimestampValue = this.currentTimestamp; + const wasPaused = this.paused; + + this.queue = tracks; + + // Try to find the current track in the new queue + if (currentTrackId) { + const newIndex = this.queue.findIndex(t => t.id === currentTrackId); + if (newIndex !== -1) { + // Found the track - preserve playback position + this.currentIndex = newIndex; + if (wasPaused) { + this.pausedAt = currentTimestampValue; + } else { + this.startedAt = Date.now() - currentTimestampValue * 1000; + } + } else { + // Track not found in new queue - reset to start + this.currentIndex = 0; + this.startedAt = Date.now(); + this.pausedAt = 0; + } + } else { + // No current track - reset to start + this.currentIndex = 0; + this.startedAt = Date.now(); + this.pausedAt = 0; + } + + this.queueDirty = true; + this.persistQueue(); + this.persistState(); + this.broadcast(); + } + + addTracks(tracks: Track[]) { + if (tracks.length === 0) return; + this.queue.push(...tracks); + this.queueDirty = true; + this.persistQueue(); + this.broadcast(); + } + + removeTracksByIndex(indices: number[]) { + if (indices.length === 0) return; + + // Sort descending to remove from end first (preserve indices) + const sorted = [...indices].sort((a, b) => b - a); + const currentTrackId = this.currentTrack?.id; + + for (const idx of sorted) { + if (idx >= 0 && idx < this.queue.length) { + this.queue.splice(idx, 1); + // Adjust currentIndex if we removed a track before it + if (idx < this.currentIndex) { + this.currentIndex--; + } else if (idx === this.currentIndex) { + // Removed currently playing track - stay at same index (next track slides in) + // If we removed the last track, wrap to start + if (this.currentIndex >= this.queue.length) { + this.currentIndex = 0; + this.startedAt = Date.now(); + this.pausedAt = 0; + } + } + } + } + + // If queue is now empty, reset state + if (this.queue.length === 0) { + this.currentIndex = 0; + this.startedAt = Date.now(); + this.pausedAt = 0; + } + + // If current track changed, reset playback position + if (this.queue.length > 0 && this.currentTrack?.id !== currentTrackId) { + this.startedAt = Date.now(); + this.pausedAt = 0; + } + + this.queueDirty = true; + this.persistQueue(); + this.persistState(); + this.broadcast(); + } + + broadcast() { + const now = Date.now(); + const includeQueue = this.queueDirty || (now - this.lastQueueBroadcast >= 60000); + if (includeQueue) { + this.lastQueueBroadcast = now; + this.queueDirty = false; + } + const msg = JSON.stringify(this.getState(includeQueue)); + + for (const ws of this.clients) { + ws.send(msg); + } + } + + addClient(ws: ServerWebSocket) { + this.clients.add(ws); + console.log(`[Channel] "${this.name}" added client, now ${this.clients.size} clients`); + + // Always send full state with queue on connect + ws.send(JSON.stringify(this.getState(true))); + // Reset timer so next queue broadcast is in 60s + this.lastQueueBroadcast = Date.now(); + } + + removeClient(ws: ServerWebSocket) { + this.clients.delete(ws); + console.log(`[Channel] "${this.name}" removed client, now ${this.clients.size} clients`); + } + + getListInfo() { + const listeners = Array.from(this.clients).map(ws => ws.data?.username ?? 'Unknown'); + return { + id: this.id, + name: this.name, + description: this.description, + trackCount: this.queue.length, + listenerCount: this.clients.size, + listeners, + isDefault: this.isDefault, + createdBy: this.createdBy, + }; + } +} diff --git a/db.ts b/db.ts new file mode 100644 index 0000000..775addc --- /dev/null +++ b/db.ts @@ -0,0 +1,415 @@ +import { Database } from "bun:sqlite"; + +const DB_PATH = "./blastoise.db"; +const SESSION_EXPIRY_DAYS = 7; +const GUEST_SESSION_EXPIRY_HOURS = 24; + +const db = new Database(DB_PATH); + +// Initialize tables +db.run(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER DEFAULT 0, + is_guest INTEGER DEFAULT 0, + created_at INTEGER DEFAULT (unixepoch()) + ) +`); + +// Migration: add is_guest column if it doesn't exist +try { + db.run(`ALTER TABLE users ADD COLUMN is_guest INTEGER DEFAULT 0`); +} catch {} + +db.run(` + CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + created_at INTEGER DEFAULT (unixepoch()), + user_agent TEXT, + ip_address TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) +`); + +// Migration: add columns if they don't exist +try { + db.run(`ALTER TABLE sessions ADD COLUMN user_agent TEXT`); +} catch {} +try { + db.run(`ALTER TABLE sessions ADD COLUMN ip_address TEXT`); +} catch {} + +db.run(` + CREATE TABLE IF NOT EXISTS permissions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + resource_type TEXT NOT NULL, + resource_id TEXT, + permission TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(user_id, resource_type, resource_id, permission) + ) +`); + +// Types +export interface User { + id: number; + username: string; + password_hash: string; + is_admin: boolean; + is_guest: boolean; + created_at: number; +} + +export interface Session { + token: string; + user_id: number; + expires_at: number; + created_at: number; + user_agent: string | null; + ip_address: string | null; +} + +export interface Permission { + id: number; + user_id: number; + resource_type: string; + resource_id: string | null; + permission: string; +} + +export interface Track { + id: string; + title: string | null; + artist: string | null; + album: string | null; + duration: number; + size: number; + created_at: number; +} + +// User functions +export async function createUser(username: string, password: string): Promise { + const password_hash = await Bun.password.hash(password); + + // First user becomes admin + const userCount = db.query("SELECT COUNT(*) as count FROM users WHERE is_guest = 0").get() as { count: number }; + const is_admin = userCount.count === 0 ? 1 : 0; + + const result = db.query( + "INSERT INTO users (username, password_hash, is_admin, is_guest) VALUES (?, ?, ?, 0) RETURNING *" + ).get(username, password_hash, is_admin) as any; + + return { ...result, is_admin: !!result.is_admin, is_guest: false }; +} + +export function createGuestUser(ipAddress: string, userAgent: string): User { + const guestId = crypto.randomUUID().slice(0, 8); + const username = `guest_${guestId}`; + const password_hash = ""; // No password for guests + + const result = db.query( + "INSERT INTO users (username, password_hash, is_admin, is_guest) VALUES (?, ?, 0, 1) RETURNING *" + ).get(username, password_hash) as any; + + return { ...result, is_admin: false, is_guest: true }; +} + +export function findUserById(id: number): User | null { + const result = db.query("SELECT * FROM users WHERE id = ?").get(id) as any; + if (!result) return null; + return { ...result, is_admin: !!result.is_admin, is_guest: !!result.is_guest }; +} + +export function findUserByUsername(username: string): User | null { + const result = db.query("SELECT * FROM users WHERE username = ?").get(username) as any; + if (!result) return null; + return { ...result, is_admin: !!result.is_admin, is_guest: !!result.is_guest }; +} + +export async function validatePassword(user: User, password: string): Promise { + return Bun.password.verify(password, user.password_hash); +} + +// Session functions +export function createSession(userId: number, userAgent?: string, ipAddress?: string, isGuest: boolean = false): string { + const token = crypto.randomUUID(); + const expirySeconds = isGuest + ? GUEST_SESSION_EXPIRY_HOURS * 60 * 60 + : SESSION_EXPIRY_DAYS * 24 * 60 * 60; + const expires_at = Math.floor(Date.now() / 1000) + expirySeconds; + + db.query("INSERT INTO sessions (token, user_id, expires_at, user_agent, ip_address) VALUES (?, ?, ?, ?, ?)") + .run(token, userId, expires_at, userAgent ?? null, ipAddress ?? null); + + return token; +} + +export function createGuestSession(userAgent?: string, ipAddress?: string): { user: User, token: string } { + const user = createGuestUser(ipAddress ?? "unknown", userAgent ?? "unknown"); + const token = createSession(user.id, userAgent, ipAddress, true); + return { user, token }; +} + +export function validateSession(token: string, currentUserAgent?: string, currentIpAddress?: string): User | null { + const now = Math.floor(Date.now() / 1000); + const session = db.query( + "SELECT * FROM sessions WHERE token = ? AND expires_at > ?" + ).get(token, now) as Session | null; + + if (!session) return null; + + const user = findUserById(session.user_id); + if (!user) return null; + + // Invalidate if BOTH ip and user agent changed (potential session hijack) + if (currentUserAgent && currentIpAddress && session.user_agent && session.ip_address) { + const ipChanged = session.ip_address !== currentIpAddress; + const uaChanged = session.user_agent !== currentUserAgent; + if (ipChanged && uaChanged) { + console.log(`[AUTH] Session invalidated (ip+ua changed): session=${token} old_ip=${session.ip_address} new_ip=${currentIpAddress}`); + deleteSession(token); + return null; + } + } + + // Sliding expiration - extend on each use + const expirySeconds = user.is_guest + ? GUEST_SESSION_EXPIRY_HOURS * 60 * 60 + : SESSION_EXPIRY_DAYS * 24 * 60 * 60; + const newExpiry = now + expirySeconds; + db.query("UPDATE sessions SET expires_at = ? WHERE token = ?").run(newExpiry, token); + + return findUserById(session.user_id); +} + +export function deleteSession(token: string): void { + db.query("DELETE FROM sessions WHERE token = ?").run(token); +} + +export function deleteExpiredSessions(): void { + const now = Math.floor(Date.now() / 1000); + db.query("DELETE FROM sessions WHERE expires_at <= ?").run(now); +} + +// Permission functions +export function hasPermission( + userId: number, + resourceType: string, + resourceId: string | null, + permission: string +): boolean { + const user = findUserById(userId); + if (!user) return false; + if (user.is_admin) return true; + + const result = db.query(` + SELECT 1 FROM permissions + WHERE user_id = ? + AND resource_type = ? + AND (resource_id = ? OR resource_id IS NULL) + AND permission = ? + LIMIT 1 + `).get(userId, resourceType, resourceId, permission); + + return !!result; +} + +export function grantPermission( + userId: number, + resourceType: string, + resourceId: string | null, + permission: string +): void { + db.query(` + INSERT OR IGNORE INTO permissions (user_id, resource_type, resource_id, permission) + VALUES (?, ?, ?, ?) + `).run(userId, resourceType, resourceId, permission); +} + +export function revokePermission( + userId: number, + resourceType: string, + resourceId: string | null, + permission: string +): void { + db.query(` + DELETE FROM permissions + WHERE user_id = ? AND resource_type = ? AND resource_id IS ? AND permission = ? + `).run(userId, resourceType, resourceId, permission); +} + +export function getUserPermissions(userId: number): Permission[] { + return db.query("SELECT * FROM permissions WHERE user_id = ?").all(userId) as Permission[]; +} + +export function getAllUsers(): Omit[] { + const users = db.query("SELECT id, username, is_admin, is_guest, created_at FROM users WHERE is_guest = 0").all() as any[]; + return users.map(u => ({ ...u, is_admin: !!u.is_admin, is_guest: false })); +} + +export function getUserSessions(userId: number): Omit[] { + return db.query( + "SELECT user_id, expires_at, created_at, user_agent, ip_address FROM sessions WHERE user_id = ? AND expires_at > ?" + ).all(userId, Math.floor(Date.now() / 1000)) as Omit[]; +} + +// Cleanup expired sessions periodically +setInterval(() => deleteExpiredSessions(), 60 * 60 * 1000); // Every hour + +// Channel tables +db.run(` + CREATE TABLE IF NOT EXISTS channels ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT DEFAULT '', + created_by INTEGER, + is_default INTEGER DEFAULT 0, + current_index INTEGER DEFAULT 0, + started_at INTEGER DEFAULT (unixepoch() * 1000), + paused INTEGER DEFAULT 0, + paused_at REAL DEFAULT 0, + playback_mode TEXT DEFAULT 'repeat-all', + created_at INTEGER DEFAULT (unixepoch()), + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL + ) +`); + +db.run(` + CREATE TABLE IF NOT EXISTS channel_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel_id TEXT NOT NULL, + track_id TEXT NOT NULL, + position INTEGER NOT NULL, + FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE, + UNIQUE(channel_id, position) + ) +`); + +// Create index for faster queue lookups +db.run(`CREATE INDEX IF NOT EXISTS idx_channel_queue_channel ON channel_queue(channel_id)`); + +// Migration: add playback_mode column to channels +try { + db.run(`ALTER TABLE channels ADD COLUMN playback_mode TEXT DEFAULT 'repeat-all'`); +} catch {} + +// Channel types +export interface ChannelRow { + id: string; + name: string; + description: string; + created_by: number | null; + is_default: number; + current_index: number; + started_at: number; + paused: number; + paused_at: number; + playback_mode: string; + created_at: number; +} + +export interface ChannelQueueRow { + id: number; + channel_id: string; + track_id: string; + position: number; +} + +// Channel CRUD functions +export function saveChannel(channel: { + id: string; + name: string; + description: string; + createdBy: number | null; + isDefault: boolean; + currentIndex: number; + startedAt: number; + paused: boolean; + pausedAt: number; + playbackMode: string; +}): void { + db.query(` + INSERT INTO channels (id, name, description, created_by, is_default, current_index, started_at, paused, paused_at, playback_mode) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + description = excluded.description, + current_index = excluded.current_index, + started_at = excluded.started_at, + paused = excluded.paused, + paused_at = excluded.paused_at, + playback_mode = excluded.playback_mode + `).run( + channel.id, + channel.name, + channel.description, + channel.createdBy, + channel.isDefault ? 1 : 0, + channel.currentIndex, + channel.startedAt, + channel.paused ? 1 : 0, + channel.pausedAt, + channel.playbackMode + ); +} + +export function updateChannelState(channelId: string, state: { + currentIndex: number; + startedAt: number; + paused: boolean; + pausedAt: number; + playbackMode: string; +}): void { + db.query(` + UPDATE channels + SET current_index = ?, started_at = ?, paused = ?, paused_at = ?, playback_mode = ? + WHERE id = ? + `).run(state.currentIndex, state.startedAt, state.paused ? 1 : 0, state.pausedAt, state.playbackMode, channelId); +} + +export function loadChannel(id: string): ChannelRow | null { + return db.query("SELECT * FROM channels WHERE id = ?").get(id) as ChannelRow | null; +} + +export function loadAllChannels(): ChannelRow[] { + return db.query("SELECT * FROM channels").all() as ChannelRow[]; +} + +export function deleteChannelFromDb(id: string): void { + db.query("DELETE FROM channels WHERE id = ?").run(id); +} + +// Queue persistence functions +export function saveChannelQueue(channelId: string, trackIds: string[]): void { + db.query("BEGIN").run(); + try { + db.query("DELETE FROM channel_queue WHERE channel_id = ?").run(channelId); + + const insert = db.query( + "INSERT INTO channel_queue (channel_id, track_id, position) VALUES (?, ?, ?)" + ); + for (let i = 0; i < trackIds.length; i++) { + insert.run(channelId, trackIds[i], i); + } + db.query("COMMIT").run(); + } catch (e) { + db.query("ROLLBACK").run(); + throw e; + } +} + +export function loadChannelQueue(channelId: string): string[] { + const rows = db.query( + "SELECT track_id FROM channel_queue WHERE channel_id = ? ORDER BY position" + ).all(channelId) as { track_id: string }[]; + return rows.map(r => r.track_id); +} + +export function removeTrackFromQueues(trackId: string): void { + db.query("DELETE FROM channel_queue WHERE track_id = ?").run(trackId); +} diff --git a/library.ts b/library.ts new file mode 100644 index 0000000..8be8e32 --- /dev/null +++ b/library.ts @@ -0,0 +1,464 @@ +import { Database } from "bun:sqlite"; +import { createHash } from "crypto"; +import { watch, type FSWatcher } from "fs"; +import { readdir, stat } from "fs/promises"; +import { join, relative } from "path"; +import { parseFile } from "music-metadata"; +import { type Track } from "./db"; + +const HASH_CHUNK_SIZE = 64 * 1024; // 64KB +const AUDIO_EXTENSIONS = new Set([".mp3", ".ogg", ".flac", ".wav", ".m4a", ".aac", ".opus", ".wma", ".mp4"]); + +export interface LibraryTrack extends Track { + filename: string; + filepath: string; + available: boolean; +} + +interface CacheEntry { + path: string; + track_id: string; + size: number; + mtime_ms: number; + cached_at: number; +} + +type LibraryEventType = "added" | "removed" | "changed"; +type LibraryEventCallback = (track: LibraryTrack) => void; + +export class Library { + private cacheDb: Database; + private musicDir: string; + private trackMap = new Map(); // trackId -> filepath + private trackInfo = new Map(); // trackId -> full info + private watcher: FSWatcher | null = null; + private eventListeners = new Map>(); + private pendingFiles = new Map(); // filepath -> debounce timer + + // Scan progress tracking + private _scanProgress = { scanning: false, processed: 0, total: 0 }; + private scanCompleteCallbacks: Set<() => void> = new Set(); + + get scanProgress() { + return { ...this._scanProgress }; + } + + constructor(musicDir: string, cacheDbPath: string = "./library_cache.db") { + this.musicDir = musicDir; + this.cacheDb = new Database(cacheDbPath); + this.cacheDb.run("PRAGMA journal_mode = WAL"); + this.initCacheDb(); + } + + private initCacheDb(): void { + this.cacheDb.run(` + CREATE TABLE IF NOT EXISTS file_cache ( + path TEXT PRIMARY KEY, + track_id TEXT NOT NULL, + size INTEGER NOT NULL, + mtime_ms INTEGER NOT NULL, + cached_at INTEGER DEFAULT (unixepoch()) + ) + `); + this.cacheDb.run(`CREATE INDEX IF NOT EXISTS idx_file_cache_track ON file_cache(track_id)`); + + // Tracks table - stores metadata for each unique track + this.cacheDb.run(` + CREATE TABLE IF NOT EXISTS tracks ( + id TEXT PRIMARY KEY, + title TEXT, + artist TEXT, + album TEXT, + duration REAL NOT NULL, + size INTEGER NOT NULL, + created_at INTEGER DEFAULT (unixepoch()) + ) + `); + + // Library activity log + this.cacheDb.run(` + CREATE TABLE IF NOT EXISTS library_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp INTEGER DEFAULT (unixepoch()), + action TEXT NOT NULL, + track_id TEXT, + filename TEXT, + title TEXT, + user_id INTEGER, + username TEXT + ) + `); + this.cacheDb.run(`CREATE INDEX IF NOT EXISTS idx_library_log_time ON library_log(timestamp DESC)`); + } + + logActivity(action: string, track: { id?: string; filename?: string; title?: string | null }, user?: { id: number; username: string } | null): void { + this.cacheDb.query(` + INSERT INTO library_log (action, track_id, filename, title, user_id, username) + VALUES (?, ?, ?, ?, ?, ?) + `).run(action, track.id || null, track.filename || null, track.title || null, user?.id || null, user?.username || null); + } + + getActivityLog(limit: number = 100): Array<{ id: number; timestamp: number; action: string; track_id: string | null; filename: string | null; title: string | null; user_id: number | null; username: string | null }> { + return this.cacheDb.query(`SELECT * FROM library_log ORDER BY timestamp DESC LIMIT ?`).all(limit) as any; + } + + private upsertTrack(track: { id: string; title: string | null; artist: string | null; album: string | null; duration: number; size: number }): void { + this.cacheDb.query(` + INSERT INTO tracks (id, title, artist, album, duration, size) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + title = COALESCE(excluded.title, title), + artist = COALESCE(excluded.artist, artist), + album = COALESCE(excluded.album, album) + `).run(track.id, track.title, track.artist, track.album, track.duration, track.size); + } + + async computeTrackId(filePath: string): Promise { + const file = Bun.file(filePath); + const size = file.size; + + // Read first 64KB + const chunk = await file.slice(0, HASH_CHUNK_SIZE).arrayBuffer(); + + // Get duration from metadata + let duration = 0; + try { + const metadata = await parseFile(filePath, { duration: true }); + duration = metadata.format.duration || 0; + } catch { + // If metadata parsing fails, use size only + } + + // Hash: size + duration + first 64KB + const hash = createHash("sha256"); + hash.update(`${size}:${duration.toFixed(3)}:`); + hash.update(new Uint8Array(chunk)); + + return "sha256:" + hash.digest("hex").substring(0, 32); + } + + private getCacheEntry(relativePath: string): CacheEntry | null { + return this.cacheDb.query("SELECT * FROM file_cache WHERE path = ?").get(relativePath) as CacheEntry | null; + } + + private setCacheEntry(relativePath: string, trackId: string, size: number, mtimeMs: number): void { + this.cacheDb.query(` + INSERT OR REPLACE INTO file_cache (path, track_id, size, mtime_ms, cached_at) + VALUES (?, ?, ?, ?, unixepoch()) + `).run(relativePath, trackId, size, mtimeMs); + } + + private removeCacheEntry(relativePath: string): void { + this.cacheDb.query("DELETE FROM file_cache WHERE path = ?").run(relativePath); + } + + private isAudioFile(filename: string): boolean { + const ext = filename.substring(filename.lastIndexOf(".")).toLowerCase(); + return AUDIO_EXTENSIONS.has(ext); + } + + private async processFile(filePath: string): Promise { + const relativePath = relative(this.musicDir, filePath); + const filename = filePath.substring(filePath.lastIndexOf("/") + 1).replace(/\\/g, "/").split("/").pop() || filePath; + + try { + const fileStat = await stat(filePath); + const size = fileStat.size; + const mtimeMs = fileStat.mtimeMs; + + // Check cache + const cached = this.getCacheEntry(relativePath); + let trackId: string; + + if (cached && cached.size === size && cached.mtime_ms === Math.floor(mtimeMs)) { + trackId = cached.track_id; + } else { + // Compute new hash + trackId = await this.computeTrackId(filePath); + this.setCacheEntry(relativePath, trackId, size, Math.floor(mtimeMs)); + } + + // Get metadata + let title = filename.replace(/\.[^.]+$/, ""); + let artist: string | null = null; + let album: string | null = null; + let duration = 0; + + try { + const metadata = await parseFile(filePath, { duration: true }); + title = metadata.common.title || title; + artist = metadata.common.artist || null; + album = metadata.common.album || null; + duration = metadata.format.duration || 0; + } catch { + // Use defaults + } + + const track: LibraryTrack = { + id: trackId, + filename, + filepath: filePath, + title, + artist, + album, + duration, + size, + available: true, + created_at: Math.floor(Date.now() / 1000), + }; + + // Upsert to cache database + this.upsertTrack({ + id: trackId, + title, + artist, + album, + duration, + size, + }); + + return track; + } catch (e) { + console.error(`[Library] Failed to process ${filePath}:`, e); + return null; + } + } + + async scan(): Promise { + console.log(`[Library] Quick loading from cache...`); + + // Single query joining file_cache and tracks - all in cacheDb now + const cachedTracks = this.cacheDb.query(` + SELECT fc.path, fc.track_id, t.title, t.artist, t.album, t.duration + FROM file_cache fc + LEFT JOIN tracks t ON fc.track_id = t.id + `).all() as Array<{ + path: string; + track_id: string; + title: string | null; + artist: string | null; + album: string | null; + duration: number | null; + }>; + + for (const row of cachedTracks) { + const fullPath = join(this.musicDir, row.path); + const filename = row.path.split(/[/\\]/).pop() || row.path; + + const track: LibraryTrack = { + id: row.track_id, + filename, + filepath: fullPath, + title: row.title || filename.replace(/\.[^.]+$/, ""), + artist: row.artist || null, + album: row.album || null, + duration: row.duration || 0, + available: true, + }; + + this.trackMap.set(track.id, fullPath); + this.trackInfo.set(track.id, track); + } + + console.log(`[Library] Quick loaded ${cachedTracks.length} tracks from cache`); + + // Start background scan for new/changed files + this.startBackgroundScan(); + } + + private async startBackgroundScan(): Promise { + console.log(`[Library] Starting background scan...`); + const startTime = Date.now(); + let processed = 0; + let skipped = 0; + const BATCH_SIZE = 10; + const BATCH_DELAY_MS = 50; // Pause between batches to not block + + const filesToProcess: string[] = []; + + // Collect all files first (fast operation) + const collectFiles = async (dir: string) => { + try { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + await collectFiles(fullPath); + } else if (entry.isFile() && this.isAudioFile(entry.name)) { + filesToProcess.push(fullPath); + } + } + } catch (e) { + console.error(`[Library] Error reading directory ${dir}:`, e); + } + }; + + await collectFiles(this.musicDir); + console.log(`[Library] Found ${filesToProcess.length} audio files to check`); + + // Initialize scan progress + this._scanProgress = { scanning: true, processed: 0, total: filesToProcess.length }; + + // Process in batches with delays + for (let i = 0; i < filesToProcess.length; i += BATCH_SIZE) { + const batch = filesToProcess.slice(i, i + BATCH_SIZE); + + for (const fullPath of batch) { + const relativePath = relative(this.musicDir, fullPath); + const cacheEntry = this.getCacheEntry(relativePath); + + // Check if already loaded and cache is valid + if (cacheEntry && this.trackInfo.has(cacheEntry.track_id)) { + try { + const fileStat = await stat(fullPath); + if (cacheEntry.size === fileStat.size && cacheEntry.mtime_ms === Math.floor(fileStat.mtimeMs)) { + skipped++; + this._scanProgress.processed++; + continue; + } + } catch {} + } + + // Need to process this file + const track = await this.processFile(fullPath); + if (track) { + const isNew = !this.trackInfo.has(track.id); + this.trackMap.set(track.id, fullPath); + this.trackInfo.set(track.id, track); + processed++; + + if (isNew) { + this.emit("added", track); + } + } + this._scanProgress.processed++; + } + + // Yield to other operations + if (i + BATCH_SIZE < filesToProcess.length) { + await new Promise(resolve => setTimeout(resolve, BATCH_DELAY_MS)); + } + + // Progress log every 100 files + if ((i + BATCH_SIZE) % 100 === 0) { + console.log(`[Library] Background scan progress: ${Math.min(i + BATCH_SIZE, filesToProcess.length)}/${filesToProcess.length}`); + } + } + + this._scanProgress = { scanning: false, processed: filesToProcess.length, total: filesToProcess.length }; + const elapsed = Date.now() - startTime; + console.log(`[Library] Background scan complete: ${processed} new/updated, ${skipped} unchanged in ${elapsed}ms`); + + // Notify listeners that scan is complete + this.scanCompleteCallbacks.forEach(cb => cb()); + } + + onScanComplete(callback: () => void): void { + this.scanCompleteCallbacks.add(callback); + } + + startWatching(): void { + if (this.watcher) return; + + console.log(`[Library] Watching ${this.musicDir} for changes...`); + + this.watcher = watch(this.musicDir, { recursive: true }, async (event, filename) => { + if (!filename) return; + + // Normalize path separators + const normalizedFilename = filename.replace(/\\/g, "/"); + if (!this.isAudioFile(normalizedFilename)) return; + + const fullPath = join(this.musicDir, filename); + + // Debounce: wait 5 seconds after last change before processing + const existing = this.pendingFiles.get(fullPath); + if (existing) clearTimeout(existing); + + this.pendingFiles.set(fullPath, setTimeout(async () => { + this.pendingFiles.delete(fullPath); + await this.processFileChange(fullPath); + }, 5000)); + }); + } + + private async processFileChange(fullPath: string): Promise { + try { + const exists = await Bun.file(fullPath).exists(); + + if (exists) { + // File added or modified + const track = await this.processFile(fullPath); + if (track) { + const wasNew = !this.trackMap.has(track.id); + this.trackMap.set(track.id, fullPath); + this.trackInfo.set(track.id, track); + this.emit(wasNew ? "added" : "changed", track); + console.log(`[Library] ${wasNew ? "Added" : "Updated"}: ${track.title}`); + } + } else { + // File deleted + const relativePath = relative(this.musicDir, fullPath); + const cacheEntry = this.getCacheEntry(relativePath); + + if (cacheEntry) { + const track = this.trackInfo.get(cacheEntry.track_id); + if (track) { + track.available = false; + this.trackMap.delete(cacheEntry.track_id); + this.emit("removed", track); + console.log(`[Library] Removed: ${track.title}`); + } + this.removeCacheEntry(relativePath); + } + } + } catch (e) { + console.error(`[Library] Watch error for ${fullPath}:`, e); + } + } + + stopWatching(): void { + if (this.watcher) { + this.watcher.close(); + this.watcher = null; + } + } + + // Event handling + on(event: LibraryEventType, callback: LibraryEventCallback): void { + if (!this.eventListeners.has(event)) { + this.eventListeners.set(event, new Set()); + } + this.eventListeners.get(event)!.add(callback); + } + + off(event: LibraryEventType, callback: LibraryEventCallback): void { + this.eventListeners.get(event)?.delete(callback); + } + + private emit(event: LibraryEventType, track: LibraryTrack): void { + this.eventListeners.get(event)?.forEach((cb) => cb(track)); + } + + // Accessors + getTrack(id: string): LibraryTrack | null { + return this.trackInfo.get(id) || null; + } + + getFilePath(id: string): string | null { + return this.trackMap.get(id) || null; + } + + getAllTracks(): LibraryTrack[] { + return Array.from(this.trackInfo.values()).filter((t) => t.available); + } + + getTrackCount(): number { + return this.trackMap.size; + } + + // Check if a track ID is available (file exists) + isAvailable(id: string): boolean { + return this.trackMap.has(id); + } +} diff --git a/music/music_goes_here.txt b/music/music_goes_here.txt new file mode 100644 index 0000000..e69de29 diff --git a/package.json b/package.json new file mode 100644 index 0000000..6db1b25 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "blastoise", + "version": "1.0.0", + "scripts": { + "start": "bun run server.ts" + }, + "dependencies": { + "music-metadata": "^11.11.2" + }, + "private": true, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + } +} diff --git a/public/audioCache.js b/public/audioCache.js new file mode 100644 index 0000000..078fc66 --- /dev/null +++ b/public/audioCache.js @@ -0,0 +1,264 @@ +// MusicRoom - Audio Cache module +// Track caching, downloading, and prefetching + +(function() { + const M = window.MusicRoom; + + // Load stream-only preference from localStorage + M.streamOnly = localStorage.getItem("blastoise_streamOnly") === "true"; + + // Toggle stream-only mode + M.setStreamOnly = function(enabled) { + M.streamOnly = enabled; + localStorage.setItem("blastoise_streamOnly", enabled ? "true" : "false"); + M.showToast(enabled ? "Stream-only mode enabled" : "Caching enabled"); + }; + + // Check cache size and prune if over limit + M.checkCacheSize = async function() { + const stats = await TrackStorage.getStats(); + if (stats.totalSize > M.cacheLimit) { + console.log(`[Cache] Size ${(stats.totalSize / 1024 / 1024).toFixed(1)}MB exceeds limit ${(M.cacheLimit / 1024 / 1024).toFixed(0)}MB, pruning...`); + await M.pruneCache(stats.totalSize - M.cacheLimit + (10 * 1024 * 1024)); // Free extra 10MB + } + }; + + // Remove oldest cached tracks to free up space + M.pruneCache = async function(bytesToFree) { + // Get all cached tracks with metadata + const allKeys = await TrackStorage.list(); + if (allKeys.length === 0) return; + + // Get track sizes (we need to fetch each to know size) + const trackSizes = []; + for (const key of allKeys) { + const cached = await TrackStorage.get(key); + if (cached && cached.blob) { + trackSizes.push({ id: key, size: cached.blob.size }); + } + } + + // Sort by... we don't have timestamps, so just remove from start + let freed = 0; + for (const { id, size } of trackSizes) { + if (freed >= bytesToFree) break; + await TrackStorage.remove(id); + M.cachedTracks.delete(id); + freed += size; + console.log(`[Cache] Pruned: ${id.slice(0, 16)}... (${(size / 1024 / 1024).toFixed(1)}MB)`); + } + + M.showToast(`Freed ${(freed / 1024 / 1024).toFixed(0)}MB cache space`); + M.renderQueue(); + M.renderLibrary(); + }; + + // Get or create cache for a track + M.getTrackCache = function(trackId) { + if (!trackId) return new Set(); + if (!M.trackCaches.has(trackId)) { + M.trackCaches.set(trackId, new Set()); + } + return M.trackCaches.get(trackId); + }; + + // Get track URL - prefers cached blob, falls back to API + M.getTrackUrl = function(trackId) { + return M.trackBlobs.get(trackId) || "/api/tracks/" + encodeURIComponent(trackId); + }; + + // Load a track blob from storage or fetch from server + M.loadTrackBlob = async function(trackId) { + // Check if already in memory + if (M.trackBlobs.has(trackId)) { + return M.trackBlobs.get(trackId); + } + + // Check persistent storage + const cached = await TrackStorage.get(trackId); + if (cached) { + const blobUrl = URL.createObjectURL(cached.blob); + M.trackBlobs.set(trackId, blobUrl); + // Mark all segments as cached + const trackCache = M.getTrackCache(trackId); + for (let i = 0; i < M.SEGMENTS; i++) trackCache.add(i); + M.bulkDownloadStarted.set(trackId, true); + // Update cache status indicator + if (!M.cachedTracks.has(trackId)) { + M.cachedTracks.add(trackId); + M.renderQueue(); + M.renderLibrary(); + } + return blobUrl; + } + + return null; + }; + + // Check if track has all segments and trigger full cache if so + M.checkAndCacheComplete = function(trackId) { + if (!trackId || M.cachedTracks.has(trackId)) return; + + const trackCache = M.trackCaches.get(trackId); + if (trackCache && trackCache.size >= M.SEGMENTS) { + console.log("[Cache] Track has all segments, triggering full cache:", trackId.slice(0, 16) + "..."); + M.downloadAndCacheTrack(trackId); + } + }; + + // Download and cache a full track + M.downloadAndCacheTrack = async function(trackId) { + if (M.bulkDownloadStarted.get(trackId)) return M.trackBlobs.get(trackId); + M.bulkDownloadStarted.set(trackId, true); + + try { + const startTime = performance.now(); + const res = await fetch("/api/tracks/" + encodeURIComponent(trackId)); + const data = await res.arrayBuffer(); + const elapsed = (performance.now() - startTime) / 1000; + + // Mark all segments as cached + const trackCache = M.getTrackCache(trackId); + for (let i = 0; i < M.SEGMENTS; i++) trackCache.add(i); + + // Create blob and URL + const contentType = res.headers.get("Content-Type") || "audio/mpeg"; + const blob = new Blob([data], { type: contentType }); + const blobUrl = URL.createObjectURL(blob); + M.trackBlobs.set(trackId, blobUrl); + + // Persist to storage (handle quota errors gracefully) + try { + await TrackStorage.set(trackId, blob, contentType); + M.cachedTracks.add(trackId); + // Check if we need to prune old tracks + M.checkCacheSize(); + } catch (e) { + if (e.name === 'QuotaExceededError') { + M.showToast("Cache full - track will stream only", "warning"); + } else { + console.warn("[Cache] Storage error:", e); + } + } + + // Update cache status and re-render lists + console.log("[Cache] Track cached:", trackId.slice(0, 16) + "...", "| size:", (data.byteLength / 1024 / 1024).toFixed(2) + "MB"); + M.renderQueue(); + M.renderLibrary(); + + // Update download speed + if (elapsed > 0 && data.byteLength > 0) { + M.recentDownloads.push(data.byteLength / elapsed); + if (M.recentDownloads.length > 5) M.recentDownloads.shift(); + M.downloadSpeed = M.recentDownloads.reduce((a, b) => a + b, 0) / M.recentDownloads.length; + } + + return blobUrl; + } catch (e) { + M.bulkDownloadStarted.set(trackId, false); + return null; + } + }; + + // Fetch a single segment with range request + async function fetchSegment(i, segStart, segEnd) { + const trackId = M.currentTrackId; + const trackCache = M.getTrackCache(trackId); + if (M.loadingSegments.has(i) || trackCache.has(i)) return; + M.loadingSegments.add(i); + try { + const byteStart = Math.floor(segStart * M.audioBytesPerSecond); + const byteEnd = Math.floor(segEnd * M.audioBytesPerSecond); + const startTime = performance.now(); + const res = await fetch("/api/tracks/" + encodeURIComponent(trackId), { + headers: { "Range": `bytes=${byteStart}-${byteEnd}` } + }); + const data = await res.arrayBuffer(); + const elapsed = (performance.now() - startTime) / 1000; + + // Mark segment as cached + trackCache.add(i); + + // Check if all segments are now cached - if so, trigger full cache + if (trackCache.size >= M.SEGMENTS && !M.cachedTracks.has(trackId)) { + console.log("[Cache] All segments complete for:", trackId.slice(0, 16) + "...", "- triggering full cache"); + // Download full track to persist to storage + M.downloadAndCacheTrack(trackId); + } + + // Update audio bitrate estimate + const bytesReceived = data.byteLength; + const durationCovered = segEnd - segStart; + if (bytesReceived > 0 && durationCovered > 0) { + M.audioBytesPerSecond = Math.round(bytesReceived / durationCovered); + } + + // Update download speed (rolling average of last 5 downloads) + if (elapsed > 0 && bytesReceived > 0) { + M.recentDownloads.push(bytesReceived / elapsed); + if (M.recentDownloads.length > 5) M.recentDownloads.shift(); + M.downloadSpeed = M.recentDownloads.reduce((a, b) => a + b, 0) / M.recentDownloads.length; + } + } catch (e) {} + M.loadingSegments.delete(i); + } + + // Background bulk download - runs independently + async function startBulkDownload() { + const trackId = M.currentTrackId; + if (!trackId || M.bulkDownloadStarted.get(trackId)) return; + + const blobUrl = await M.downloadAndCacheTrack(trackId); + + // Switch to blob URL if still on this track + if (blobUrl && M.currentTrackId === trackId && M.audio.src && !M.audio.src.startsWith("blob:")) { + const currentTime = M.audio.currentTime; + const wasPlaying = !M.audio.paused; + M.audio.src = blobUrl; + M.audio.currentTime = currentTime; + if (wasPlaying) M.audio.play().catch(() => {}); + } + } + + // Prefetch missing segments + let prefetching = false; + + M.prefetchSegments = async function() { + if (prefetching || !M.currentTrackId || !M.audio.src || M.serverTrackDuration <= 0) return; + prefetching = true; + + const segmentDur = M.serverTrackDuration / M.SEGMENTS; + const missingSegments = []; + const trackCache = M.getTrackCache(M.currentTrackId); + + // Find all missing segments (not in audio buffer AND not in our cache) + for (let i = 0; i < M.SEGMENTS; i++) { + if (trackCache.has(i) || M.loadingSegments.has(i)) continue; + + const segStart = i * segmentDur; + const segEnd = (i + 1) * segmentDur; + let available = false; + for (let j = 0; j < M.audio.buffered.length; j++) { + if (M.audio.buffered.start(j) <= segStart && M.audio.buffered.end(j) >= segEnd) { + available = true; + break; + } + } + if (!available) { + missingSegments.push({ i, segStart, segEnd }); + } + } + + if (missingSegments.length > 0) { + // Fast connection: also start bulk download in background + if (M.downloadSpeed >= M.FAST_THRESHOLD && !M.bulkDownloadStarted.get(M.currentTrackId)) { + startBulkDownload(); // Fire and forget + } + // Always fetch segments one at a time for seek support + const s = missingSegments[0]; + await fetchSegment(s.i, s.segStart, s.segEnd); + } + + prefetching = false; + }; +})(); diff --git a/public/auth.js b/public/auth.js new file mode 100644 index 0000000..b60216a --- /dev/null +++ b/public/auth.js @@ -0,0 +1,132 @@ +// MusicRoom - Auth module +// Login, signup, logout, guest authentication + +(function() { + const M = window.MusicRoom; + + // Load current user from session + M.loadCurrentUser = async function() { + try { + const res = await fetch("/api/auth/me"); + const data = await res.json(); + M.currentUser = data.user; + if (M.currentUser && data.permissions) { + M.currentUser.permissions = data.permissions; + } + M.updateAuthUI(); + } catch (e) { + M.currentUser = null; + M.updateAuthUI(); + } + }; + + // Tab switching + M.$("#tab-login").onclick = () => { + M.$("#tab-login").classList.add("active"); + M.$("#tab-signup").classList.remove("active"); + M.$("#login-fields").classList.remove("hidden"); + M.$("#signup-fields").classList.add("hidden"); + M.$("#auth-error").textContent = ""; + M.$("#signup-error").textContent = ""; + }; + + M.$("#tab-signup").onclick = () => { + M.$("#tab-signup").classList.add("active"); + M.$("#tab-login").classList.remove("active"); + M.$("#signup-fields").classList.remove("hidden"); + M.$("#login-fields").classList.add("hidden"); + M.$("#auth-error").textContent = ""; + M.$("#signup-error").textContent = ""; + }; + + // Login + M.$("#btn-login").onclick = async () => { + const username = M.$("#login-username").value.trim(); + const password = M.$("#login-password").value; + try { + const res = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }) + }); + const data = await res.json(); + if (!res.ok) { + M.$("#auth-error").textContent = data.error || "Login failed"; + return; + } + M.$("#login-username").value = ""; + M.$("#login-password").value = ""; + await M.loadCurrentUser(); + M.loadStreams(); + } catch (e) { + M.$("#auth-error").textContent = "Login failed"; + } + }; + + // Signup + M.$("#btn-signup").onclick = async () => { + const username = M.$("#signup-username").value.trim(); + const password = M.$("#signup-password").value; + try { + const res = await fetch("/api/auth/signup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }) + }); + const data = await res.json(); + if (!res.ok) { + M.$("#signup-error").textContent = data.error || "Signup failed"; + return; + } + M.$("#signup-username").value = ""; + M.$("#signup-password").value = ""; + await M.loadCurrentUser(); + M.loadStreams(); + } catch (e) { + M.$("#signup-error").textContent = "Signup failed"; + } + }; + + // Guest login + M.$("#btn-guest").onclick = async () => { + try { + const res = await fetch("/api/auth/me"); + const data = await res.json(); + M.currentUser = data.user; + if (M.currentUser && data.permissions) { + M.currentUser.permissions = data.permissions; + } + M.updateAuthUI(); + if (M.currentUser) M.loadStreams(); + } catch (e) { + M.$("#auth-error").textContent = "Failed to continue as guest"; + } + }; + + // Logout + M.$("#btn-logout").onclick = async () => { + const wasGuest = M.currentUser?.isGuest; + await fetch("/api/auth/logout", { method: "POST" }); + M.currentUser = null; + if (wasGuest) { + // Guest clicking "Sign In" - show login panel + M.updateAuthUI(); + } else { + // Regular user logging out - reload to get new guest session + M.updateAuthUI(); + } + }; + + // Kick other clients + M.$("#btn-kick-others").onclick = async () => { + try { + const res = await fetch("/api/auth/kick-others", { method: "POST" }); + const data = await res.json(); + if (res.ok) { + M.showToast(`Kicked ${data.kicked} other client${data.kicked !== 1 ? 's' : ''}`); + } + } catch (e) { + M.showToast("Failed to kick other clients"); + } + }; +})(); diff --git a/public/channelSync.js b/public/channelSync.js new file mode 100644 index 0000000..3d9db5b --- /dev/null +++ b/public/channelSync.js @@ -0,0 +1,414 @@ +// MusicRoom - Channel Sync module +// WebSocket connection and server synchronization + +(function() { + const M = window.MusicRoom; + + // Load available channels and connect to saved or default + M.loadChannels = async function() { + try { + const res = await fetch("/api/channels"); + const channels = await res.json(); + if (channels.length === 0) { + M.setTrackTitle("No channels available"); + return; + } + M.channels = channels; + M.renderChannelList(); + // Try saved channel first, fall back to default + const savedChannelId = localStorage.getItem("blastoise_channel"); + const savedChannel = savedChannelId && channels.find(c => c.id === savedChannelId); + const targetChannel = savedChannel || channels.find(c => c.isDefault) || channels[0]; + M.connectChannel(targetChannel.id); + } catch (e) { + M.setTrackTitle("Server unavailable"); + M.$("#status").textContent = "Local (offline)"; + M.synced = false; + M.updateUI(); + } + }; + + // 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; + } + }; + + // Delete a channel + M.deleteChannel = async function(channelId) { + const channel = M.channels?.find(c => c.id === channelId); + if (!channel) return; + if (channel.isDefault) { + M.showToast("Cannot delete default channel"); + return; + } + if (!confirm(`Delete channel "${channel.name}"?`)) return; + try { + const res = await fetch(`/api/channels/${channelId}`, { method: "DELETE" }); + if (!res.ok) { + const err = await res.json(); + M.showToast(err.error || "Failed to delete channel"); + return; + } + M.showToast(`Channel "${channel.name}" deleted`); + } catch (e) { + M.showToast("Failed to delete channel"); + } + }; + + // New channel creation with slideout input + M.createNewChannel = async function() { + const header = M.$("#channels-panel .panel-header"); + const btn = M.$("#btn-new-channel"); + + // Already in edit mode? + if (header.querySelector(".new-channel-input")) return; + + // Hide button, show input + btn.style.display = "none"; + + const input = document.createElement("input"); + input.type = "text"; + input.className = "new-channel-input"; + input.placeholder = "Channel name..."; + input.maxLength = 64; + + const submit = document.createElement("button"); + submit.className = "btn-submit-channel"; + submit.textContent = "›"; + + header.appendChild(input); + header.appendChild(submit); + input.focus(); + + const cleanup = () => { + input.remove(); + submit.remove(); + btn.style.display = ""; + }; + + const doCreate = async () => { + const name = input.value.trim(); + if (!name) { + cleanup(); + return; + } + try { + await M.createChannel(name); + } catch (e) { + M.showToast("Failed to create channel"); + } + cleanup(); + }; + + submit.onclick = doCreate; + input.onkeydown = (e) => { + if (e.key === "Enter") doCreate(); + if (e.key === "Escape") cleanup(); + }; + input.onblur = (e) => { + if (e.relatedTarget !== submit) cleanup(); + }; + }; + + // New channel button handler + document.addEventListener("DOMContentLoaded", () => { + const btn = M.$("#btn-new-channel"); + if (btn) { + btn.onclick = () => M.createNewChannel(); + } + }); + + // 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" : ""); + const listeners = ch.listeners || []; + // Count occurrences of each user + const counts = {}; + for (const name of listeners) { + counts[name] = (counts[name] || 0) + 1; + } + const listenersHtml = Object.entries(counts).map(([name, count]) => + `
${name}${count > 1 ? ` x${count}` : ""}
` + ).join(""); + + // Show delete button for non-default channels if user is admin or creator + const canDelete = !ch.isDefault && M.currentUser && + (M.currentUser.isAdmin || ch.createdBy === M.currentUser.id); + const deleteBtn = canDelete ? `` : ""; + + div.innerHTML = ` +
+ ${ch.name} + ${deleteBtn} + ${ch.listenerCount} +
+
${listenersHtml}
+ `; + const headerEl = div.querySelector(".channel-header"); + headerEl.querySelector(".channel-name").onclick = () => M.switchChannel(ch.id); + const delBtn = headerEl.querySelector(".btn-delete-channel"); + if (delBtn) { + delBtn.onclick = (e) => { + e.stopPropagation(); + M.deleteChannel(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.onerror = null; + oldWs.close(); + } + M.currentChannelId = id; + localStorage.setItem("blastoise_channel", id); + const proto = location.protocol === "https:" ? "wss:" : "ws:"; + M.ws = new WebSocket(proto + "//" + location.host + "/api/channels/" + id + "/ws"); + + // Track if we've ever connected successfully + let wasConnected = false; + + 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 kick command + if (data.type === "kick") { + M.showToast("Disconnected: " + (data.reason || "Kicked by another session")); + M.wantSync = false; + M.synced = false; + M.audio.pause(); + if (M.ws) { + const oldWs = M.ws; + M.ws = null; + oldWs.onclose = null; + oldWs.onerror = null; + oldWs.close(); + } + M.updateUI(); + return; + } + // Handle library updates + if (data.type === "track_added") { + M.showToast(`"${data.track.title}" is now available`); + if (data.library) { + M.library = data.library; + M.renderLibrary(); + } + return; + } + if (data.type === "track_removed") { + M.showToast(`"${data.track.title}" was removed`); + if (data.library) { + M.library = data.library; + M.renderLibrary(); + } + return; + } + // Handle scan progress + if (data.type === "scan_progress") { + const el = M.$("#scan-progress"); + const wasScanning = !el.classList.contains("complete") && !el.classList.contains("hidden"); + + if (data.scanning) { + el.innerHTML = `Scanning: ${data.processed}/${data.total} files`; + el.classList.remove("hidden"); + el.classList.remove("complete"); + } else { + // Show track count when not scanning + const count = M.library.length; + el.innerHTML = `${count} song${count !== 1 ? 's' : ''} in library`; + el.classList.remove("hidden"); + el.classList.add("complete"); + + // Show toast if scan just finished + if (wasScanning) { + M.showToast("Scanning complete!"); + } + } + return; + } + // Handle server-sent toasts + if (data.type === "toast") { + M.showToast(data.message, data.toastType || "info"); + return; + } + // Handle fetch progress from ytdlp + if (data.type && data.type.startsWith("fetch_")) { + if (M.handleFetchProgress) { + M.handleFetchProgress(data); + } + return; + } + // Normal channel state update + M.handleUpdate(data); + }; + + M.ws.onerror = () => { + console.log("[WS] Connection error"); + }; + + M.ws.onclose = () => { + M.synced = false; + M.ws = null; + M.$("#sync-indicator").classList.add("disconnected"); + M.updateUI(); + // Auto-reconnect if user wants to be synced + // Use faster retry (2s) if never connected, slower (3s) if disconnected after connecting + if (M.wantSync) { + const delay = wasConnected ? 3000 : 2000; + setTimeout(() => M.connectChannel(id), delay); + } + }; + + M.ws.onopen = () => { + wasConnected = true; + M.synced = true; + M.$("#sync-indicator").classList.remove("disconnected"); + M.updateUI(); + }; + }; + + // 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 + }); + + M.$("#channel-name").textContent = data.channelName || ""; + + // Update queue if provided (do this before early return for no track) + if (data.queue) { + M.queue = data.queue; + M.currentIndex = data.currentIndex ?? 0; + M.renderQueue(); + } else if (data.currentIndex !== undefined && data.currentIndex !== M.currentIndex) { + M.currentIndex = data.currentIndex; + M.renderQueue(); + } + + // Update playback mode if provided + if (data.playbackMode && data.playbackMode !== M.playbackMode) { + M.playbackMode = data.playbackMode; + if (M.updateModeButton) M.updateModeButton(); + } + + if (!data.track) { + M.setTrackTitle("No tracks"); + return; + } + M.serverTimestamp = data.currentTimestamp; + M.serverTrackDuration = data.track.duration; + M.lastServerUpdate = Date.now(); + const wasServerPaused = M.serverPaused; + M.serverPaused = data.paused ?? true; + + // Cache track info for local mode - use track.id (content hash) as the identifier + const trackId = data.track.id || data.track.filename; // Fallback for compatibility + const isNewTrack = trackId !== M.currentTrackId; + if (isNewTrack) { + M.currentTrackId = trackId; + M.setTrackTitle(data.track.title); + M.loadingSegments.clear(); + + // Debug: log cache state for this track + const trackCache = M.trackCaches.get(trackId); + console.log("[Playback] Starting track:", data.track.title, { + trackId: trackId, + segments: trackCache ? [...trackCache] : [], + segmentCount: trackCache ? trackCache.size : 0, + inCachedTracks: M.cachedTracks.has(trackId), + bulkStarted: M.bulkDownloadStarted.get(trackId) || false, + hasBlobUrl: M.trackBlobs.has(trackId) + }); + + // Check if this track already has all segments cached + M.checkAndCacheComplete(trackId); + } + + if (M.synced) { + if (!M.serverPaused) { + // Server is playing - ensure we're playing and synced + if (isNewTrack || !M.audio.src) { + // Try cache first + const cachedUrl = await M.loadTrackBlob(M.currentTrackId); + M.audio.src = cachedUrl || M.getTrackUrl(M.currentTrackId); + 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; + } + } + } else { + // Server is paused - ensure we're paused too + if (!M.audio.paused) { + M.audio.pause(); + } + // Sync to paused position + if (isNewTrack || !M.audio.src) { + const cachedUrl = await M.loadTrackBlob(M.currentTrackId); + M.audio.src = cachedUrl || M.getTrackUrl(M.currentTrackId); + M.audio.currentTime = data.currentTimestamp; + } + } + } + M.updateUI(); + }; +})(); diff --git a/public/controls.js b/public/controls.js new file mode 100644 index 0000000..a73e85b --- /dev/null +++ b/public/controls.js @@ -0,0 +1,240 @@ +// MusicRoom - Controls module +// Play, pause, seek, volume, prev/next track + +(function() { + const M = window.MusicRoom; + + // Load saved volume + const savedVolume = localStorage.getItem(M.STORAGE_KEY); + if (savedVolume !== null) { + M.audio.volume = parseFloat(savedVolume); + M.$("#volume").value = savedVolume; + } else { + // No saved volume - sync audio to slider's default value + M.audio.volume = parseFloat(M.$("#volume").value); + } + + // Toggle play/pause + function togglePlayback() { + if (!M.currentTrackId) return; + + if (M.synced) { + if (M.ws && M.ws.readyState === WebSocket.OPEN) { + M.ws.send(JSON.stringify({ action: M.serverPaused ? "unpause" : "pause" })); + } + } else { + if (M.audio.paused) { + if (!M.audio.src) { + M.audio.src = M.getTrackUrl(M.currentTrackId); + M.audio.currentTime = M.localTimestamp; + } + M.audio.play(); + } else { + M.localTimestamp = M.audio.currentTime; + M.audio.pause(); + } + M.updateUI(); + } + } + + // Jump to a specific track index + async function jumpToTrack(index) { + if (M.queue.length === 0) return; + const newIndex = (index + M.queue.length) % M.queue.length; + + 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 }) + }); + if (res.status === 403) M.flashPermissionDenied(); + if (res.status === 400) console.warn("Jump failed: 400 - newIndex:", newIndex, "queue length:", M.queue.length); + } else { + const track = M.queue[newIndex]; + const trackId = track.id || track.filename; + M.currentIndex = newIndex; + M.currentTrackId = trackId; + M.serverTrackDuration = track.duration; + M.setTrackTitle(track.title?.trim() || track.filename?.replace(/\.[^.]+$/, "") || "Unknown"); + M.loadingSegments.clear(); + const cachedUrl = await M.loadTrackBlob(trackId); + M.audio.src = cachedUrl || M.getTrackUrl(trackId); + M.audio.currentTime = 0; + M.localTimestamp = 0; + M.audio.play(); + M.renderQueue(); + } + } + + // Sync toggle + M.$("#btn-sync").onclick = () => { + M.wantSync = !M.wantSync; + if (M.wantSync) { + // User wants to sync - try to connect + if (M.currentChannelId) { + M.connectChannel(M.currentChannelId); + } + } else { + // User wants local mode - disconnect + M.synced = false; + M.localTimestamp = M.audio.currentTime || M.getServerTime(); + if (M.ws) { + const oldWs = M.ws; + M.ws = null; + oldWs.onclose = null; + oldWs.close(); + } + } + M.updateUI(); + }; + + // Play/pause button + M.$("#status-icon").onclick = togglePlayback; + + // Prev/next buttons + M.$("#btn-prev").onclick = () => jumpToTrack(M.currentIndex - 1); + M.$("#btn-next").onclick = () => jumpToTrack(M.currentIndex + 1); + + // Stream-only toggle + const streamBtn = M.$("#btn-stream-only"); + M.updateStreamOnlyButton = function() { + streamBtn.classList.toggle("active", M.streamOnly); + streamBtn.title = M.streamOnly ? "Stream-only mode (click to enable caching)" : "Click to enable stream-only mode (no caching)"; + }; + streamBtn.onclick = () => { + M.setStreamOnly(!M.streamOnly); + M.updateStreamOnlyButton(); + }; + M.updateStreamOnlyButton(); + + // Playback mode button + const modeLabels = { + "once": "once", + "repeat-all": "repeat", + "repeat-one": "single", + "shuffle": "shuffle" + }; + const modeOrder = ["once", "repeat-all", "repeat-one", "shuffle"]; + + M.updateModeButton = function() { + const btn = M.$("#btn-mode"); + btn.textContent = modeLabels[M.playbackMode] || "repeat"; + btn.title = `Playback: ${M.playbackMode}`; + btn.className = "mode-" + M.playbackMode; + }; + + M.$("#btn-mode").onclick = async () => { + if (!M.synced || !M.currentChannelId) { + // Local mode - just cycle through modes + const currentIdx = modeOrder.indexOf(M.playbackMode); + M.playbackMode = modeOrder[(currentIdx + 1) % modeOrder.length]; + M.updateModeButton(); + return; + } + + // Synced mode - send to server + const currentIdx = modeOrder.indexOf(M.playbackMode); + const newMode = modeOrder[(currentIdx + 1) % modeOrder.length]; + const res = await fetch("/api/channels/" + M.currentChannelId + "/mode", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode: newMode }) + }); + if (res.status === 403) M.flashPermissionDenied(); + }; + + M.updateModeButton(); + + // Progress bar seek tooltip + M.$("#progress-container").onmousemove = (e) => { + if (M.serverTrackDuration <= 0) return; + const rect = M.$("#progress-container").getBoundingClientRect(); + const pct = (e.clientX - rect.left) / rect.width; + const hoverTime = pct * M.serverTrackDuration; + const tooltip = M.$("#seek-tooltip"); + tooltip.textContent = M.fmt(hoverTime); + tooltip.style.left = (pct * 100) + "%"; + tooltip.style.display = "block"; + }; + + M.$("#progress-container").onmouseleave = () => { + M.$("#seek-tooltip").style.display = "none"; + }; + + // Progress bar seek + M.$("#progress-container").onclick = (e) => { + const dur = M.synced ? M.serverTrackDuration : (M.audio.duration || M.serverTrackDuration); + if (!M.currentTrackId || dur <= 0) return; + const rect = M.$("#progress-container").getBoundingClientRect(); + const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + const seekTime = pct * dur; + + if (M.synced && M.currentChannelId) { + fetch("/api/channels/" + M.currentChannelId + "/seek", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ timestamp: seekTime }) + }).then(res => { if (res.status === 403) M.flashPermissionDenied(); }); + } else { + if (!M.audio.src) { + M.audio.src = M.getTrackUrl(M.currentTrackId); + } + M.audio.currentTime = seekTime; + M.localTimestamp = seekTime; + } + }; + + // Mute toggle + M.$("#btn-mute").onclick = () => { + if (M.audio.volume > 0) { + M.preMuteVolume = M.audio.volume; + M.audio.volume = 0; + M.$("#volume").value = 0; + } else { + M.audio.volume = M.preMuteVolume; + M.$("#volume").value = M.preMuteVolume; + } + localStorage.setItem(M.STORAGE_KEY, M.audio.volume); + M.updateUI(); + }; + + // Volume slider + M.$("#volume").oninput = (e) => { + M.audio.volume = e.target.value; + localStorage.setItem(M.STORAGE_KEY, e.target.value); + M.updateUI(); + }; + + // Audio element events + M.audio.onplay = () => { M.$("#progress-bar").classList.add("playing"); M.updateUI(); }; + M.audio.onpause = () => { M.$("#progress-bar").classList.remove("playing"); M.updateUI(); }; + + // Track loading state from audio element's progress + M.audio.onprogress = () => { + if (M.serverTrackDuration <= 0) return; + const segmentDur = M.serverTrackDuration / M.SEGMENTS; + M.loadingSegments.clear(); + for (let i = 0; i < M.SEGMENTS; i++) { + const segStart = i * segmentDur; + const segEnd = (i + 1) * segmentDur; + let fullyBuffered = false; + let partiallyBuffered = false; + for (let j = 0; j < M.audio.buffered.length; j++) { + const bufStart = M.audio.buffered.start(j); + const bufEnd = M.audio.buffered.end(j); + if (bufStart <= segStart && bufEnd >= segEnd) { + fullyBuffered = true; + break; + } + // Check if buffer is actively loading into this segment + if (bufEnd > segStart && bufEnd < segEnd && bufStart <= segStart) { + partiallyBuffered = true; + } + } + if (partiallyBuffered && !fullyBuffered) { + M.loadingSegments.add(i); + } + } + }; +})(); diff --git a/public/core.js b/public/core.js new file mode 100644 index 0000000..9de1c45 --- /dev/null +++ b/public/core.js @@ -0,0 +1,67 @@ +// MusicRoom - Core module +// Shared state and namespace setup + +window.MusicRoom = { + // Audio element + audio: new Audio(), + + // WebSocket and channel state + ws: null, + currentChannelId: null, + currentTrackId: null, + currentTitle: null, + serverTimestamp: 0, + serverTrackDuration: 0, + 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? + + // Volume + preMuteVolume: 1, + STORAGE_KEY: "blastoise_volume", + + // Playback state + localTimestamp: 0, + queue: [], + currentIndex: 0, + playbackMode: "repeat-all", + + // User state + currentUser: null, + serverStatus: null, + + // Library (all discovered tracks) + library: [], + + // Caching state + prefetchController: null, + loadingSegments: new Set(), + trackCaches: new Map(), // Map of filename -> Set of cached segment indices + trackBlobs: new Map(), // Map of filename -> Blob URL for fully cached tracks + bulkDownloadStarted: new Map(), + cachedTracks: new Set(), // Set of track IDs that are fully cached locally + streamOnly: false, // When true, skip bulk downloads (still cache played tracks) + cacheLimit: 100 * 1024 * 1024, // 100MB default cache limit + + // Download metrics + audioBytesPerSecond: 20000, // Audio bitrate estimate for range requests + downloadSpeed: 0, // Actual network download speed + recentDownloads: [], // Track recent downloads for speed calculation + + // Constants + SEGMENTS: 20, + FAST_THRESHOLD: 10 * 1024 * 1024, // 10 MB/s + + // UI update tracking (to avoid unnecessary DOM updates) + lastProgressPct: -1, + lastTimeCurrent: "", + lastTimeTotal: "", + lastBufferPct: -1, + lastSpeedText: "" +}; diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..002b1db --- /dev/null +++ b/public/index.html @@ -0,0 +1,156 @@ + + + + + +NeoRose + + + +
+

Blastoise

+ +
+

Sign in to continue

+
+ + +
+
+ + + +
+
+ + +
+ +
+
+
+ +
+
+
+ +
+
+
+

Channels

+ +
+
+
+
+
+ + +
+
+
+
+ + +
+ +
+ + + + +
+
+
+
No active tasks
+
+
+
+
+

Queue

+ +
+
+
+ +
+
+
+
+ Loading... +
+
+
+
+ sync + + + + repeat +
+
0:00/0:00
+
+
+
+
+
+ stream + 🔊 + +
+
+
+
+
+ + +
+ + + + + + + + + + + + + diff --git a/public/init.js b/public/init.js new file mode 100644 index 0000000..9e2c83a --- /dev/null +++ b/public/init.js @@ -0,0 +1,80 @@ +// MusicRoom - Init module +// Application initialization sequence + +(function() { + const M = window.MusicRoom; + + // Fetch server status/config + async function loadServerStatus() { + try { + const res = await fetch("/api/status"); + M.serverStatus = await res.json(); + console.log("Server status:", M.serverStatus); + } catch (e) { + console.warn("Failed to load server status"); + M.serverStatus = null; + } + } + + // Initialize track storage + async function initStorage() { + await TrackStorage.init(); + await M.updateCacheStatus(); + console.log(`TrackStorage: ${M.cachedTracks.size} tracks cached`); + } + + // Setup panel tab switching + function initPanelTabs() { + const tabs = document.querySelectorAll(".panel-tab"); + tabs.forEach(tab => { + tab.onclick = () => { + const tabId = tab.dataset.tab; + const panel = tab.closest("#library-panel, #queue-panel"); + if (!panel) return; + + // Update active tab + panel.querySelectorAll(".panel-tab").forEach(t => t.classList.remove("active")); + tab.classList.add("active"); + + // Update active view + panel.querySelectorAll(".panel-view").forEach(v => v.classList.remove("active")); + const view = panel.querySelector(`#${tabId}-view`); + if (view) view.classList.add("active"); + }; + }); + } + + // Setup history panel handlers + document.addEventListener("DOMContentLoaded", () => { + const btnHistory = M.$("#btn-history"); + const btnClose = M.$("#btn-close-history"); + + if (btnHistory) { + btnHistory.onclick = () => M.toggleToastHistory(); + } + if (btnClose) { + btnClose.onclick = () => M.toggleToastHistory(); + } + + initPanelTabs(); + }); + + // Update UI based on server status + function updateFeatureVisibility() { + const fetchBtn = M.$("#btn-fetch-url"); + if (fetchBtn) { + const ytdlpEnabled = M.serverStatus?.ytdlp?.enabled && M.serverStatus?.ytdlp?.available; + fetchBtn.style.display = ytdlpEnabled ? "" : "none"; + } + } + + // Initialize the application + Promise.all([initStorage(), loadServerStatus()]).then(async () => { + updateFeatureVisibility(); + await M.loadLibrary(); + await M.loadCurrentUser(); + if (M.currentUser) { + M.loadChannels(); + } + }); +})(); diff --git a/public/queue.js b/public/queue.js new file mode 100644 index 0000000..e9da53b --- /dev/null +++ b/public/queue.js @@ -0,0 +1,896 @@ +// MusicRoom - Queue module +// Queue rendering and library display + +(function() { + const M = window.MusicRoom; + + // Selection state for bulk operations + M.selectedQueueIndices = new Set(); + M.selectedLibraryIds = new Set(); + + // Last selected index for shift-select range + let lastSelectedQueueIndex = null; + let lastSelectedLibraryIndex = null; + + // Context menu state + let activeContextMenu = null; + + // Download state - only one at a time + let isDownloading = false; + let exportQueue = []; + let isExporting = false; + + // Download a track to user's device (uses cache if available) + async function downloadTrack(trackId, filename) { + if (isDownloading) { + M.showToast("Download already in progress", "warning"); + return; + } + + isDownloading = true; + M.showToast(`Downloading: ${filename}`); + + try { + let blob = null; + + // Try to get from cache first + if (M.cachedTracks.has(trackId)) { + try { + const cached = await TrackStorage.get(trackId); + if (cached && cached.blob) { + blob = cached.blob; + } + } catch (e) { + console.log("Cache miss, fetching from server"); + } + } + + // Fall back to fetching from server + if (!blob) { + const res = await fetch(`/api/tracks/${encodeURIComponent(trackId)}`); + if (!res.ok) throw new Error(`Server returned ${res.status}`); + blob = await res.blob(); + } + + if (!blob || blob.size === 0) { + throw new Error("Empty blob"); + } + + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + M.showToast(`Downloaded: ${filename}`); + } catch (e) { + console.error("Download error:", e); + M.showToast(`Download failed: ${e.message}`, "error"); + } finally { + isDownloading = false; + } + } + + // Export all cached tracks + M.exportAllCached = async function() { + if (isExporting) { + M.showToast("Export already in progress", "warning"); + return; + } + + // Build list of cached tracks with filenames + const cachedIds = [...M.cachedTracks]; + if (cachedIds.length === 0) { + M.showToast("No cached tracks to export", "warning"); + return; + } + + // Find filenames from library or queue + const trackMap = new Map(); + M.library.forEach(t => { if (t.filename) trackMap.set(t.id, t.filename); }); + M.queue.forEach(t => { if (t.filename && !trackMap.has(t.id)) trackMap.set(t.id, t.filename); }); + + // Only export tracks with known filenames + exportQueue = cachedIds + .filter(id => trackMap.has(id)) + .map(id => ({ id, filename: trackMap.get(id) })); + + const skipped = cachedIds.length - exportQueue.length; + if (exportQueue.length === 0) { + M.showToast("No exportable tracks (filenames unknown)", "warning"); + return; + } + + isExporting = true; + const msg = skipped > 0 + ? `Exporting ${exportQueue.length} tracks (${skipped} skipped - not in library)` + : `Exporting ${exportQueue.length} cached tracks...`; + M.showToast(msg); + + let exported = 0; + for (const { id, filename } of exportQueue) { + if (!isExporting) break; // Allow cancellation + + try { + const cached = await TrackStorage.get(id); + if (cached && cached.blob) { + const url = URL.createObjectURL(cached.blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + exported++; + + // Small delay between downloads to not overwhelm browser + await new Promise(r => setTimeout(r, 500)); + } + } catch (e) { + console.error(`Export error for ${filename}:`, e); + } + } + + isExporting = false; + exportQueue = []; + M.showToast(`Exported ${exported} tracks`); + }; + + M.cancelExport = function() { + if (isExporting) { + isExporting = false; + M.showToast("Export cancelled"); + } + }; + + // Close context menu when clicking elsewhere + document.addEventListener("click", () => { + if (activeContextMenu) { + activeContextMenu.remove(); + activeContextMenu = null; + } + }); + + // Show context menu + function showContextMenu(e, items) { + e.preventDefault(); + if (activeContextMenu) activeContextMenu.remove(); + + const menu = document.createElement("div"); + menu.className = "context-menu"; + + items.forEach(item => { + const el = document.createElement("div"); + el.className = "context-menu-item" + (item.danger ? " danger" : ""); + el.textContent = item.label; + el.onclick = (ev) => { + ev.stopPropagation(); + menu.remove(); + activeContextMenu = null; + item.action(); + }; + menu.appendChild(el); + }); + + document.body.appendChild(menu); + + // Position menu, keep within viewport + let x = e.clientX; + let y = e.clientY; + const rect = menu.getBoundingClientRect(); + if (x + rect.width > window.innerWidth) x = window.innerWidth - rect.width - 5; + if (y + rect.height > window.innerHeight) y = window.innerHeight - rect.height - 5; + menu.style.left = x + "px"; + menu.style.top = y + "px"; + + activeContextMenu = menu; + } + + // Drag state for queue reordering + let draggedIndices = []; + let draggedLibraryIds = []; + let dropTargetIndex = null; + let dragSource = null; // 'queue' or 'library' + + // Insert library tracks into queue at position + async function insertTracksAtPosition(trackIds, position) { + if (!M.currentChannelId || trackIds.length === 0) return; + + // Build new queue with tracks inserted at position + const newQueue = [...M.queue]; + const newTrackIds = [...newQueue.map(t => t.id)]; + newTrackIds.splice(position, 0, ...trackIds); + + const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ set: newTrackIds }) + }); + + if (res.status === 403) M.flashPermissionDenied(); + else if (res.ok) { + M.showToast(`Added ${trackIds.length} track${trackIds.length > 1 ? 's' : ''}`); + M.clearSelections(); + } + } + + // Reorder queue on server + async function reorderQueue(fromIndices, toIndex) { + if (!M.currentChannelId || fromIndices.length === 0) return; + + // Build new queue order + const newQueue = [...M.queue]; + + // Sort indices descending to remove from end first + const sortedIndices = [...fromIndices].sort((a, b) => b - a); + const movedTracks = []; + + // Remove items (in reverse order to preserve indices) + for (const idx of sortedIndices) { + movedTracks.unshift(newQueue.splice(idx, 1)[0]); + } + + // Adjust target index for removed items before it + let adjustedTarget = toIndex; + for (const idx of fromIndices) { + if (idx < toIndex) adjustedTarget--; + } + + // Insert at new position + newQueue.splice(adjustedTarget, 0, ...movedTracks); + + // Send to server + const trackIds = newQueue.map(t => t.id); + const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ set: trackIds }) + }); + + if (res.status === 403) M.flashPermissionDenied(); + else if (res.ok) { + M.clearSelections(); + } + } + + // Toggle selection mode (with optional shift for range select) + M.toggleQueueSelection = function(index, shiftKey = false) { + if (shiftKey && lastSelectedQueueIndex !== null) { + // Range select: select all between last and current + const start = Math.min(lastSelectedQueueIndex, index); + const end = Math.max(lastSelectedQueueIndex, index); + for (let i = start; i <= end; i++) { + M.selectedQueueIndices.add(i); + } + } else { + if (M.selectedQueueIndices.has(index)) { + M.selectedQueueIndices.delete(index); + } else { + M.selectedQueueIndices.add(index); + } + lastSelectedQueueIndex = index; + } + M.renderQueue(); + }; + + M.toggleLibrarySelection = function(index, shiftKey = false) { + if (shiftKey && lastSelectedLibraryIndex !== null) { + // Range select: select all between last and current + const start = Math.min(lastSelectedLibraryIndex, index); + const end = Math.max(lastSelectedLibraryIndex, index); + for (let i = start; i <= end; i++) { + M.selectedLibraryIds.add(M.library[i].id); + } + } else { + const trackId = M.library[index].id; + if (M.selectedLibraryIds.has(trackId)) { + M.selectedLibraryIds.delete(trackId); + } else { + M.selectedLibraryIds.add(trackId); + } + lastSelectedLibraryIndex = index; + } + M.renderLibrary(); + }; + + M.clearSelections = function() { + M.selectedQueueIndices.clear(); + M.selectedLibraryIds.clear(); + lastSelectedQueueIndex = null; + lastSelectedLibraryIndex = null; + M.renderQueue(); + M.renderLibrary(); + }; + + // Update cache status for all tracks + M.updateCacheStatus = async function() { + const cached = await TrackStorage.list(); + + // Migration: remove old filename-based cache entries (keep only sha256: prefixed) + const oldEntries = cached.filter(id => !id.startsWith("sha256:")); + if (oldEntries.length > 0) { + console.log("[Cache] Migrating: removing", oldEntries.length, "old filename-based entries"); + for (const oldId of oldEntries) { + await TrackStorage.remove(oldId); + } + // Re-fetch after cleanup + const updated = await TrackStorage.list(); + M.cachedTracks = new Set(updated); + } else { + M.cachedTracks = new Set(cached); + } + console.log("[Cache] Updated cache status:", M.cachedTracks.size, "tracks cached"); + }; + + // Debug: log cache status for current track + M.debugCacheStatus = function() { + if (!M.currentTrackId) { + console.log("[Cache Debug] No current track"); + return; + } + const trackCache = M.getTrackCache(M.currentTrackId); + const segmentsPct = Math.round((trackCache.size / M.SEGMENTS) * 100); + const inCachedTracks = M.cachedTracks.has(M.currentTrackId); + const hasBlobUrl = M.trackBlobs.has(M.currentTrackId); + const bulkStarted = M.bulkDownloadStarted.get(M.currentTrackId); + + console.log("[Cache Debug]", { + trackId: M.currentTrackId.slice(0, 16) + "...", + segments: `${trackCache.size}/${M.SEGMENTS} (${segmentsPct}%)`, + inCachedTracks, + hasBlobUrl, + bulkStarted, + loadingSegments: [...M.loadingSegments], + cachedTracksSize: M.cachedTracks.size + }); + }; + + // Debug: compare queue track IDs with cached track IDs + M.debugCacheMismatch = function() { + console.log("[Cache Mismatch Debug]"); + console.log("=== Raw State ==="); + console.log("M.cachedTracks:", M.cachedTracks); + console.log("M.trackCaches:", M.trackCaches); + console.log("M.trackBlobs keys:", [...M.trackBlobs.keys()]); + console.log("M.bulkDownloadStarted:", M.bulkDownloadStarted); + console.log("=== Queue Tracks ==="); + M.queue.forEach((t, i) => { + const id = t.id || t.filename; + console.log(` [${i}] ${t.title?.slice(0, 30)} | id: ${id?.slice(0, 12)}... | cached: ${M.cachedTracks.has(id)}`); + }); + console.log("=== Cached Track IDs ==="); + [...M.cachedTracks].forEach(id => { + console.log(` ${id.slice(0, 20)}...`); + }); + }; + + // Debug: detailed info for a specific track + M.debugTrack = function(index) { + const track = M.queue[index]; + if (!track) { + console.log("[Debug] No track at index", index); + return; + } + const id = track.id || track.filename; + console.log("[Debug Track]", { + index, + title: track.title, + id, + idPrefix: id?.slice(0, 16), + inCachedTracks: M.cachedTracks.has(id), + inTrackCaches: M.trackCaches.has(id), + segmentCount: M.trackCaches.get(id)?.size || 0, + inTrackBlobs: M.trackBlobs.has(id), + bulkStarted: M.bulkDownloadStarted.get(id) + }); + }; + + // Clear all caches (for debugging) + M.clearAllCaches = async function() { + await TrackStorage.clear(); + M.cachedTracks.clear(); + M.trackCaches.clear(); + M.trackBlobs.clear(); + M.bulkDownloadStarted.clear(); + M.renderQueue(); + M.renderLibrary(); + console.log("[Cache] All caches cleared. Refresh the page."); + }; + + // Render the current queue + M.renderQueue = function() { + const container = M.$("#queue"); + if (!container) return; + container.innerHTML = ""; + + const canEdit = M.canControl(); + + // Setup container-level drag handlers for dropping from library + if (canEdit) { + container.ondragover = (e) => { + if (dragSource === 'library') { + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + // If no tracks or hovering at bottom, show we can drop + if (M.queue.length === 0) { + container.classList.add("drop-target"); + } + } + }; + + container.ondragleave = (e) => { + // Only remove if leaving the container entirely + if (!container.contains(e.relatedTarget)) { + container.classList.remove("drop-target"); + } + }; + + container.ondrop = (e) => { + container.classList.remove("drop-target"); + // Handle drop on empty queue or at the end + if (dragSource === 'library' && draggedLibraryIds.length > 0) { + e.preventDefault(); + const targetIndex = dropTargetIndex !== null ? dropTargetIndex : M.queue.length; + insertTracksAtPosition(draggedLibraryIds, targetIndex); + draggedLibraryIds = []; + dragSource = null; + dropTargetIndex = null; + } + }; + } + + if (M.queue.length === 0) { + container.innerHTML = '
Queue empty - drag tracks here
'; + M.updateNowPlayingBar(); + return; + } + + // Debug: log first few track cache statuses + if (M.queue.length > 0 && M.cachedTracks.size > 0) { + const sample = M.queue.slice(0, 3).map(t => { + const id = t.id || t.filename; + return { title: t.title?.slice(0, 20), id: id?.slice(0, 12), cached: M.cachedTracks.has(id) }; + }); + console.log("[Queue Render] Sample tracks:", sample, "| cachedTracks sample:", [...M.cachedTracks].slice(0, 3).map(s => s.slice(0, 12))); + } + + M.queue.forEach((track, i) => { + const div = document.createElement("div"); + const trackId = track.id || track.filename; + const isCached = M.cachedTracks.has(trackId); + const isSelected = M.selectedQueueIndices.has(i); + div.className = "track" + (i === M.currentIndex ? " active" : "") + (isCached ? " cached" : " not-cached") + (isSelected ? " selected" : ""); + div.dataset.index = i; + const title = track.title?.trim() || (track.id || track.filename || "Unknown").replace(/\.[^.]+$/, ""); + div.title = title; // Tooltip for full name + + const checkmark = isSelected ? `` : ''; + const trackNum = `${i + 1}.`; + div.innerHTML = `${checkmark}${trackNum}${title}${M.fmt(track.duration)}`; + + // Drag and drop for reordering (if user can edit) + if (canEdit) { + div.draggable = true; + + div.ondragstart = (e) => { + dragSource = 'queue'; + draggedLibraryIds = []; + // If dragging a selected item, drag all selected; otherwise just this one + if (M.selectedQueueIndices.has(i)) { + draggedIndices = [...M.selectedQueueIndices]; + } else { + draggedIndices = [i]; + } + div.classList.add("dragging"); + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", "queue:" + draggedIndices.join(",")); + }; + + div.ondragend = () => { + div.classList.remove("dragging"); + draggedIndices = []; + draggedLibraryIds = []; + dragSource = null; + // Clear all drop indicators + container.querySelectorAll(".drop-above, .drop-below").forEach(el => { + el.classList.remove("drop-above", "drop-below"); + }); + }; + + div.ondragover = (e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + + // Determine if dropping above or below + const rect = div.getBoundingClientRect(); + const midY = rect.top + rect.height / 2; + const isAbove = e.clientY < midY; + + // Clear other indicators + container.querySelectorAll(".drop-above, .drop-below").forEach(el => { + el.classList.remove("drop-above", "drop-below"); + }); + + // Don't show indicator on dragged queue items (for reorder) + if (dragSource === 'queue' && draggedIndices.includes(i)) return; + + div.classList.add(isAbove ? "drop-above" : "drop-below"); + dropTargetIndex = isAbove ? i : i + 1; + }; + + div.ondragleave = () => { + div.classList.remove("drop-above", "drop-below"); + }; + + div.ondrop = (e) => { + e.preventDefault(); + div.classList.remove("drop-above", "drop-below"); + + if (dragSource === 'library' && draggedLibraryIds.length > 0 && dropTargetIndex !== null) { + // Insert library tracks at drop position + insertTracksAtPosition(draggedLibraryIds, dropTargetIndex); + } else if (dragSource === 'queue' && draggedIndices.length > 0 && dropTargetIndex !== null) { + // Reorder queue + const minDragged = Math.min(...draggedIndices); + const maxDragged = Math.max(...draggedIndices); + if (dropTargetIndex < minDragged || dropTargetIndex > maxDragged + 1) { + reorderQueue(draggedIndices, dropTargetIndex); + } + } + + draggedIndices = []; + draggedLibraryIds = []; + dragSource = null; + dropTargetIndex = null; + }; + } + + // Click toggles selection + div.onclick = (e) => { + if (e.target.closest('.track-actions')) return; + M.toggleQueueSelection(i, e.shiftKey); + }; + + // Right-click context menu + div.oncontextmenu = (e) => { + const menuItems = []; + const hasSelection = M.selectedQueueIndices.size > 0; + const selectedCount = hasSelection ? M.selectedQueueIndices.size : 1; + const indicesToRemove = hasSelection ? [...M.selectedQueueIndices] : [i]; + + // Play track option (only for single track, not bulk) + if (!hasSelection) { + menuItems.push({ + label: "▶ Play track", + action: async () => { + 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 }) + }); + if (res.status === 403) M.flashPermissionDenied(); + } else { + M.currentIndex = i; + M.currentTrackId = trackId; + M.serverTrackDuration = track.duration; + M.setTrackTitle(title); + M.loadingSegments.clear(); + const cachedUrl = await M.loadTrackBlob(trackId); + M.audio.src = cachedUrl || M.getTrackUrl(trackId); + M.audio.currentTime = 0; + M.localTimestamp = 0; + M.audio.play(); + M.renderQueue(); + } + } + }); + } + + // Remove track(s) option (if user can edit) + if (canEdit) { + const label = selectedCount > 1 ? `✕ Remove ${selectedCount} tracks` : "✕ Remove track"; + menuItems.push({ + label, + danger: true, + action: async () => { + if (!M.currentChannelId) return; + const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ remove: indicesToRemove }) + }); + if (res.status === 403) M.flashPermissionDenied(); + else if (res.ok) { + M.showToast(selectedCount > 1 ? `Removed ${selectedCount} tracks` : "Track removed"); + M.clearSelections(); + } + } + }); + } + + // Preload track(s) option - only show if not in stream-only mode + if (!M.streamOnly) { + const idsToPreload = hasSelection + ? [...M.selectedQueueIndices].map(idx => M.queue[idx]?.id).filter(Boolean) + : [trackId]; + const preloadLabel = hasSelection && idsToPreload.length > 1 ? `Preload ${idsToPreload.length} tracks` : "Preload track"; + menuItems.push({ + label: preloadLabel, + action: () => { + const uncachedIds = idsToPreload.filter(id => !M.cachedTracks.has(id)); + if (uncachedIds.length === 0) { + M.showToast("All tracks already cached"); + return; + } + M.showToast(`Preloading ${uncachedIds.length} track${uncachedIds.length > 1 ? 's' : ''}...`); + uncachedIds.forEach(id => M.downloadAndCacheTrack(id)); + } + }); + } + + // Download track option (single track only) + if (!hasSelection) { + menuItems.push({ + label: "Download", + action: () => downloadTrack(trackId, track.filename) + }); + } + + // Clear selection option (if items selected) + if (hasSelection) { + menuItems.push({ + label: "Clear selection", + action: () => M.clearSelections() + }); + } + + showContextMenu(e, menuItems); + }; + + container.appendChild(div); + }); + + M.updateNowPlayingBar(); + }; + + // Update the now-playing bar above the queue + M.updateNowPlayingBar = function() { + const bar = M.$("#now-playing-bar"); + if (!bar) return; + + const track = M.queue[M.currentIndex]; + if (!track) { + bar.classList.add("hidden"); + return; + } + + const title = track.title?.trim() || (track.id || track.filename || "Unknown").replace(/\.[^.]+$/, ""); + bar.innerHTML = `Now playing: ${title}`; + bar.title = title; + bar.classList.remove("hidden"); + }; + + // Scroll queue to current track + M.scrollToCurrentTrack = function() { + const container = M.$("#queue"); + if (!container) return; + + const activeTrack = container.querySelector(".track.active"); + if (activeTrack) { + activeTrack.scrollIntoView({ behavior: "smooth", block: "center" }); + } + }; + + // Setup now-playing bar click handler + document.addEventListener("DOMContentLoaded", () => { + const bar = M.$("#now-playing-bar"); + if (bar) { + bar.onclick = () => M.scrollToCurrentTrack(); + } + }); + + // Library search state + M.librarySearchQuery = ""; + + // Render the library + M.renderLibrary = function() { + const container = M.$("#library"); + if (!container) return; + container.innerHTML = ""; + if (M.library.length === 0) { + container.innerHTML = '
No tracks discovered
'; + return; + } + + const canEdit = M.canControl(); + const query = M.librarySearchQuery.toLowerCase(); + + // Filter library by search query + const filteredLibrary = query + ? M.library.map((track, i) => ({ track, i })).filter(({ track }) => { + const title = track.title?.trim() || track.filename; + return title.toLowerCase().includes(query); + }) + : M.library.map((track, i) => ({ track, i })); + + if (filteredLibrary.length === 0) { + container.innerHTML = '
No matches
'; + return; + } + + filteredLibrary.forEach(({ track, i }) => { + const div = document.createElement("div"); + const isCached = M.cachedTracks.has(track.id); + const isSelected = M.selectedLibraryIds.has(track.id); + div.className = "track" + (isCached ? " cached" : " not-cached") + (isSelected ? " selected" : ""); + const title = track.title?.trim() || track.filename.replace(/\.[^.]+$/, ""); + div.title = title; // Tooltip for full name + + const checkmark = isSelected ? `` : ''; + div.innerHTML = `${checkmark}${title}${M.fmt(track.duration)}`; + + // Drag from library to queue (if user can edit) + if (canEdit) { + div.draggable = true; + + div.ondragstart = (e) => { + dragSource = 'library'; + draggedIndices = []; + // If dragging a selected item, drag all selected; otherwise just this one + if (M.selectedLibraryIds.has(track.id)) { + draggedLibraryIds = [...M.selectedLibraryIds]; + } else { + draggedLibraryIds = [track.id]; + } + div.classList.add("dragging"); + e.dataTransfer.effectAllowed = "copy"; + e.dataTransfer.setData("text/plain", "library:" + draggedLibraryIds.join(",")); + }; + + div.ondragend = () => { + div.classList.remove("dragging"); + draggedIndices = []; + draggedLibraryIds = []; + dragSource = null; + // Clear drop indicators in queue + const queueContainer = M.$("#queue"); + if (queueContainer) { + queueContainer.querySelectorAll(".drop-above, .drop-below").forEach(el => { + el.classList.remove("drop-above", "drop-below"); + }); + } + }; + } + + // Click toggles selection + div.onclick = (e) => { + if (e.target.closest('.track-actions')) return; + M.toggleLibrarySelection(i, e.shiftKey); + }; + + // Right-click context menu + div.oncontextmenu = (e) => { + const menuItems = []; + const hasSelection = M.selectedLibraryIds.size > 0; + const selectedCount = hasSelection ? M.selectedLibraryIds.size : 1; + const idsToAdd = hasSelection ? [...M.selectedLibraryIds] : [track.id]; + + // Play track option (local mode only, single track) + if (!M.synced && !hasSelection) { + menuItems.push({ + label: "▶ Play track", + action: async () => { + M.currentTrackId = track.id; + M.serverTrackDuration = track.duration; + M.setTrackTitle(title); + M.loadingSegments.clear(); + const cachedUrl = await M.loadTrackBlob(track.id); + M.audio.src = cachedUrl || M.getTrackUrl(track.id); + M.audio.currentTime = 0; + M.localTimestamp = 0; + M.audio.play(); + } + }); + } + + // Add to queue option (if user can edit) + if (canEdit) { + const label = selectedCount > 1 ? `+ Add ${selectedCount} to queue` : "+ Add to queue"; + menuItems.push({ + label, + action: async () => { + if (!M.currentChannelId) { + M.showToast("No channel selected"); + return; + } + const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ add: idsToAdd }) + }); + if (res.status === 403) M.flashPermissionDenied(); + else if (res.ok) { + M.showToast(selectedCount > 1 ? `Added ${selectedCount} tracks` : "Track added to queue"); + M.clearSelections(); + } + } + }); + } + + // Preload track(s) option - only show if not in stream-only mode + if (!M.streamOnly) { + const preloadLabel = selectedCount > 1 ? `Preload ${selectedCount} tracks` : "Preload track"; + menuItems.push({ + label: preloadLabel, + action: () => { + const uncachedIds = idsToAdd.filter(id => !M.cachedTracks.has(id)); + if (uncachedIds.length === 0) { + M.showToast("All tracks already cached"); + return; + } + M.showToast(`Preloading ${uncachedIds.length} track${uncachedIds.length > 1 ? 's' : ''}...`); + uncachedIds.forEach(id => M.downloadAndCacheTrack(id)); + } + }); + } + + // Download track option (single track only) + if (!hasSelection) { + menuItems.push({ + label: "Download", + action: () => downloadTrack(track.id, track.filename) + }); + } + + // Export all cached option (if there are cached tracks) + if (M.cachedTracks.size > 0) { + menuItems.push({ + label: `Preload and export ${M.cachedTracks.size} cached`, + action: () => M.exportAllCached() + }); + } + + // Clear selection option (if items selected) + if (hasSelection) { + menuItems.push({ + label: "Clear selection", + action: () => M.clearSelections() + }); + } + + if (menuItems.length > 0) { + showContextMenu(e, menuItems); + } + }; + + container.appendChild(div); + }); + }; + + // Load library from server + M.loadLibrary = async function() { + try { + const res = await fetch("/api/library"); + M.library = await res.json(); + M.renderLibrary(); + } catch (e) { + console.warn("Failed to load library"); + } + }; + + // Setup library search + document.addEventListener("DOMContentLoaded", () => { + const searchInput = M.$("#library-search"); + if (searchInput) { + searchInput.addEventListener("input", (e) => { + M.librarySearchQuery = e.target.value; + M.renderLibrary(); + }); + } + }); +})(); diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..5a0262f --- /dev/null +++ b/public/styles.css @@ -0,0 +1,252 @@ +* { margin: 0; padding: 0; box-sizing: border-box; } +body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #111; color: #eee; min-height: 100vh; } +#app { width: 100%; max-width: 1700px; margin: 0 auto; padding: 0.5rem; display: flex; flex-direction: column; min-height: 100vh; } +h1 { font-size: 1rem; color: #888; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.4rem; } +h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppercase; letter-spacing: 0.05em; } +#sync-indicator { width: 8px; height: 8px; border-radius: 50%; background: #4e8; display: none; flex-shrink: 0; } +#sync-indicator.visible { display: inline-block; } +#sync-indicator.disconnected { background: #e44; } + +/* Header */ +#header-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; } +#auth-section { display: flex; gap: 0.4rem; align-items: center; } +#auth-section .user-info { display: flex; align-items: center; gap: 0.4rem; } +#auth-section .username { color: #4e8; font-weight: 600; font-size: 0.85rem; } +#auth-section .admin-badge { background: #c4f; color: #111; padding: 0.1rem 0.3rem; border-radius: 3px; font-size: 0.65rem; } +#btn-logout { background: none; border: none; color: #e44; font-size: 0.65rem; cursor: pointer; padding: 0; opacity: 0.7; } +#btn-logout:hover { opacity: 1; text-decoration: underline; } +#btn-kick-others { background: none; border: none; color: #ea4; font-size: 0.65rem; cursor: pointer; padding: 0; opacity: 0.7; } +#btn-kick-others:hover { opacity: 1; text-decoration: underline; } +#stream-select select { background: #222; color: #eee; border: 1px solid #333; padding: 0.3rem 0.6rem; border-radius: 4px; font-size: 0.85rem; } + +/* Main content - library and queue */ +#main-content { display: flex; gap: 0.5rem; flex: 1; min-height: 0; margin-bottom: 0.5rem; max-width: 1600px; margin-left: auto; margin-right: auto; } +#channels-panel { flex: 0 0 160px; background: #1a1a1a; border-radius: 6px; padding: 0.4rem; display: flex; flex-direction: column; min-height: 250px; max-height: 60vh; } +#channels-list { flex: 1; overflow-y: auto; } +#channels-list .channel-item { padding: 0.2rem 0.4rem; border-radius: 3px; font-size: 0.8rem; display: flex; flex-direction: column; gap: 0.1rem; } +#channels-list .channel-item.active { background: #2a4a3a; color: #4e8; } +#channels-list .channel-header { display: flex; justify-content: space-between; align-items: center; cursor: pointer; padding: 0.1rem 0; border-radius: 3px; } +#channels-list .channel-header:hover { background: #222; } +#channels-list .channel-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.8rem; } +#channels-list .listener-count { font-size: 0.65rem; color: #666; flex-shrink: 0; margin-left: 0.3rem; } +#channels-list .btn-delete-channel { background: none; border: none; color: #666; font-size: 0.9rem; cursor: pointer; padding: 0 0.2rem; line-height: 1; opacity: 0; transition: opacity 0.15s; } +#channels-list .channel-header:hover .btn-delete-channel { opacity: 1; } +#channels-list .btn-delete-channel:hover { color: #e44; } +#channels-list .channel-listeners { display: flex; flex-direction: column; margin-left: 0.5rem; border-left: 1px solid #333; padding-left: 0.3rem; } +#channels-list .listener { font-size: 0.65rem; color: #aaa; padding: 0.05rem 0; position: relative; } +#channels-list .listener::before { content: ""; position: absolute; left: -0.3rem; top: 50%; width: 0.2rem; height: 1px; background: #333; } +#channels-list .listener-mult { color: #666; font-size: 0.55rem; } +#library-panel, #queue-panel { flex: 0 0 700px; min-width: 0; overflow: hidden; background: #1a1a1a; border-radius: 6px; padding: 0.5rem; display: flex; flex-direction: column; min-height: 250px; max-height: 60vh; position: relative; } +.panel-tabs { display: flex; gap: 0; margin-bottom: 0; flex-shrink: 0; } +.panel-tab { background: #252525; border: none; color: #666; font-family: inherit; font-size: 0.8rem; font-weight: bold; padding: 0.3rem 0.6rem; cursor: pointer; border-radius: 4px 4px 0 0; text-transform: uppercase; letter-spacing: 0.05em; margin-right: 2px; } +.panel-tab:hover { color: #aaa; background: #2a2a2a; } +.panel-tab.active { color: #eee; background: #222; } +.panel-views { flex: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden; position: relative; background: #222; border-radius: 0 4px 4px 4px; } +.panel-view { display: none; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; position: relative; padding: 0.4rem; } +.panel-view.active { display: flex; } +.tasks-empty { color: #666; font-size: 0.85rem; padding: 1rem; text-align: center; } +.tasks-empty.hidden { display: none; } +#tasks-list { display: flex; flex-direction: column; gap: 0.3rem; overflow-y: auto; flex: 1; } +.task-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.6rem; background: #2a2a1a; border-radius: 4px; font-size: 0.8rem; color: #ea4; position: relative; overflow: hidden; } +.task-item.complete { background: #1a2a1a; color: #4e8; } +.task-item.error { background: #2a1a1a; color: #e44; } +.task-item .task-spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid #ea4; border-top-color: transparent; border-radius: 50%; animation: spin 0.8s linear infinite; flex-shrink: 0; } +.task-item.complete .task-spinner { display: none; } +.task-item.error .task-spinner { display: none; } +.task-item .task-icon { flex-shrink: 0; } +.task-item.complete .task-icon::before { content: "✓"; } +.task-item.error .task-icon::before { content: "✗"; } +.task-item .task-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.task-item .task-progress { font-size: 0.7rem; color: #888; flex-shrink: 0; } +.task-item .task-bar { position: absolute; left: 0; bottom: 0; height: 2px; background: #ea4; transition: width 0.2s; } +.task-item.complete .task-bar { background: #4e8; } +.scan-progress { font-size: 0.7rem; color: #ea4; padding: 0.2rem 0.4rem; background: #2a2a1a; border-radius: 3px; margin-bottom: 0.3rem; display: flex; align-items: center; gap: 0.4rem; flex-shrink: 0; } +.scan-progress.hidden { display: none; } +.scan-progress.complete { color: #4e8; background: #1a2a1a; } +.scan-progress .spinner { display: inline-block; width: 10px; height: 10px; border: 2px solid #ea4; border-top-color: transparent; border-radius: 50%; animation: spin 0.8s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } +.upload-btn { width: 100%; padding: 0.5rem; background: #2a3a2a; border: 1px dashed #4e8; border-radius: 4px; color: #4e8; font-size: 0.85rem; cursor: pointer; margin-top: 0.3rem; flex-shrink: 0; } +.upload-btn:hover { background: #3a4a3a; } +.add-btn { width: 100%; padding: 0.5rem; background: #252525; border: 1px solid #444; border-radius: 4px; color: #888; font-size: 0.85rem; cursor: pointer; margin-top: 0.3rem; flex-shrink: 0; transition: all 0.2s; } +.add-btn:hover { background: #2a2a2a; border-color: #666; color: #aaa; } +.add-btn.hidden { display: none; } +.fetch-dialog { position: absolute; bottom: 0; left: 0; right: 0; background: #1a1a1a; border-top: 1px solid #333; border-radius: 0 0 6px 6px; display: flex; flex-direction: column; animation: panelSlideUp 0.2s ease-out; } +.fetch-dialog.hidden { display: none; } +.fetch-dialog.closing { animation: panelSlideDown 0.2s ease-in forwards; } +.fetch-dialog-header { display: flex; justify-content: space-between; align-items: center; padding: 0.4rem 0.5rem; border-bottom: 1px solid #333; } +.fetch-dialog-header span { font-size: 0.8rem; color: #aaa; } +.fetch-dialog-close { background: none; border: none; color: #666; font-size: 1.2rem; cursor: pointer; padding: 0 0.3rem; line-height: 1; } +.fetch-dialog-close:hover { color: #aaa; } +.fetch-dialog-content { padding: 0.5rem; display: flex; gap: 0.4rem; } +.fetch-url-input { flex: 1; padding: 0.4rem 0.6rem; background: #222; border: 1px solid #444; border-radius: 4px; color: #eee; font-size: 0.85rem; font-family: inherit; } +.fetch-url-input:focus { outline: none; border-color: #4e8; } +.fetch-submit-btn { padding: 0.4rem 0.8rem; background: #2a3a2a; border: 1px solid #4e8; border-radius: 4px; color: #4e8; font-size: 0.85rem; cursor: pointer; font-family: inherit; } +.fetch-submit-btn:hover { background: #3a4a3a; } +.add-panel { position: absolute; bottom: 0; left: 0; right: 0; background: #1a1a1a; border-top: 1px solid #333; border-radius: 0 0 6px 6px; display: flex; flex-direction: column; height: 50%; overflow: hidden; animation: panelSlideUp 0.2s ease-out; } +.add-panel.hidden { display: none; } +.add-panel.closing { animation: panelSlideDown 0.2s ease-in forwards; } +@keyframes panelSlideUp { from { transform: translateY(100%); opacity: 0; } to { transform: translateY(0); opacity: 1; } } +@keyframes panelSlideDown { from { transform: translateY(0); opacity: 1; } to { transform: translateY(100%); opacity: 0; } } +.add-panel-close { width: calc(100% - 1rem); margin: 0.5rem; padding: 0.5rem; background: #252525; border: 1px solid #444; border-radius: 4px; color: #888; font-size: 0.85rem; cursor: pointer; flex-shrink: 0; } +.add-panel-close:hover { background: #2a2a2a; border-color: #666; color: #aaa; } +.add-panel-content { flex: 1; overflow-y: auto; padding: 0 0.5rem 0.5rem; display: flex; flex-direction: column; gap: 0.3rem; } +.add-option { width: 100%; padding: 0.6rem 0.8rem; background: #222; border: 1px solid #333; border-radius: 4px; color: #aaa; font-size: 0.85rem; cursor: pointer; text-align: left; transition: all 0.15s; } +.add-option:hover { background: #2a3a2a; border-color: #4e8; color: #4e8; } +.upload-progress { display: flex; align-items: center; gap: 0.4rem; padding: 0.3rem 0.5rem; background: #2a2a1a; border-radius: 4px; margin-top: 0.3rem; flex-shrink: 0; position: relative; overflow: hidden; } +.upload-progress.hidden { display: none; } +.upload-progress-bar { position: absolute; left: 0; top: 0; bottom: 0; background: #4a6a4a; transition: width 0.2s; } +.upload-progress-text { position: relative; z-index: 1; font-size: 0.75rem; color: #4e8; } +.upload-dropzone { position: absolute; inset: 0; background: rgba(42, 74, 58, 0.95); display: flex; align-items: center; justify-content: center; border-radius: 6px; border: 2px dashed #4e8; z-index: 10; } +.upload-dropzone.hidden { display: none; } +.dropzone-content { color: #4e8; font-size: 1.2rem; font-weight: 600; } +#queue-title { margin: 0 0 0.3rem 0; } +.now-playing-bar { font-size: 0.75rem; color: #4e8; padding: 0.3rem 0.5rem; background: #1a2a1a; border: 1px solid #2a4a3a; border-radius: 4px; margin-bottom: 0.3rem; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.now-playing-bar:hover { background: #2a3a2a; } +.now-playing-bar.hidden { display: none; } +.now-playing-bar .label { color: #666; } +.panel-header { display: flex; align-items: center; gap: 0.4rem; margin-bottom: 0.3rem; } +.panel-header h3 { margin: 0; flex-shrink: 0; } +.panel-header select { flex: 1; background: #222; color: #eee; border: 1px solid #333; padding: 0.2rem 0.4rem; border-radius: 4px; font-size: 0.75rem; } +.panel-header button { background: #333; color: #eee; border: 1px solid #444; width: 24px; height: 24px; border-radius: 4px; cursor: pointer; font-size: 0.9rem; line-height: 1; padding: 0; } +.panel-header button:hover { background: #444; } +.new-channel-input { flex: 1; background: #222; color: #eee; border: 1px solid #333; padding: 0.2rem 0.4rem; border-radius: 4px; font-size: 0.75rem; } +.btn-submit-channel { background: #2a4a3a; color: #4e8; border: 1px solid #3a5a4a; width: 24px; height: 24px; border-radius: 4px; cursor: pointer; font-size: 1rem; line-height: 1; padding: 0; } +.btn-submit-channel:hover { background: #3a5a4a; } +.search-input { flex: 1; background: #222; color: #eee; border: 1px solid #333; padding: 0.2rem 0.4rem; border-radius: 4px; font-size: 0.75rem; } +.search-input::placeholder { color: #666; } +#library, #queue { flex: 1; overflow-y: auto; overflow-x: hidden; min-width: 0; } +#library .track, #queue .track { padding: 0.3rem 0.5rem; border-radius: 4px; cursor: pointer; font-size: 0.85rem; display: flex; justify-content: space-between; align-items: center; position: relative; user-select: none; min-width: 0; } +#library .track[title], #queue .track[title] { cursor: pointer; } +#library .track:hover, #queue .track:hover { background: #222; } +#queue .track.active { background: #2a4a3a; color: #4e8; } +.cache-indicator { width: 3px; height: 100%; position: absolute; left: 0; top: 0; border-radius: 4px 0 0 4px; } +.track.cached .cache-indicator { background: #4e8; } +.track.not-cached .cache-indicator { background: #ea4; } +.track-number { color: #555; font-size: 0.7rem; min-width: 1.3rem; margin-right: 0.2rem; } +.track-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } +.track-actions { display: flex; align-items: center; gap: 0.4rem; flex-shrink: 0; } +.track-actions .duration { color: #666; font-size: 0.75rem; } +.track-actions .track-add, .track-actions .track-remove { width: 18px; height: 18px; display: flex; align-items: center; justify-content: center; background: #333; border: none; border-radius: 3px; font-size: 0.85rem; color: #ccc; cursor: pointer; opacity: 0; transition: opacity 0.2s; } +.track:hover .track-add, .track:hover .track-remove { opacity: 0.6; } +.track-actions .track-add:hover, .track-actions .track-remove:hover { opacity: 1; background: #444; } +.track-actions .track-remove { color: #e44; } +.track-actions .track-add { color: #4e4; } + +/* Track selection */ +.track-checkmark { color: #4e8; font-weight: bold; margin-right: 0.4rem; font-size: 0.85rem; } +.track.selected { background: #2a3a4a; } +.track.dragging { opacity: 0.5; } +.track.drop-above::before { content: ""; position: absolute; top: -1px; left: 0; right: 0; height: 2px; background: #4e8; border-radius: 2px; } +.track.drop-below::after { content: ""; position: absolute; bottom: -1px; left: 0; right: 0; height: 2px; background: #4e8; border-radius: 2px; } +#queue.drop-target, #queue .drop-zone { border: 2px dashed #4e8; border-radius: 4px; } +#queue .drop-zone { padding: 1.5rem; text-align: center; color: #4e8; } + +/* Context menu */ +.context-menu { position: fixed; background: #222; border: 1px solid #444; border-radius: 5px; padding: 0.2rem 0; z-index: 1000; min-width: 130px; box-shadow: 0 4px 12px rgba(0,0,0,0.6); } +.context-menu-item { padding: 0.4rem 0.75rem; cursor: pointer; font-size: 0.8rem; display: flex; align-items: center; gap: 0.4rem; } +.context-menu-item:hover { background: #333; } +.context-menu-item.danger { color: #e44; } +.context-menu-item.danger:hover { background: #3a2a2a; } + +/* Player bar */ +#player-bar { background: #1a1a1a; border-radius: 6px; padding: 0.5rem 0.75rem; display: flex; gap: 0.75rem; align-items: center; } +#now-playing { width: 180px; flex-shrink: 0; } +#channel-name { font-size: 0.7rem; color: #666; margin-bottom: 0.1rem; } +#track-name { font-size: 0.9rem; font-weight: 600; overflow: hidden; position: relative; } +#track-name .marquee-inner { display: inline-block; white-space: nowrap; } +#track-name.scrolling .marquee-inner { animation: scroll-marquee 8s linear infinite; } +@keyframes scroll-marquee { 0% { transform: translateX(0); } 100% { transform: translateX(-50%); } } +#player-controls { flex: 1; } +#progress-row { display: flex; align-items: center; gap: 0.4rem; margin-bottom: 0.2rem; } +#progress-row.denied { animation: flash-red 0.5s ease-out; } +@keyframes flash-red { 0% { background: #e44; } 100% { background: transparent; } } +#btn-sync { font-size: 0.7rem; cursor: pointer; color: #666; transition: color 0.2s, text-shadow 0.2s; letter-spacing: 0.05em; } +#btn-sync:hover { color: #888; } +#btn-sync.synced { color: #eb0; text-shadow: 0 0 8px #eb0, 0 0 12px #eb0; } +#btn-sync.synced.connected { color: #4e8; text-shadow: 0 0 8px #4e8, 0 0 12px #4e8; } +#btn-prev, #btn-next { font-size: 0.75rem; cursor: pointer; opacity: 0.6; transition: opacity 0.2s; } +#btn-prev:hover, #btn-next:hover { opacity: 1; } +#btn-mode { font-size: 0.7rem; cursor: pointer; transition: color 0.2s, text-shadow 0.2s; letter-spacing: 0.05em; padding: 0.1rem 0.3rem; border-radius: 3px; } +#btn-mode.mode-once { color: #888; text-shadow: none; } +#btn-mode.mode-repeat-all { color: #4e8; text-shadow: 0 0 6px #4e8; } +#btn-mode.mode-repeat-one { color: #eb0; text-shadow: 0 0 6px #eb0; } +#btn-mode.mode-shuffle { color: #c4f; text-shadow: 0 0 6px #c4f; } +#btn-mode:hover { opacity: 0.8; } +#status-icon { font-size: 0.85rem; width: 1rem; text-align: center; cursor: pointer; } +#progress-container { background: #222; border-radius: 4px; height: 5px; cursor: pointer; position: relative; flex: 1; } +#progress-bar { background: #555; height: 100%; border-radius: 4px; width: 0%; transition: width 0.3s linear; pointer-events: none; } +#progress-bar.playing.synced { background: #4e8; } +#progress-bar.playing.local { background: #c4f; } +#progress-bar.muted { background: #555 !important; } +#seek-tooltip { position: absolute; bottom: 10px; background: #333; color: #eee; padding: 2px 5px; border-radius: 3px; font-size: 0.7rem; pointer-events: none; display: none; transform: translateX(-50%); } +#time { font-size: 0.75rem; color: #888; margin: 0; line-height: 1; white-space: nowrap; } +#buffer-bar { display: flex; gap: 1px; margin-bottom: 0.2rem; } +#buffer-bar .segment { flex: 1; height: 2px; background: #333; border-radius: 1px; } +#buffer-bar .segment.available { background: #396; } +#buffer-bar .segment.loading { background: #666; animation: throb 0.6s ease-in-out infinite alternate; } +@keyframes throb { from { background: #444; } to { background: #888; } } +#download-speed { font-size: 0.6rem; color: #555; text-align: right; } +#volume-controls { display: flex; gap: 0.4rem; align-items: center; } +#btn-stream-only { font-size: 0.7rem; cursor: pointer; color: #666; transition: color 0.2s, text-shadow 0.2s; letter-spacing: 0.05em; } +#btn-stream-only:hover { color: #888; } +#btn-stream-only.active { color: #4af; text-shadow: 0 0 6px #4af; } +#btn-mute { font-size: 1rem; cursor: pointer; opacity: 0.8; transition: opacity 0.2s; } +#btn-mute:hover { opacity: 1; } +#volume { width: 120px; accent-color: #4e8; } + +/* Common */ +button { background: #222; color: #eee; border: 1px solid #333; padding: 0.4rem 1rem; border-radius: 4px; cursor: pointer; font-size: 0.85rem; } +button:hover { background: #333; } +#status { margin-top: 0.3rem; font-size: 0.75rem; color: #666; text-align: center; } +.empty { color: #666; font-style: italic; font-size: 0.85rem; } + +/* Login panel */ +#login-panel { display: flex; flex-direction: column; gap: 0.75rem; padding: 1.5rem; background: #1a1a1a; border-radius: 6px; border: 1px solid #333; max-width: 360px; margin: auto; } +#login-panel.hidden { display: none; } +#login-panel h2 { font-size: 1rem; color: #888; margin-bottom: 0.3rem; } +#login-panel .tabs { display: flex; gap: 0.75rem; margin-bottom: 0.75rem; } +#login-panel .tabs button { background: none; border: none; color: #666; font-size: 0.9rem; cursor: pointer; padding: 0.4rem 0; border-bottom: 2px solid transparent; } +#login-panel .tabs button.active { color: #4e8; border-bottom-color: #4e8; } +#login-panel input { background: #222; color: #eee; border: 1px solid #333; padding: 0.5rem; border-radius: 4px; font-size: 0.9rem; } +#login-panel .form-group { display: flex; flex-direction: column; gap: 0.4rem; } +#login-panel .form-group.hidden { display: none; } +#login-panel .submit-btn { background: #4e8; color: #111; border: none; padding: 0.5rem; border-radius: 4px; font-size: 0.9rem; cursor: pointer; font-weight: 600; } +#login-panel .submit-btn:hover { background: #5fa; } +#auth-error, #signup-error { color: #e44; font-size: 0.75rem; } +#guest-section { margin-top: 0.75rem; } +#guest-section.hidden { display: none; } +#guest-section .divider { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.75rem; color: #666; font-size: 0.8rem; } +#guest-section .divider::before, #guest-section .divider::after { content: ""; flex: 1; height: 1px; background: #333; } +#guest-section .guest-btn { width: 100%; background: #333; color: #eee; border: 1px solid #444; padding: 0.5rem; border-radius: 4px; font-size: 0.9rem; cursor: pointer; } +#guest-section .guest-btn:hover { background: #444; } + +#player-content { display: none; flex-direction: column; flex: 1; } +#player-content.visible { display: flex; } + +::-webkit-scrollbar { width: 6px; } +::-webkit-scrollbar-track { background-color: #111; border-radius: 3px; } +::-webkit-scrollbar-thumb { background-color: #333; border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background-color: #555; } + +/* Toast notifications */ +#toast-container { position: fixed; top: 0.5rem; left: 0.5rem; display: flex; flex-direction: column; gap: 0.4rem; z-index: 1000; pointer-events: none; max-height: 80vh; overflow-y: auto; } +.toast { background: #1a3a2a; color: #4e8; padding: 0.5rem 0.75rem; border-radius: 5px; border: 1px solid #4e8; box-shadow: 0 4px 12px rgba(0,0,0,0.4); font-size: 0.8rem; animation: toast-in 0.3s ease-out; max-width: 280px; } +.toast.toast-warning { background: #3a3a1a; color: #ea4; border-color: #ea4; } +.toast.toast-error { background: #3a1a1a; color: #e44; border-color: #e44; } +.toast.fade-out { animation: toast-out 0.3s ease-in forwards; } +@keyframes toast-in { from { opacity: 0; transform: translateX(-20px); } to { opacity: 1; transform: translateX(0); } } +@keyframes toast-out { from { opacity: 1; transform: translateX(0); } to { opacity: 0; transform: translateX(-20px); } } + +/* Toast history panel */ +#btn-history { position: fixed; bottom: 0.5rem; left: 0.5rem; background: #222; border: 1px solid #333; color: #888; padding: 0.3rem 0.6rem; border-radius: 4px; cursor: pointer; font-size: 0.7rem; z-index: 999; } +#btn-history:hover { background: #333; color: #ccc; } +#toast-history { position: fixed; bottom: 3rem; left: 0.5rem; width: 320px; max-height: 400px; background: #1a1a1a; border: 1px solid #333; border-radius: 6px; z-index: 1001; display: flex; flex-direction: column; } +#toast-history.hidden { display: none; } +.history-header { display: flex; justify-content: space-between; align-items: center; padding: 0.5rem; border-bottom: 1px solid #333; color: #888; font-size: 0.8rem; } +.history-header button { background: none; border: none; color: #888; cursor: pointer; font-size: 1rem; padding: 0; width: 20px; height: 20px; } +.history-header button:hover { color: #ccc; } +#toast-history-list { flex: 1; overflow-y: auto; padding: 0.3rem; max-height: 350px; } +.history-item { padding: 0.3rem 0.5rem; font-size: 0.75rem; border-radius: 3px; margin-bottom: 0.2rem; color: #4e8; background: #1a2a1a; } +.history-item.history-warning { color: #ea4; background: #2a2a1a; } +.history-item.history-error { color: #e44; background: #2a1a1a; } +.history-time { color: #666; margin-right: 0.4rem; } diff --git a/public/trackStorage.js b/public/trackStorage.js new file mode 100644 index 0000000..43183cb --- /dev/null +++ b/public/trackStorage.js @@ -0,0 +1,167 @@ +// Track Storage Abstraction Layer +// Provides a unified interface for storing/retrieving audio blobs +// Default implementation uses IndexedDB, can be swapped for Electron file API + +const TrackStorage = (function() { + const DB_NAME = 'blastoise'; + const DB_VERSION = 1; + const STORE_NAME = 'tracks'; + + let db = null; + let initPromise = null; + + // Initialize IndexedDB + function init() { + if (initPromise) return initPromise; + + initPromise = new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onerror = () => { + console.warn('TrackStorage: IndexedDB failed to open'); + resolve(false); + }; + + request.onsuccess = () => { + db = request.result; + resolve(true); + }; + + request.onupgradeneeded = (event) => { + const database = event.target.result; + if (!database.objectStoreNames.contains(STORE_NAME)) { + database.createObjectStore(STORE_NAME, { keyPath: 'filename' }); + } + }; + }); + + return initPromise; + } + + // Check if a track is cached + async function has(filename) { + await init(); + if (!db) return false; + + return new Promise((resolve) => { + const transaction = db.transaction(STORE_NAME, 'readonly'); + const store = transaction.objectStore(STORE_NAME); + const request = store.getKey(filename); + + request.onsuccess = () => resolve(request.result !== undefined); + request.onerror = () => resolve(false); + }); + } + + // Get a track blob, returns { blob, contentType } or null + async function get(filename) { + await init(); + if (!db) return null; + + return new Promise((resolve) => { + const transaction = db.transaction(STORE_NAME, 'readonly'); + const store = transaction.objectStore(STORE_NAME); + const request = store.get(filename); + + request.onsuccess = () => { + const result = request.result; + if (result) { + resolve({ blob: result.blob, contentType: result.contentType }); + } else { + resolve(null); + } + }; + request.onerror = () => resolve(null); + }); + } + + // Store a track blob + async function set(filename, blob, contentType) { + await init(); + if (!db) return false; + + return new Promise((resolve) => { + const transaction = db.transaction(STORE_NAME, 'readwrite'); + const store = transaction.objectStore(STORE_NAME); + const request = store.put({ filename, blob, contentType, cachedAt: Date.now() }); + + request.onsuccess = () => resolve(true); + request.onerror = () => resolve(false); + }); + } + + // Remove a track from cache + async function remove(filename) { + await init(); + if (!db) return false; + + return new Promise((resolve) => { + const transaction = db.transaction(STORE_NAME, 'readwrite'); + const store = transaction.objectStore(STORE_NAME); + const request = store.delete(filename); + + request.onsuccess = () => resolve(true); + request.onerror = () => resolve(false); + }); + } + + // Clear all cached tracks + async function clear() { + await init(); + if (!db) return false; + + return new Promise((resolve) => { + const transaction = db.transaction(STORE_NAME, 'readwrite'); + const store = transaction.objectStore(STORE_NAME); + const request = store.clear(); + + request.onsuccess = () => resolve(true); + request.onerror = () => resolve(false); + }); + } + + // List all cached track filenames + async function list() { + await init(); + if (!db) return []; + + return new Promise((resolve) => { + const transaction = db.transaction(STORE_NAME, 'readonly'); + const store = transaction.objectStore(STORE_NAME); + const request = store.getAllKeys(); + + request.onsuccess = () => resolve(request.result || []); + request.onerror = () => resolve([]); + }); + } + + // Get storage stats + async function getStats() { + await init(); + if (!db) return { count: 0, totalSize: 0 }; + + return new Promise((resolve) => { + const transaction = db.transaction(STORE_NAME, 'readonly'); + const store = transaction.objectStore(STORE_NAME); + const request = store.getAll(); + + request.onsuccess = () => { + const tracks = request.result || []; + const totalSize = tracks.reduce((sum, t) => sum + (t.blob?.size || 0), 0); + resolve({ count: tracks.length, totalSize }); + }; + request.onerror = () => resolve({ count: 0, totalSize: 0 }); + }); + } + + return { + init, + has, + get, + set, + remove, + clear, + list, + getStats + }; +})(); diff --git a/public/ui.js b/public/ui.js new file mode 100644 index 0000000..26587cd --- /dev/null +++ b/public/ui.js @@ -0,0 +1,165 @@ +// MusicRoom - UI module +// Progress bar, buffer display, and UI state updates + +(function() { + const M = window.MusicRoom; + + // Create buffer segments on load + for (let i = 0; i < M.SEGMENTS; i++) { + const seg = document.createElement("div"); + seg.className = "segment"; + M.$("#buffer-bar").appendChild(seg); + } + + // Update general UI state + M.updateUI = function() { + const isConnecting = M.wantSync && !M.synced; + // While connecting, treat as not playing (paused state) + const isPlaying = M.synced ? !M.serverPaused : (!isConnecting && !M.audio.paused); + M.$("#btn-sync").classList.toggle("synced", M.wantSync); + M.$("#btn-sync").classList.toggle("connected", M.synced); + M.$("#btn-sync").title = M.wantSync ? "Unsync" : "Sync"; + M.$("#status").textContent = M.synced ? "Synced" : (M.wantSync ? "Connecting..." : "Local"); + M.$("#sync-indicator").classList.toggle("visible", M.synced); + M.$("#progress-bar").classList.toggle("synced", M.synced); + M.$("#progress-bar").classList.toggle("local", !M.synced); + M.$("#progress-bar").classList.toggle("muted", M.audio.volume === 0); + M.$("#btn-mute").textContent = M.audio.volume === 0 ? "🔇" : "🔊"; + M.$("#status-icon").textContent = isPlaying ? "⏸" : "▶"; + + // Show/hide controls based on permissions + const hasControl = M.canControl(); + M.$("#status-icon").style.cursor = hasControl || !M.synced ? "pointer" : "default"; + }; + + // Update auth-related UI + M.updateAuthUI = function() { + if (M.currentUser) { + M.$("#login-panel").classList.add("hidden"); + M.$("#player-content").classList.add("visible"); + if (M.currentUser.isGuest) { + M.$("#current-username").textContent = "Guest"; + M.$("#btn-logout").textContent = "Sign In"; + } else { + M.$("#current-username").textContent = M.currentUser.username; + M.$("#btn-logout").textContent = "Logout"; + } + M.$("#admin-badge").style.display = M.currentUser.isAdmin ? "inline" : "none"; + } else { + M.$("#login-panel").classList.remove("hidden"); + M.$("#player-content").classList.remove("visible"); + // Pause and unsync when login panel is shown + if (!M.audio.paused) { + M.localTimestamp = M.audio.currentTime; + M.audio.pause(); + } + if (M.synced && M.ws) { + M.synced = false; + M.ws.close(); + M.ws = null; + } + // Show guest button if server allows guests + if (M.serverStatus?.allowGuests) { + M.$("#guest-section").classList.remove("hidden"); + } else { + M.$("#guest-section").classList.add("hidden"); + } + } + M.updateUI(); + }; + + // Progress bar and buffer update loop (250ms interval) + setInterval(() => { + if (M.serverTrackDuration <= 0) return; + let t, dur; + if (M.synced) { + t = M.audio.paused ? M.getServerTime() : M.audio.currentTime; + dur = M.audio.duration || M.serverTrackDuration; + } else { + t = M.audio.paused ? M.localTimestamp : M.audio.currentTime; + dur = M.audio.duration || M.serverTrackDuration; + } + const pct = Math.min((t / dur) * 100, 100); + if (Math.abs(pct - M.lastProgressPct) > 0.1) { + M.$("#progress-bar").style.width = pct + "%"; + M.lastProgressPct = pct; + } + + const timeCurrent = M.fmt(t); + const timeTotal = M.fmt(dur); + if (timeCurrent !== M.lastTimeCurrent) { + M.$("#time-current").textContent = timeCurrent; + M.lastTimeCurrent = timeCurrent; + } + if (timeTotal !== M.lastTimeTotal) { + M.$("#time-total").textContent = timeTotal; + M.lastTimeTotal = timeTotal; + } + + // Update buffer segments + const segments = M.$("#buffer-bar").children; + const segmentDur = dur / M.SEGMENTS; + let availableCount = 0; + const trackCache = M.getTrackCache(M.currentTrackId); + for (let i = 0; i < M.SEGMENTS; i++) { + const segStart = i * segmentDur; + const segEnd = (i + 1) * segmentDur; + let available = trackCache.has(i); // Check our cache first + if (!available) { + // Check browser's native buffer + for (let j = 0; j < M.audio.buffered.length; j++) { + const bufStart = M.audio.buffered.start(j); + const bufEnd = M.audio.buffered.end(j); + if (bufStart <= segStart && bufEnd >= segEnd) { + available = true; + // Sync browser buffer to our trackCache + trackCache.add(i); + break; + } + } + } + if (available) availableCount++; + const isAvailable = segments[i].classList.contains("available"); + const isLoading = segments[i].classList.contains("loading"); + const shouldBeLoading = !available && M.loadingSegments.has(i); + if (available !== isAvailable) segments[i].classList.toggle("available", available); + if (shouldBeLoading !== isLoading) segments[i].classList.toggle("loading", shouldBeLoading); + } + + // Check if all segments now cached - trigger full cache + if (trackCache.size >= M.SEGMENTS && !M.cachedTracks.has(M.currentTrackId)) { + M.checkAndCacheComplete(M.currentTrackId); + } + + // Update download speed display + const kbps = M.downloadSpeed > 0 ? M.downloadSpeed * 8 / 1000 : 0; + const bufferPct = Math.round(availableCount / M.SEGMENTS * 100); + let speedText = ""; + if (kbps > 0) { + speedText = kbps >= 1024 ? ` | ${(kbps / 1024).toFixed(1)} Mb/s` : ` | ${Math.round(kbps)} kb/s`; + } + if (bufferPct !== M.lastBufferPct || speedText !== M.lastSpeedText) { + M.$("#download-speed").textContent = `${bufferPct}% buffered${speedText}`; + M.lastBufferPct = bufferPct; + M.lastSpeedText = speedText; + } + }, 250); + + // Prefetch loop (1s interval) + setInterval(() => { + if (M.currentTrackId && M.audio.src) { + M.prefetchSegments(); + } + }, 1000); + + // Cache status check (5s interval) - updates indicators when tracks finish caching + let lastCacheSize = 0; + setInterval(async () => { + const currentSize = M.cachedTracks.size; + if (currentSize !== lastCacheSize) { + lastCacheSize = currentSize; + M.renderQueue(); + M.renderLibrary(); + } + }, 5000); +})(); diff --git a/public/upload.js b/public/upload.js new file mode 100644 index 0000000..7e3c33d --- /dev/null +++ b/public/upload.js @@ -0,0 +1,359 @@ +(function() { + const M = window.MusicRoom; + + document.addEventListener("DOMContentLoaded", () => { + const addBtn = M.$("#btn-add"); + const addPanel = M.$("#add-panel"); + const addCloseBtn = M.$("#btn-add-close"); + const uploadFilesBtn = M.$("#btn-upload-files"); + const fileInput = M.$("#file-input"); + const dropzone = M.$("#upload-dropzone"); + const libraryPanel = M.$("#library-panel"); + const tasksList = M.$("#tasks-list"); + const tasksEmpty = M.$("#tasks-empty"); + + if (!addBtn || !fileInput || !dropzone) return; + + function openPanel() { + addPanel.classList.remove("hidden", "closing"); + addBtn.classList.add("hidden"); + } + + function closePanel() { + addPanel.classList.add("closing"); + addBtn.classList.remove("hidden"); + addPanel.addEventListener("animationend", () => { + if (addPanel.classList.contains("closing")) { + addPanel.classList.add("hidden"); + addPanel.classList.remove("closing"); + } + }, { once: true }); + } + + // Open add panel + addBtn.onclick = () => { + if (!M.currentUser) { + M.showToast("Sign in to add tracks"); + return; + } + openPanel(); + }; + + // Close add panel + addCloseBtn.onclick = () => { + closePanel(); + }; + + // Upload files option + uploadFilesBtn.onclick = () => { + fileInput.click(); + }; + + // Fetch from URL option + const fetchUrlBtn = M.$("#btn-fetch-url"); + const fetchDialog = M.$("#fetch-dialog"); + const fetchCloseBtn = M.$("#btn-fetch-close"); + const fetchUrlInput = M.$("#fetch-url-input"); + const fetchSubmitBtn = M.$("#btn-fetch-submit"); + + function openFetchDialog() { + closePanel(); + fetchDialog.classList.remove("hidden", "closing"); + fetchUrlInput.value = ""; + fetchUrlInput.focus(); + } + + function closeFetchDialog() { + fetchDialog.classList.add("closing"); + fetchDialog.addEventListener("animationend", () => { + if (fetchDialog.classList.contains("closing")) { + fetchDialog.classList.add("hidden"); + fetchDialog.classList.remove("closing"); + } + }, { once: true }); + } + + if (fetchUrlBtn) { + fetchUrlBtn.onclick = openFetchDialog; + } + + if (fetchCloseBtn) { + fetchCloseBtn.onclick = closeFetchDialog; + } + + if (fetchSubmitBtn) { + fetchSubmitBtn.onclick = async () => { + const url = fetchUrlInput.value.trim(); + if (!url) { + M.showToast("Please enter a URL"); + return; + } + closeFetchDialog(); + M.showToast("Checking URL..."); + + try { + const res = await fetch("/api/fetch", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url }) + }); + + if (res.ok) { + const data = await res.json(); + + if (data.type === "playlist") { + // Ask user to confirm playlist download + const confirmed = confirm(`Download playlist "${data.title}" with ${data.count} items?\n\nItems will be downloaded slowly (one every ~3 minutes) to avoid overloading the server.`); + + if (confirmed) { + // Confirm playlist download + const confirmRes = await fetch("/api/fetch/confirm", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ items: data.items }) + }); + + if (confirmRes.ok) { + const confirmData = await confirmRes.json(); + M.showToast(confirmData.message); + // Tasks will be created by WebSocket progress messages + } else { + const err = await confirmRes.json().catch(() => ({})); + M.showToast(err.error || "Failed to queue playlist", "error"); + } + } + } else if (data.type === "single") { + M.showToast(`Queued: ${data.title}`); + // Task will be created by WebSocket progress messages + } else { + M.showToast(data.message || "Fetch started"); + } + } else { + const err = await res.json().catch(() => ({})); + M.showToast(err.error || "Fetch failed", "error"); + } + } catch (e) { + M.showToast("Fetch failed", "error"); + } + }; + } + + if (fetchUrlInput) { + fetchUrlInput.onkeydown = (e) => { + if (e.key === "Enter") fetchSubmitBtn.click(); + if (e.key === "Escape") closeFetchDialog(); + }; + } + + // File input change + fileInput.onchange = () => { + if (fileInput.files.length > 0) { + closePanel(); + uploadFiles(fileInput.files); + fileInput.value = ""; + } + }; + + // Drag and drop on library panel + let dragCounter = 0; + + libraryPanel.ondragenter = (e) => { + if (!M.currentUser) return; + if (!e.dataTransfer.types.includes("Files")) return; + e.preventDefault(); + dragCounter++; + dropzone.classList.remove("hidden"); + }; + + libraryPanel.ondragleave = (e) => { + e.preventDefault(); + dragCounter--; + if (dragCounter === 0) { + dropzone.classList.add("hidden"); + } + }; + + libraryPanel.ondragover = (e) => { + if (!M.currentUser) return; + if (!e.dataTransfer.types.includes("Files")) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + }; + + libraryPanel.ondrop = (e) => { + e.preventDefault(); + dragCounter = 0; + dropzone.classList.add("hidden"); + + if (!M.currentUser) { + M.showToast("Sign in to upload"); + return; + } + + const files = e.dataTransfer.files; + if (files.length > 0) { + uploadFiles(files); + } + }; + + // Task management + const fetchTasks = new Map(); // Map + + function updateTasksEmpty() { + const hasTasks = tasksList.children.length > 0; + tasksEmpty.classList.toggle("hidden", hasTasks); + } + + // Handle WebSocket fetch progress messages + M.handleFetchProgress = function(data) { + let task = fetchTasks.get(data.id); + + // Create task if we don't have one for this id + if (!task && data.status !== "complete" && data.status !== "error") { + task = createTask(data.title || "Downloading...", data.id); + } + + if (!task) return; + + if (data.status === "downloading" || data.status === "queued") { + task.setProgress(data.progress || 0); + } else if (data.status === "complete") { + task.setComplete(); + fetchTasks.delete(data.id); + } else if (data.status === "error") { + task.setError(data.error || "Failed"); + fetchTasks.delete(data.id); + } + }; + + function createTask(filename, fetchId) { + const task = document.createElement("div"); + task.className = "task-item"; + task.innerHTML = ` + + + ${filename} + 0% +
+ `; + tasksList.appendChild(task); + updateTasksEmpty(); + + // Switch to tasks tab + const tasksTab = document.querySelector('.panel-tab[data-tab="tasks"]'); + if (tasksTab) tasksTab.click(); + + const taskHandle = { + setProgress(percent) { + task.querySelector(".task-progress").textContent = `${Math.round(percent)}%`; + task.querySelector(".task-bar").style.width = `${percent}%`; + }, + setComplete() { + task.classList.add("complete"); + task.querySelector(".task-progress").textContent = "Done"; + task.querySelector(".task-bar").style.width = "100%"; + // Remove after delay + setTimeout(() => { + task.remove(); + updateTasksEmpty(); + }, 3000); + }, + setError(msg) { + task.classList.add("error"); + task.querySelector(".task-progress").textContent = msg || "Failed"; + // Remove after delay + setTimeout(() => { + task.remove(); + updateTasksEmpty(); + }, 5000); + } + }; + + // Store fetch tasks for WebSocket updates + if (fetchId) { + fetchTasks.set(fetchId, taskHandle); + } + + return taskHandle; + } + + function uploadFile(file) { + return new Promise((resolve) => { + const task = createTask(file.name); + const xhr = new XMLHttpRequest(); + const formData = new FormData(); + formData.append("file", file); + + xhr.upload.onprogress = (e) => { + if (e.lengthComputable) { + const percent = (e.loaded / e.total) * 100; + task.setProgress(percent); + } + }; + + xhr.onload = () => { + if (xhr.status === 200) { + task.setComplete(); + resolve({ success: true }); + } else if (xhr.status === 409) { + task.setError("Duplicate"); + resolve({ success: false, duplicate: true }); + } else { + task.setError("Failed"); + resolve({ success: false }); + } + }; + + xhr.onerror = () => { + task.setError("Error"); + resolve({ success: false }); + }; + + xhr.open("POST", "/api/upload"); + xhr.send(formData); + }); + } + + async function uploadFiles(files) { + const validExts = [".mp3", ".ogg", ".flac", ".wav", ".m4a", ".aac", ".opus", ".wma", ".mp4"]; + const audioFiles = [...files].filter(f => { + const ext = f.name.toLowerCase().match(/\.[^.]+$/)?.[0]; + return ext && validExts.includes(ext); + }); + + if (audioFiles.length === 0) { + M.showToast("No valid audio files"); + return; + } + + let uploaded = 0; + let failed = 0; + + // Upload files in parallel (max 3 concurrent) + const concurrency = 3; + const queue = [...audioFiles]; + const active = []; + + while (queue.length > 0 || active.length > 0) { + while (active.length < concurrency && queue.length > 0) { + const file = queue.shift(); + const promise = uploadFile(file).then(result => { + if (result.success) uploaded++; + else failed++; + active.splice(active.indexOf(promise), 1); + }); + active.push(promise); + } + if (active.length > 0) { + await Promise.race(active); + } + } + + if (uploaded > 0) { + M.showToast(`Uploaded ${uploaded} track${uploaded > 1 ? 's' : ''}${failed > 0 ? `, ${failed} failed` : ''}`); + } else if (failed > 0) { + M.showToast(`Upload failed`); + } + } + }); +})(); diff --git a/public/utils.js b/public/utils.js new file mode 100644 index 0000000..68495cb --- /dev/null +++ b/public/utils.js @@ -0,0 +1,121 @@ +// MusicRoom - Utilities module +// DOM helpers, formatting, toast notifications + +(function() { + const M = window.MusicRoom; + + // DOM selector helper + M.$ = (s) => document.querySelector(s); + + // Format seconds as m:ss + M.fmt = function(sec) { + if (!sec || !isFinite(sec)) return "0:00"; + const m = Math.floor(sec / 60); + const s = Math.floor(sec % 60); + return m + ":" + String(s).padStart(2, "0"); + }; + + // Toast history + M.toastHistory = []; + + // Toast notifications (log style - multiple visible) + M.showToast = function(message, type = "info", duration = 5000) { + const container = M.$("#toast-container"); + const toast = document.createElement("div"); + toast.className = "toast toast-" + type; + toast.textContent = message; + container.appendChild(toast); + setTimeout(() => { + toast.classList.add("fade-out"); + setTimeout(() => toast.remove(), 300); + }, duration); + + // Add to history + M.toastHistory.push({ + message, + type, + time: new Date() + }); + M.updateToastHistory(); + }; + + // Update toast history panel + M.updateToastHistory = function() { + const list = M.$("#toast-history-list"); + if (!list) return; + + list.innerHTML = ""; + // Show newest first + const items = [...M.toastHistory].reverse().slice(0, 50); + for (const item of items) { + const div = document.createElement("div"); + div.className = "history-item history-" + item.type; + const time = item.time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + div.innerHTML = `${time} ${item.message}`; + list.appendChild(div); + } + }; + + // Toggle toast history panel + M.toggleToastHistory = function() { + const panel = M.$("#toast-history"); + if (panel) { + panel.classList.toggle("hidden"); + if (!panel.classList.contains("hidden")) { + M.updateToastHistory(); + } + } + }; + + // Flash permission denied animation + M.flashPermissionDenied = function() { + const row = M.$("#progress-row"); + row.classList.remove("denied"); + void row.offsetWidth; // Trigger reflow to restart animation + row.classList.add("denied"); + setTimeout(() => row.classList.remove("denied"), 500); + }; + + // Set track title (UI and document title) + M.setTrackTitle = function(title) { + M.currentTitle = title; + const containerEl = M.$("#track-name"); + const marqueeEl = containerEl?.querySelector(".marquee-inner"); + + if (!containerEl || !marqueeEl) return; + + document.title = title ? `${title} - MusicRoom` : "MusicRoom"; + + // First set simple content to measure + marqueeEl.innerHTML = `${title}`; + + // Check if title overflows and needs scrolling + requestAnimationFrame(() => { + const titleEl = M.$("#track-title"); + const needsScroll = titleEl && titleEl.scrollWidth > containerEl.clientWidth; + containerEl.classList.toggle("scrolling", needsScroll); + + // Duplicate text for seamless wrap-around scrolling + if (needsScroll) { + marqueeEl.innerHTML = `${title}   •   ${title}   •   `; + } + }); + }; + + // Get current server time (extrapolated) + M.getServerTime = function() { + if (M.serverPaused) return M.serverTimestamp; + return M.serverTimestamp + (Date.now() - M.lastServerUpdate) / 1000; + }; + + // Check if current user can control playback + M.canControl = function() { + if (!M.currentUser) return false; + if (M.currentUser.isAdmin) return true; + return M.currentUser.permissions?.some(p => + p.resource_type === "channel" && + (p.resource_id === M.currentChannelId || p.resource_id === null) && + p.permission === "control" + ); + }; +})(); diff --git a/server.ts b/server.ts new file mode 100644 index 0000000..0c24ce1 --- /dev/null +++ b/server.ts @@ -0,0 +1,1191 @@ +import { file, serve, type ServerWebSocket } from "bun"; +import { Channel, type Track, type WsData, type PersistenceCallback } from "./channel"; +import { readdir } from "fs/promises"; +import { join, resolve } from "path"; +import { + createUser, + findUserByUsername, + validatePassword, + createSession, + createGuestSession, + deleteSession, + validateSession, + hasPermission, + getUserPermissions, + getAllUsers, + grantPermission, + revokePermission, + findUserById, + saveChannel, + updateChannelState, + loadAllChannels, + deleteChannelFromDb, + saveChannelQueue, + loadChannelQueue, + removeTrackFromQueues, +} from "./db"; +import { + getUser, + requireUser, + requirePermission, + setSessionCookie, + clearSessionCookie, + getClientInfo, +} from "./auth"; +import { Library } from "./library"; +import { + initYtdlp, + getStatus as getYtdlpStatus, + isAvailable as isYtdlpAvailable, + checkUrl, + addToFastQueue, + addToSlowQueue, + getUserQueues, + setProgressCallback, + type QueueItem +} from "./ytdlp"; + +// Load config +interface YtdlpConfig { + enabled: boolean; + command: string; + ffmpegCommand: string; + updateCommand: string | null; + fastQueueConcurrent: number; + slowQueueInterval: number; + allowPlaylists: boolean; + autoUpdate: boolean; + updateCheckInterval: number; +} + +interface Config { + port: number; + musicDir: string; + allowGuests: boolean; + defaultPermissions: string[]; + ytdlp?: YtdlpConfig; +} + +const CONFIG_PATH = join(import.meta.dir, "config.json"); + +const DEFAULT_CONFIG: Config = { + port: 3001, + musicDir: "./music", + allowGuests: true, + defaultPermissions: ["listen", "control"], + ytdlp: { + enabled: false, + command: "yt-dlp", + ffmpegCommand: "ffmpeg", + updateCommand: "yt-dlp -U", + fastQueueConcurrent: 2, + slowQueueInterval: 180, + allowPlaylists: true, + autoUpdate: true, + updateCheckInterval: 86400 + } +}; + +// Create default config if missing +const configFile = file(CONFIG_PATH); +if (!(await configFile.exists())) { + console.log("[Config] Creating default config.json..."); + await Bun.write(CONFIG_PATH, JSON.stringify(DEFAULT_CONFIG, null, 2)); + console.log("Config created at config.json. Have a look at it then restart the server. Bye!"); + process.exit(); +} + +const config: Config = await configFile.json(); + +const MUSIC_DIR = resolve(import.meta.dir, config.musicDir); +const PUBLIC_DIR = join(import.meta.dir, "public"); + +console.log(`Config loaded: port=${config.port}, musicDir=${MUSIC_DIR}, allowGuests=${config.allowGuests}`); + +// Initialize yt-dlp if configured +const ytdlpConfig = config.ytdlp || DEFAULT_CONFIG.ytdlp!; +const ytdlpStatus = await initYtdlp({ + enabled: ytdlpConfig.enabled, + command: ytdlpConfig.command, + ffmpegCommand: ytdlpConfig.ffmpegCommand, + musicDir: MUSIC_DIR, + fastQueueConcurrent: ytdlpConfig.fastQueueConcurrent, + slowQueueInterval: ytdlpConfig.slowQueueInterval, + allowPlaylists: ytdlpConfig.allowPlaylists +}); + +// Initialize library +const library = new Library(MUSIC_DIR); + +// Auto-discover tracks if queue is empty +async function discoverTracks(): Promise { + try { + const files = await readdir(MUSIC_DIR); + return files.filter((f) => /\.(mp3|ogg|flac|wav|m4a|aac)$/i.test(f)).sort(); + } catch { + return []; + } +} + +// 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(); + +// Track all WebSocket connections by user ID for kick functionality +const userConnections = new Map>>(); + +// Persistence callback for channels +const persistChannel: PersistenceCallback = (channel, type) => { + if (type === "state") { + updateChannelState(channel.id, { + currentIndex: channel.currentIndex, + startedAt: channel.startedAt, + paused: channel.paused, + pausedAt: channel.pausedAt, + playbackMode: channel.playbackMode, + }); + } else if (type === "queue") { + saveChannelQueue(channel.id, channel.queue.map(t => t.id)); + } +}; + +// Helper to build Track objects from track IDs using library +function buildTracksFromIds(trackIds: string[], lib: Library): Track[] { + const tracks: Track[] = []; + for (const tid of trackIds) { + const libTrack = lib.getTrack(tid); + if (libTrack && libTrack.duration > 0) { + tracks.push({ + id: libTrack.id, + filename: libTrack.filename, + title: libTrack.title || libTrack.filename.replace(/\.[^.]+$/, ""), + duration: libTrack.duration, + }); + } + } + return tracks; +} + +// Helper to get all library tracks as Track objects +function getAllLibraryTracks(lib: Library): Track[] { + return lib.getAllTracks() + .filter(t => t.duration > 0) + .map(t => ({ + id: t.id, + filename: t.filename, + title: t.title || t.filename.replace(/\.[^.]+$/, ""), + duration: t.duration, + })); +} + +async function init(): Promise { + // Scan library first + await library.scan(); + library.startWatching(); + + // Broadcast when scan completes + library.onScanComplete(() => { + broadcastToAll({ type: "scan_progress", scanning: false }); + }); + + // Broadcast when tracks are added/updated + library.on("added", (track) => { + broadcastToAll({ type: "toast", message: `Added: ${track.title || track.filename}`, toastType: "info" }); + library.logActivity("scan_added", { id: track.id, filename: track.filename, title: track.title }); + }); + library.on("changed", (track) => { + broadcastToAll({ type: "toast", message: `Updated: ${track.title || track.filename}`, toastType: "info" }); + library.logActivity("scan_updated", { id: track.id, filename: track.filename, title: track.title }); + }); + + // Load channels from database + const savedChannels = loadAllChannels(); + let hasDefault = false; + + for (const row of savedChannels) { + // Load queue for this channel + const trackIds = loadChannelQueue(row.id); + const tracks = buildTracksFromIds(trackIds, library); + + // For default channel, if queue is empty, use full library + const isDefault = row.is_default === 1; + if (isDefault) { + hasDefault = true; + } + + const channelTracks = (isDefault && tracks.length === 0) + ? getAllLibraryTracks(library) + : tracks; + + const channel = new Channel({ + id: row.id, + name: row.name, + description: row.description, + tracks: channelTracks, + createdBy: row.created_by, + isDefault, + currentIndex: row.current_index, + startedAt: row.started_at, + paused: row.paused === 1, + pausedAt: row.paused_at, + playbackMode: (row.playback_mode as "repeat-all" | "repeat-one" | "shuffle") || "repeat-all", + }); + + channel.setPersistenceCallback(persistChannel); + channels.set(row.id, channel); + console.log(`Loaded channel "${row.name}" (id=${row.id}) with ${channelTracks.length} tracks`); + } + + // Create default channel if it doesn't exist + if (!hasDefault) { + const tracks = getAllLibraryTracks(library); + const defaultChannel = new Channel({ + id: "main", + name: "Main Channel", + description: "All tracks from the library", + tracks, + isDefault: true, + createdBy: null, + }); + + defaultChannel.setPersistenceCallback(persistChannel); + channels.set("main", defaultChannel); + + // Save to database + saveChannel({ + id: defaultChannel.id, + name: defaultChannel.name, + description: defaultChannel.description, + createdBy: defaultChannel.createdBy, + isDefault: true, + currentIndex: defaultChannel.currentIndex, + startedAt: defaultChannel.startedAt, + paused: defaultChannel.paused, + pausedAt: defaultChannel.pausedAt, + playbackMode: defaultChannel.playbackMode, + }); + saveChannelQueue(defaultChannel.id, tracks.map(t => t.id)); + + console.log(`Default channel created: ${tracks.length} tracks`); + } +} + +await init(); + +// Broadcast to all connected clients across all channels +function broadcastToAll(message: object) { + const data = JSON.stringify(message); + 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`); +} + +// Send message to specific user's connections +function sendToUser(userId: number, message: object) { + const connections = userConnections.get(userId); + if (connections) { + const data = JSON.stringify(message); + for (const ws of connections) { + ws.send(data); + } + } +} + +// Set up ytdlp progress callback +setProgressCallback((item) => { + sendToUser(item.userId, { + type: `fetch_${item.status === "downloading" ? "progress" : item.status}`, + id: item.id, + title: item.title, + status: item.status, + progress: item.progress, + queueType: item.queueType, + error: item.error + }); +}); + +// 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)`, JSON.stringify(list.map(c => ({ id: c.id, listeners: c.listeners })))); + broadcastToAll({ type: "channel_list", channels: list }); +} + +// Listen for library changes and notify clients +library.on("added", (track) => { + console.log(`New track detected: ${track.title}`); + + const allTracks = library.getAllTracks().map(t => ({ + id: t.id, + title: t.title, + duration: t.duration + })); + broadcastToAll({ + type: "track_added", + track: { id: track.id, title: track.title, duration: track.duration }, + library: allTracks + }); +}); + +library.on("removed", (track) => { + console.log(`Track removed: ${track.title}`); + + // Remove from database queue entries + removeTrackFromQueues(track.id); + + const allTracks = library.getAllTracks().map(t => ({ + id: t.id, + title: t.title, + duration: t.duration + })); + broadcastToAll({ + type: "track_removed", + track: { id: track.id, title: track.title }, + library: allTracks + }); +}); + +// Tick interval: advance tracks when needed, broadcast every 30s +let tickCount = 0; +setInterval(() => { + tickCount++; + 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) { + console.log(`[Tick] Broadcasting state for channel "${channel.name}" (${channel.clients.size} clients)`); + channel.broadcast(); + } + } + + // Broadcast scan progress every 2 seconds while scanning + const scanProgress = library.scanProgress; + if (scanProgress.scanning && tickCount % 2 === 0) { + broadcastToAll({ + type: "scan_progress", + scanning: true, + processed: scanProgress.processed, + total: scanProgress.total + }); + } else if (!scanProgress.scanning && tickCount % 30 === 0) { + // Periodically send "not scanning" to clear any stale UI + broadcastToAll({ type: "scan_progress", scanning: false }); + } +}, 1000); + +// Helper to get or create guest session +function getOrCreateUser(req: Request, server: any): { user: ReturnType, headers?: Headers } { + let user = getUser(req, server); + if (user) return { user }; + + if (config.allowGuests) { + const { userAgent, ipAddress } = getClientInfo(req, server); + const guest = createGuestSession(userAgent, ipAddress); + console.log(`[AUTH] Guest session created: user="${guest.user.username}" id=${guest.user.id} ip=${ipAddress}`); + const headers = new Headers(); + headers.set("Set-Cookie", setSessionCookie(guest.token)); + return { user: guest.user, headers }; + } + + return { user: null }; +} + +// Check if user has permission (including default permissions) +function userHasPermission(user: ReturnType, resourceType: string, resourceId: string | null, permission: string): boolean { + if (!user) return false; + if (user.is_admin) return true; + + // Guests can never control playback + if (user.is_guest && permission === "control") return false; + + // Check default permissions from config + if (resourceType === "channel" && config.defaultPermissions?.includes(permission)) { + return true; + } + + // Check user-specific permissions + return hasPermission(user.id, resourceType, resourceId, permission); +} + +serve({ + port: config.port, + async fetch(req, server) { + const url = new URL(req.url); + const path = url.pathname; + + // WebSocket upgrade for channels + if (path.match(/^\/api\/channels\/([^/]+)\/ws$/)) { + const id = path.split("/")[3]; + if (!channels.has(id)) return new Response("Channel not found", { status: 404 }); + const { user } = getOrCreateUser(req, server); + const ok = server.upgrade(req, { data: { channelId: id, userId: user?.id ?? null, username: user?.username ?? 'Guest' } }); + if (ok) return undefined; + return new Response("WebSocket upgrade failed", { status: 500 }); + } + + // API: server status (public) + if (path === "/api/status") { + return Response.json({ + name: "MusicRoom", + version: "1.0.0", + allowGuests: config.allowGuests, + allowSignups: true, + channelCount: channels.size, + defaultPermissions: config.defaultPermissions, + ytdlp: getYtdlpStatus() + }); + } + + // 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 = [...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 }); + } + if (name.trim().length > 64) { + return Response.json({ error: "Name must be 64 characters or less" }, { 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 empty playlist + tracks = []; + } + + const channelId = generateChannelId(); + const channel = new Channel({ + id: channelId, + name: name.trim(), + description: description || "", + tracks, + createdBy: user.id, + isDefault: false, + }); + channel.setPersistenceCallback(persistChannel); + channels.set(channelId, channel); + + // Save to database + saveChannel({ + id: channel.id, + name: channel.name, + description: channel.description, + createdBy: channel.createdBy, + isDefault: false, + currentIndex: channel.currentIndex, + startedAt: channel.startedAt, + paused: channel.paused, + pausedAt: channel.pausedAt, + playbackMode: channel.playbackMode, + }); + saveChannelQueue(channel.id, tracks.map(t => t.id)); + + 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 }); + } + + // Move connected clients to default channel before deleting + const defaultChannel = [...channels.values()].find(c => c.isDefault); + if (defaultChannel && channel.clients.size > 0) { + for (const ws of channel.clients) { + channel.removeClient(ws); + ws.data.channelId = defaultChannel.id; + defaultChannel.addClient(ws); + ws.send(JSON.stringify({ type: "switched", channelId: defaultChannel.id })); + } + } + + channels.delete(channelId); + deleteChannelFromDb(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); + if (!user) { + return Response.json({ error: "Authentication required" }, { status: 401 }); + } + const tracks = library.getAllTracks().map(t => ({ + id: t.id, + filename: t.filename, + title: t.title, + artist: t.artist, + album: t.album, + duration: t.duration, + available: t.available, + })); + return Response.json(tracks, { headers }); + } + + // API: upload audio file + if (path === "/api/upload" && req.method === "POST") { + const { user, headers } = getOrCreateUser(req, server); + if (!user) { + return Response.json({ error: "Authentication required" }, { status: 401 }); + } + + try { + const formData = await req.formData(); + const uploadedFile = formData.get("file"); + + if (!uploadedFile || !(uploadedFile instanceof File)) { + library.logActivity("upload_failed", { filename: "unknown" }, { id: user.id, username: user.username }); + return Response.json({ error: "No file provided" }, { status: 400 }); + } + + // Validate extension + const validExts = [".mp3", ".ogg", ".flac", ".wav", ".m4a", ".aac", ".opus", ".wma", ".mp4"]; + const ext = uploadedFile.name.toLowerCase().match(/\.[^.]+$/)?.[0]; + if (!ext || !validExts.includes(ext)) { + library.logActivity("upload_rejected", { filename: uploadedFile.name }, { id: user.id, username: user.username }); + return Response.json({ error: "Invalid audio format" }, { status: 400 }); + } + + // Sanitize filename + const safeName = uploadedFile.name.replace(/[^a-zA-Z0-9._-]/g, "_"); + const destPath = join(config.musicDir, safeName); + + // Check if file already exists + const existingFile = Bun.file(destPath); + if (await existingFile.exists()) { + library.logActivity("upload_duplicate", { filename: safeName }, { id: user.id, username: user.username }); + return Response.json({ error: "File already exists" }, { status: 409 }); + } + + // Write file + const arrayBuffer = await uploadedFile.arrayBuffer(); + await Bun.write(destPath, arrayBuffer); + + console.log(`[Upload] ${user.username} uploaded: ${safeName}`); + library.logActivity("upload", { filename: safeName }, { id: user.id, username: user.username }); + return Response.json({ success: true, filename: safeName }, { headers }); + } catch (e) { + console.error("[Upload] Error:", e); + library.logActivity("upload_error", { filename: "unknown" }, { id: user.id, username: user.username }); + return Response.json({ error: "Upload failed" }, { status: 500 }); + } + } + + // API: fetch from URL (yt-dlp) + if (path === "/api/fetch" && req.method === "POST") { + const { user, headers } = getOrCreateUser(req, server); + if (!user) { + return Response.json({ error: "Authentication required" }, { status: 401 }); + } + if (user.is_guest) { + return Response.json({ error: "Guests cannot fetch from URLs" }, { status: 403 }); + } + + // Check if feature is enabled + if (!ytdlpConfig.enabled) { + return Response.json({ error: "Feature disabled" }, { status: 403 }); + } + if (!isYtdlpAvailable()) { + return Response.json({ error: "yt-dlp not available" }, { status: 503 }); + } + + try { + const { url } = await req.json(); + if (!url || typeof url !== "string") { + return Response.json({ error: "URL is required" }, { status: 400 }); + } + + console.log(`[Fetch] ${user.username} checking URL: ${url}`); + + // Check URL to detect playlist vs single video + const info = await checkUrl(url); + + if (info.type === "playlist") { + if (!ytdlpConfig.allowPlaylists) { + return Response.json({ error: "Playlist downloads are disabled" }, { status: 403 }); + } + // Return playlist info for confirmation + return Response.json(info, { headers }); + } else { + // Single video - add to fast queue immediately + const item = addToFastQueue(info.url, info.title, user.id); + console.log(`[Fetch] ${user.username} queued: ${info.title} (id=${item.id})`); + return Response.json({ + type: "single", + id: item.id, + title: item.title, + queueType: "fast" + }, { headers }); + } + } catch (e: any) { + console.error("[Fetch] Error:", e); + return Response.json({ error: e.message || "Invalid request" }, { status: 400 }); + } + } + + // API: confirm playlist download + if (path === "/api/fetch/confirm" && req.method === "POST") { + const { user, headers } = getOrCreateUser(req, server); + if (!user) { + return Response.json({ error: "Authentication required" }, { status: 401 }); + } + if (user.is_guest) { + return Response.json({ error: "Guests cannot fetch from URLs" }, { status: 403 }); + } + if (!ytdlpConfig.enabled || !isYtdlpAvailable()) { + return Response.json({ error: "Feature not available" }, { status: 503 }); + } + + try { + const { items } = await req.json(); + if (!Array.isArray(items) || items.length === 0) { + return Response.json({ error: "Items required" }, { status: 400 }); + } + + const queueItems = addToSlowQueue(items, user.id); + const estimatedMinutes = Math.ceil(queueItems.length * ytdlpConfig.slowQueueInterval / 60); + const hours = Math.floor(estimatedMinutes / 60); + const mins = estimatedMinutes % 60; + const estimatedTime = hours > 0 ? `${hours}h ${mins}m` : `${mins}m`; + + console.log(`[Fetch] ${user.username} confirmed playlist: ${queueItems.length} items`); + + return Response.json({ + message: `Added ${queueItems.length} items to queue`, + queueType: "slow", + estimatedTime, + items: queueItems.map(i => ({ id: i.id, title: i.title })) + }, { headers }); + } catch (e) { + console.error("[Fetch] Confirm error:", e); + return Response.json({ error: "Invalid request" }, { status: 400 }); + } + } + + // API: get fetch queue status for current user + if (path === "/api/fetch" && req.method === "GET") { + const { user, headers } = getOrCreateUser(req, server); + if (!user) { + return Response.json({ error: "Authentication required" }, { status: 401 }); + } + + const queues = getUserQueues(user.id); + return Response.json(queues, { headers }); + } + + // Auth: signup + if (path === "/api/auth/signup" && req.method === "POST") { + try { + const { username, password } = await req.json(); + if (!username || !password) { + return Response.json({ error: "Username and password required" }, { status: 400 }); + } + if (username.length < 3 || password.length < 6) { + return Response.json({ error: "Username min 3 chars, password min 6 chars" }, { status: 400 }); + } + const existing = findUserByUsername(username); + if (existing) { + return Response.json({ error: "Username already taken" }, { status: 400 }); + } + const user = await createUser(username, password); + const userAgent = req.headers.get("user-agent") ?? undefined; + const ipAddress = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() + ?? req.headers.get("x-real-ip") + ?? server.requestIP(req)?.address + ?? undefined; + const token = createSession(user.id, userAgent, ipAddress); + console.log(`[AUTH] Signup: user="${username}" id=${user.id} admin=${user.is_admin} session=${token} ip=${ipAddress} ua="${userAgent?.slice(0, 50)}..."`); + library.logActivity("account_created", { title: user.is_admin ? "admin" : "user" }, { id: user.id, username: user.username }); + return Response.json( + { user: { id: user.id, username: user.username, isAdmin: user.is_admin } }, + { headers: { "Set-Cookie": setSessionCookie(token) } } + ); + } catch (e) { + return Response.json({ error: "Signup failed" }, { status: 500 }); + } + } + + // Auth: login + if (path === "/api/auth/login" && req.method === "POST") { + try { + const { username, password } = await req.json(); + const user = findUserByUsername(username); + if (!user || !(await validatePassword(user, password))) { + console.log(`[AUTH] Login failed: user="${username}"`); + return Response.json({ error: "Invalid username or password" }, { status: 401 }); + } + const userAgent = req.headers.get("user-agent") ?? undefined; + const ipAddress = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() + ?? req.headers.get("x-real-ip") + ?? server.requestIP(req)?.address + ?? undefined; + const token = createSession(user.id, userAgent, ipAddress); + console.log(`[AUTH] Login: user="${username}" id=${user.id} session=${token} ip=${ipAddress} ua="${userAgent?.slice(0, 50)}..."`); + return Response.json( + { user: { id: user.id, username: user.username, isAdmin: user.is_admin } }, + { headers: { "Set-Cookie": setSessionCookie(token) } } + ); + } catch (e) { + return Response.json({ error: "Login failed" }, { status: 500 }); + } + } + + // Auth: logout + if (path === "/api/auth/logout" && req.method === "POST") { + const token = req.headers.get("cookie")?.match(/blastoise_session=([^;]+)/)?.[1]; + if (token) { + const user = validateSession(token); + console.log(`[AUTH] Logout: user="${user?.username ?? "unknown"}" session=${token}`); + deleteSession(token); + } + return Response.json( + { success: true }, + { headers: { "Set-Cookie": clearSessionCookie() } } + ); + } + + // Auth: get current user + if (path === "/api/auth/me") { + const { user, headers } = getOrCreateUser(req, server); + if (!user) { + return Response.json({ user: null }); + } + const permissions = getUserPermissions(user.id); + // Add default permissions for all users (except control for guests) + const effectivePermissions = [...permissions]; + if (config.defaultPermissions) { + for (const perm of config.defaultPermissions) { + // Guests can never have control permission + if (user.is_guest && perm === "control") continue; + effectivePermissions.push({ + id: 0, + user_id: user.id, + resource_type: "channel", + resource_id: null, + permission: perm, + }); + } + } + return Response.json({ + user: { id: user.id, username: user.username, isAdmin: user.is_admin, isGuest: user.is_guest }, + permissions: effectivePermissions, + }, { headers }); + } + + // Kick all other clients for current user + if (path === "/api/auth/kick-others" && req.method === "POST") { + const { user } = getOrCreateUser(req, server); + if (!user) { + return Response.json({ error: "Not authenticated" }, { status: 401 }); + } + + const connections = userConnections.get(user.id); + if (!connections || connections.size === 0) { + return Response.json({ kicked: 0 }); + } + + // Get the current request's session to identify which connection NOT to kick + const token = req.headers.get("Cookie")?.match(/session=([^;]+)/)?.[1]; + let kickedCount = 0; + + for (const ws of connections) { + // Send kick message to all connections (client will handle it) + ws.send(JSON.stringify({ type: "kick", reason: "Kicked by another session" })); + kickedCount++; + } + + console.log(`[Kick] User ${user.username} kicked ${kickedCount} other clients`); + return Response.json({ kicked: kickedCount }); + } + + // Admin: list users + if (path === "/api/admin/users" && req.method === "GET") { + try { + requirePermission(req, "global", null, "admin", server); + return Response.json(getAllUsers()); + } catch (e) { + if (e instanceof Response) return e; + return Response.json({ error: "Failed" }, { status: 500 }); + } + } + + // Admin: grant permission + if (path.match(/^\/api\/admin\/users\/(\d+)\/permissions$/) && req.method === "POST") { + try { + requirePermission(req, "global", null, "admin", server); + const userId = parseInt(path.split("/")[4]); + const { resourceType, resourceId, permission } = await req.json(); + grantPermission(userId, resourceType, resourceId, permission); + return Response.json({ success: true }); + } catch (e) { + if (e instanceof Response) return e; + return Response.json({ error: "Failed" }, { status: 500 }); + } + } + + // Admin: revoke permission + if (path.match(/^\/api\/admin\/users\/(\d+)\/permissions$/) && req.method === "DELETE") { + try { + requirePermission(req, "global", null, "admin", server); + const userId = parseInt(path.split("/")[4]); + const { resourceType, resourceId, permission } = await req.json(); + revokePermission(userId, resourceType, resourceId, permission); + return Response.json({ success: true }); + } catch (e) { + if (e instanceof Response) return e; + return Response.json({ error: "Failed" }, { status: 500 }); + } + } + + // API: jump to track in queue + const jumpMatch = path.match(/^\/api\/channels\/([^/]+)\/jump$/); + if (jumpMatch && req.method === "POST") { + const channelId = jumpMatch[1]; + const { user } = getOrCreateUser(req, server); + if (!userHasPermission(user, "channel", channelId, "control")) { + return new Response("Forbidden", { status: 403 }); + } + 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") { + channel.jumpTo(body.index); + return Response.json({ success: true }); + } + return new Response("Invalid index", { status: 400 }); + } catch { + return new Response("Invalid JSON", { status: 400 }); + } + } + + // API: seek in channel + const seekMatch = path.match(/^\/api\/channels\/([^/]+)\/seek$/); + if (seekMatch && req.method === "POST") { + const channelId = seekMatch[1]; + const { user } = getOrCreateUser(req, server); + if (!userHasPermission(user, "channel", channelId, "control")) { + return new Response("Forbidden", { status: 403 }); + } + 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") { + channel.seek(body.timestamp); + return Response.json({ success: true }); + } + return new Response("Invalid timestamp", { status: 400 }); + } catch { + return new Response("Invalid JSON", { status: 400 }); + } + } + + // API: modify channel queue (add/remove tracks) + const queueMatch = path.match(/^\/api\/channels\/([^/]+)\/queue$/); + if (queueMatch && req.method === "PATCH") { + const channelId = queueMatch[1]; + const { user } = getOrCreateUser(req, server); + if (!userHasPermission(user, "channel", channelId, "control")) { + return new Response("Forbidden", { status: 403 }); + } + const channel = channels.get(channelId); + if (!channel) return new Response("Not found", { status: 404 }); + + try { + const body = await req.json(); + const { add, remove, set } = body; + + // If 'set' is provided, replace entire queue + if (Array.isArray(set)) { + const tracks = buildTracksFromIds(set, library); + channel.setQueue(tracks); + return Response.json({ success: true, queueLength: channel.queue.length }); + } + + // Otherwise apply remove then add + if (Array.isArray(remove) && remove.length > 0) { + const indices = remove.filter((i: unknown) => typeof i === "number"); + channel.removeTracksByIndex(indices); + } + + if (Array.isArray(add) && add.length > 0) { + const tracks = buildTracksFromIds(add, library); + channel.addTracks(tracks); + } + + return Response.json({ success: true, queueLength: channel.queue.length }); + } catch { + return new Response("Invalid JSON", { status: 400 }); + } + } + + // API: set channel playback mode + const modeMatch = path.match(/^\/api\/channels\/([^/]+)\/mode$/); + if (modeMatch && req.method === "POST") { + const channelId = modeMatch[1]; + const { user } = getOrCreateUser(req, server); + if (!userHasPermission(user, "channel", channelId, "control")) { + return new Response("Forbidden", { status: 403 }); + } + const channel = channels.get(channelId); + if (!channel) return new Response("Not found", { status: 404 }); + try { + const body = await req.json(); + const validModes = ["once", "repeat-all", "repeat-one", "shuffle"]; + if (typeof body.mode === "string" && validModes.includes(body.mode)) { + channel.setPlaybackMode(body.mode); + return Response.json({ success: true, playbackMode: channel.playbackMode }); + } + return new Response("Invalid mode", { status: 400 }); + } catch { + return new Response("Invalid JSON", { status: 400 }); + } + } + + // API: serve audio file (requires auth or guest) + // Supports both filename and track ID (sha256:...) + const trackMatch = path.match(/^\/api\/tracks\/(.+)$/); + if (trackMatch) { + const { user } = getOrCreateUser(req, server); + if (!user) { + return Response.json({ error: "Authentication required" }, { status: 401 }); + } + const identifier = decodeURIComponent(trackMatch[1]); + if (identifier.includes("..")) return new Response("Forbidden", { status: 403 }); + + let filepath: string; + if (identifier.startsWith("sha256:")) { + // Track ID - look up in library + const trackPath = library.getFilePath(identifier); + if (!trackPath) return new Response("Not found", { status: 404 }); + filepath = trackPath; + } else { + // Filename - direct path + filepath = join(MUSIC_DIR, identifier); + } + + const f = file(filepath); + if (!(await f.exists())) return new Response("Not found", { status: 404 }); + + const size = f.size; + const range = req.headers.get("range"); + + if (range) { + const match = range.match(/bytes=(\d+)-(\d*)/); + if (match) { + const start = parseInt(match[1]); + const end = match[2] ? parseInt(match[2]) : size - 1; + const chunk = f.slice(start, end + 1); + return new Response(chunk, { + status: 206, + headers: { + "Content-Range": `bytes ${start}-${end}/${size}`, + "Accept-Ranges": "bytes", + "Content-Length": String(end - start + 1), + "Content-Type": f.type || "audio/mpeg", + }, + }); + } + } + + return new Response(f, { + headers: { + "Accept-Ranges": "bytes", + "Content-Length": String(size), + "Content-Type": f.type || "audio/mpeg", + }, + }); + } + + // Serve static client + if (path === "/" || path === "/index.html") { + return new Response(file(join(PUBLIC_DIR, "index.html")), { + headers: { "Content-Type": "text/html" }, + }); + } + if (path === "/styles.css") { + return new Response(file(join(PUBLIC_DIR, "styles.css")), { + headers: { "Content-Type": "text/css" }, + }); + } + // Serve JS files from public directory + if (path.endsWith(".js")) { + const jsFile = file(join(PUBLIC_DIR, path.slice(1))); + if (await jsFile.exists()) { + return new Response(jsFile, { + headers: { "Content-Type": "application/javascript" }, + }); + } + } + + return new Response("Not found", { status: 404 }); + }, + + websocket: { + open(ws: ServerWebSocket) { + const channel = channels.get(ws.data.channelId); + if (channel) { + channel.addClient(ws); + // Broadcast updated channel list to all clients + broadcastChannelList(); + } + // Track connection by user ID + const userId = ws.data.userId; + if (userId) { + if (!userConnections.has(userId)) { + userConnections.set(userId, new Set()); + } + userConnections.get(userId)!.add(ws); + } + }, + close(ws: ServerWebSocket) { + const channel = channels.get(ws.data.channelId); + if (channel) { + channel.removeClient(ws); + broadcastChannelList(); + } + // Remove from user connections tracking + const userId = ws.data.userId; + if (userId && userConnections.has(userId)) { + userConnections.get(userId)!.delete(ws); + if (userConnections.get(userId)!.size === 0) { + userConnections.delete(userId); + } + } + }, + message(ws: ServerWebSocket, message: string | Buffer) { + try { + const data = JSON.parse(String(message)); + + // 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 })); + broadcastChannelList(); + 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?.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") 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 {} + }, + }, +}); + +console.log(`MusicRoom running on http://localhost:${config.port}`); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..e7541a2 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["bun-types"], + "strict": true, + "esModuleInterop": true + } +} diff --git a/ytdlp.ts b/ytdlp.ts new file mode 100644 index 0000000..880edc3 --- /dev/null +++ b/ytdlp.ts @@ -0,0 +1,416 @@ +// MusicRoom - yt-dlp integration module +// Handles fetching audio from URLs via yt-dlp + +import { spawn } from "child_process"; +import { join } from "path"; + +export interface QueueItem { + id: string; + url: string; + title: string; + userId: number; + status: "queued" | "downloading" | "complete" | "error"; + progress: number; + queueType: "fast" | "slow"; + error?: string; + filename?: string; + createdAt: number; + completedAt?: number; +} + +export interface YtdlpStatus { + available: boolean; + enabled: boolean; + version: string | null; + ffmpeg: boolean; +} + +export interface PlaylistInfo { + type: "playlist"; + title: string; + count: number; + items: { id: string; url: string; title: string }[]; + requiresConfirmation: true; +} + +export interface SingleVideoInfo { + type: "single"; + id: string; + title: string; + url: string; +} + +type ProgressCallback = (item: QueueItem) => void; + +// Configuration +let ytdlpCommand = "yt-dlp"; +let ffmpegCommand = "ffmpeg"; +let musicDir = "./music"; +let fastQueueConcurrent = 2; +let slowQueueInterval = 180; +let allowPlaylists = true; + +// Status +let ytdlpAvailable = false; +let ytdlpVersion: string | null = null; +let ffmpegAvailable = false; +let featureEnabled = false; + +// Queues +const fastQueue: QueueItem[] = []; +const slowQueue: QueueItem[] = []; +let activeDownloads = 0; +let slowQueueTimer: ReturnType | null = null; +let lastSlowDownload = 0; + +// Callbacks +let onProgress: ProgressCallback | null = null; + +// Generate unique ID +function generateId(): string { + return Math.random().toString(36).substring(2, 10); +} + +// Initialize ytdlp module +export async function initYtdlp(config: { + enabled: boolean; + command: string; + ffmpegCommand: string; + musicDir: string; + fastQueueConcurrent: number; + slowQueueInterval: number; + allowPlaylists: boolean; +}): Promise { + featureEnabled = config.enabled; + ytdlpCommand = config.command; + ffmpegCommand = config.ffmpegCommand; + musicDir = config.musicDir; + fastQueueConcurrent = config.fastQueueConcurrent; + slowQueueInterval = config.slowQueueInterval; + allowPlaylists = config.allowPlaylists; + + if (!featureEnabled) { + console.log("[ytdlp] Feature disabled in config"); + return { available: false, enabled: false, version: null, ffmpeg: false }; + } + + // Check yt-dlp availability + try { + ytdlpVersion = await runCommand(ytdlpCommand, ["--version"]); + ytdlpAvailable = true; + console.log(`[ytdlp] Found yt-dlp version: ${ytdlpVersion.trim()}`); + } catch (e) { + console.error(`[ytdlp] yt-dlp not found (command: ${ytdlpCommand})`); + ytdlpAvailable = false; + featureEnabled = false; + } + + // Check ffmpeg availability + try { + await runCommand(ffmpegCommand, ["-version"]); + ffmpegAvailable = true; + console.log("[ytdlp] ffmpeg available"); + } catch (e) { + console.warn("[ytdlp] ffmpeg not found - audio extraction may fail"); + ffmpegAvailable = false; + } + + // Start slow queue processor + if (featureEnabled) { + startSlowQueueProcessor(); + } + + return getStatus(); +} + +// Run a command and return stdout +function runCommand(cmd: string, args: string[]): Promise { + const fullCmd = `${cmd} ${args.join(" ")}`; + console.log(`[ytdlp] Running: ${fullCmd}`); + return new Promise((resolve, reject) => { + const proc = spawn(cmd, args); + let stdout = ""; + let stderr = ""; + proc.stdout.on("data", (data) => { stdout += data; }); + proc.stderr.on("data", (data) => { stderr += data; }); + proc.on("close", (code) => { + console.log(`[ytdlp] Command exited with code ${code}`); + if (code === 0) resolve(stdout); + else reject(new Error(stderr || `Exit code ${code}`)); + }); + proc.on("error", reject); + }); +} + +// Get current status +export function getStatus(): YtdlpStatus { + return { + available: ytdlpAvailable, + enabled: featureEnabled, + version: ytdlpVersion, + ffmpeg: ffmpegAvailable + }; +} + +// Check if feature is enabled and available +export function isAvailable(): boolean { + return featureEnabled && ytdlpAvailable; +} + +// Set progress callback +export function setProgressCallback(callback: ProgressCallback): void { + onProgress = callback; +} + +// Get all queue items +export function getQueues(): { fastQueue: QueueItem[]; slowQueue: QueueItem[]; slowQueueNextIn: number } { + const now = Date.now(); + const nextIn = Math.max(0, Math.floor((lastSlowDownload + slowQueueInterval * 1000 - now) / 1000)); + return { + fastQueue: [...fastQueue], + slowQueue: [...slowQueue], + slowQueueNextIn: nextIn + }; +} + +// Get queue items for a specific user +export function getUserQueues(userId: number): { fastQueue: QueueItem[]; slowQueue: QueueItem[]; slowQueueNextIn: number } { + const queues = getQueues(); + return { + fastQueue: queues.fastQueue.filter(i => i.userId === userId), + slowQueue: queues.slowQueue.filter(i => i.userId === userId), + slowQueueNextIn: queues.slowQueueNextIn + }; +} + +// Check URL and detect if it's a playlist +export async function checkUrl(url: string): Promise { + const args = ["--flat-playlist", "--dump-json", "--no-warnings", url]; + const output = await runCommand(ytdlpCommand, args); + + // Parse JSON lines + const lines = output.trim().split("\n").filter(l => l); + + if (lines.length === 0) { + throw new Error("No video found"); + } + + if (lines.length === 1) { + const data = JSON.parse(lines[0]); + if (data._type === "playlist") { + // It's a playlist with entries + const items = (data.entries || []).map((e: any) => ({ + id: generateId(), + url: e.url || e.webpage_url || `https://youtube.com/watch?v=${e.id}`, + title: e.title || "Unknown" + })); + return { + type: "playlist", + title: data.title || "Playlist", + count: items.length, + items, + requiresConfirmation: true + }; + } else { + // Single video + return { + type: "single", + id: generateId(), + title: data.title || "Unknown", + url + }; + } + } else { + // Multiple JSON lines = playlist + const items = lines.map(line => { + const data = JSON.parse(line); + return { + id: generateId(), + url: data.url || data.webpage_url || url, + title: data.title || "Unknown" + }; + }); + return { + type: "playlist", + title: "Playlist", + count: items.length, + items, + requiresConfirmation: true + }; + } +} + +// Add single video to fast queue +export function addToFastQueue(url: string, title: string, userId: number): QueueItem { + const item: QueueItem = { + id: generateId(), + url, + title, + userId, + status: "queued", + progress: 0, + queueType: "fast", + createdAt: Date.now() + }; + fastQueue.push(item); + processNextFast(); + return item; +} + +// Add items to slow queue (for playlists) +export function addToSlowQueue(items: { url: string; title: string }[], userId: number): QueueItem[] { + const queueItems: QueueItem[] = items.map(item => ({ + id: generateId(), + url: item.url, + title: item.title, + userId, + status: "queued" as const, + progress: 0, + queueType: "slow" as const, + createdAt: Date.now() + })); + slowQueue.push(...queueItems); + return queueItems; +} + +// Process next item in fast queue +function processNextFast(): void { + if (activeDownloads >= fastQueueConcurrent) return; + + const item = fastQueue.find(i => i.status === "queued"); + if (!item) return; + + activeDownloads++; + downloadItem(item).finally(() => { + activeDownloads--; + processNextFast(); + }); +} + +// Start slow queue processor +function startSlowQueueProcessor(): void { + if (slowQueueTimer) return; + + const processNext = () => { + const item = slowQueue.find(i => i.status === "queued"); + if (item) { + lastSlowDownload = Date.now(); + downloadItem(item).finally(() => { + slowQueueTimer = setTimeout(processNext, slowQueueInterval * 1000); + }); + } else { + slowQueueTimer = setTimeout(processNext, 5000); // Check again in 5s + } + }; + + // Start immediately if there are items + const hasQueued = slowQueue.some(i => i.status === "queued"); + if (hasQueued) { + processNext(); + } else { + slowQueueTimer = setTimeout(processNext, 5000); + } +} + +// Download a single item +async function downloadItem(item: QueueItem): Promise { + item.status = "downloading"; + item.progress = 0; + notifyProgress(item); + + console.log(`[ytdlp] Starting download: ${item.title} (${item.url})`); + + try { + const outputTemplate = join(musicDir, "%(title)s.%(ext)s"); + const args = [ + "-x", + "--audio-format", "mp3", + "-o", outputTemplate, + "--progress", + "--newline", + "--no-warnings", + item.url + ]; + + const fullCmd = `${ytdlpCommand} ${args.join(" ")}`; + console.log(`[ytdlp] Running: ${fullCmd}`); + + await new Promise((resolve, reject) => { + const proc = spawn(ytdlpCommand, args); + + proc.stdout.on("data", (data) => { + const line = data.toString(); + console.log(`[ytdlp] ${line.trim()}`); + // Parse progress from yt-dlp output + const match = line.match(/(\d+\.?\d*)%/); + if (match) { + item.progress = parseFloat(match[1]); + notifyProgress(item); + } + }); + + proc.stderr.on("data", (data) => { + console.error(`[ytdlp] stderr: ${data}`); + }); + + proc.on("close", (code) => { + console.log(`[ytdlp] Download finished with code ${code}`); + if (code === 0) resolve(); + else reject(new Error(`yt-dlp exited with code ${code}`)); + }); + + proc.on("error", reject); + }); + + console.log(`[ytdlp] Complete: ${item.title}`); + item.status = "complete"; + item.progress = 100; + item.completedAt = Date.now(); + notifyProgress(item); + + // Remove from queue after delay + setTimeout(() => removeFromQueue(item), 5000); + + } catch (e: any) { + item.status = "error"; + item.error = e.message || "Download failed"; + notifyProgress(item); + + // Remove from queue after delay + setTimeout(() => removeFromQueue(item), 10000); + } +} + +// Remove item from queue +function removeFromQueue(item: QueueItem): void { + if (item.queueType === "fast") { + const idx = fastQueue.findIndex(i => i.id === item.id); + if (idx !== -1) fastQueue.splice(idx, 1); + } else { + const idx = slowQueue.findIndex(i => i.id === item.id); + if (idx !== -1) slowQueue.splice(idx, 1); + } +} + +// Notify progress callback +function notifyProgress(item: QueueItem): void { + if (onProgress) { + onProgress(item); + } +} + +// Cleanup old completed/failed items +export function cleanupOldItems(maxAge: number = 3600000): void { + const now = Date.now(); + const cleanup = (queue: QueueItem[]) => { + for (let i = queue.length - 1; i >= 0; i--) { + const item = queue[i]; + if ((item.status === "complete" || item.status === "error") && + now - item.createdAt > maxAge) { + queue.splice(i, 1); + } + } + }; + cleanup(fastQueue); + cleanup(slowQueue); +}