From d3e5c249628e650b7374435be1cbb9c38ccacf82 Mon Sep 17 00:00:00 2001 From: Peterino2 Date: Thu, 5 Feb 2026 09:34:49 -0800 Subject: [PATCH] saving --- .gitignore | 40 ++ AGENTS.md | 202 +++++++ README.md | 21 + auth.ts | 67 +++ bun.lock | 57 ++ channel.ts | 336 +++++++++++ db.ts | 415 +++++++++++++ library.ts | 464 +++++++++++++++ music/music_goes_here.txt | 0 package.json | 17 + public/audioCache.js | 264 ++++++++ public/auth.js | 132 ++++ public/channelSync.js | 414 +++++++++++++ public/controls.js | 240 ++++++++ public/core.js | 67 +++ public/index.html | 156 +++++ public/init.js | 80 +++ public/queue.js | 896 ++++++++++++++++++++++++++++ public/styles.css | 252 ++++++++ public/trackStorage.js | 167 ++++++ public/ui.js | 165 +++++ public/upload.js | 359 +++++++++++ public/utils.js | 121 ++++ server.ts | 1191 +++++++++++++++++++++++++++++++++++++ tsconfig.json | 10 + ytdlp.ts | 416 +++++++++++++ 26 files changed, 6549 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 README.md create mode 100644 auth.ts create mode 100644 bun.lock create mode 100644 channel.ts create mode 100644 db.ts create mode 100644 library.ts create mode 100644 music/music_goes_here.txt create mode 100644 package.json create mode 100644 public/audioCache.js create mode 100644 public/auth.js create mode 100644 public/channelSync.js create mode 100644 public/controls.js create mode 100644 public/core.js create mode 100644 public/index.html create mode 100644 public/init.js create mode 100644 public/queue.js create mode 100644 public/styles.css create mode 100644 public/trackStorage.js create mode 100644 public/ui.js create mode 100644 public/upload.js create mode 100644 public/utils.js create mode 100644 server.ts create mode 100644 tsconfig.json create mode 100644 ytdlp.ts 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); +}