This commit is contained in:
Peterino2 2026-02-05 09:34:49 -08:00
commit d3e5c24962
26 changed files with 6549 additions and 0 deletions

40
.gitignore vendored Normal file
View File

@ -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

202
AGENTS.md Normal file
View File

@ -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<WebSocket>;
createdBy: number | null; // user ID or null for default
isDefault: boolean;
}
```
- **Default Channel**: Created on startup, plays all library tracks, cannot be deleted
- **Dynamic Channels**: Users can create channels via POST `/api/channels`
- **Channel Switching**: Clients switch channels via WebSocket `{ action: "switch", channelId }`
### Client
The player's role is simple: **play an arbitrary track by ID**. It does not manage queues or sync logic directly.
- Receives track ID and timestamp from server via WebSocket
- Downloads audio from `/api/tracks/:id`
- Syncs playback position to server timestamp
- Caches tracks locally in IndexedDB
### Library & Queue Views
Both views display tracks with real-time status indicators:
- **Green bar**: Track is fully cached locally (in IndexedDB)
- **Yellow bar**: Track is not cached
The buffer bar (below progress) shows 20 segments indicating download/buffer status:
- Segments fill as audio is buffered by browser or fetched via range requests
- When all segments are buffered, the track is automatically cached to IndexedDB
## Content-Addressed Tracks
All tracks are identified by a **content hash** (`sha256:` prefix + first 64KB hash):
| Field | Purpose |
|-------|---------|
| `track.id` | Content hash - primary key, used for API, caching, database |
| `track.filename` | Original filename - display only |
| `track.title` | Metadata title - display only |
Benefits:
- Deduplication (same file with different names = same track)
- Renaming files without breaking queues
- Reliable client-side caching by content hash
The client uses `track.id` for:
- Caching tracks in IndexedDB (`TrackStorage.set(trackId, blob)`)
- Fetching audio (`/api/tracks/:trackId`)
- Checking cache status (`M.cachedTracks.has(trackId)`)
## Client Caching System
### Segment-Based Buffering
- Tracks are divided into 20 virtual segments
- Browser's native buffering is synced to `M.trackCaches` Map
- Range requests fetch individual segments for seeking
### Full Track Caching
When all 20 segments are buffered:
1. Full track is downloaded via `downloadAndCacheTrack()`
2. Stored in IndexedDB via `TrackStorage`
3. Added to `M.cachedTracks` Set
4. UI indicators update to green
### Cache State (in-memory)
```js
M.trackCaches // Map<trackId, Set<segmentIndex>> - per-track segment status
M.cachedTracks // Set<trackId> - tracks fully cached in IndexedDB
M.trackBlobs // Map<trackId, blobUrl> - blob URLs for cached tracks
```
## Routes
```
GET / → Serves public/index.html
GET /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

21
README.md Normal file
View File

@ -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.

67
auth.ts Normal file
View File

@ -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`;
}

57
bun.lock Normal file
View File

@ -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=="],
}
}

336
channel.ts Normal file
View File

@ -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<ServerWebSocket<WsData>> = 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<string, unknown> = {
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<WsData>) {
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<WsData>) {
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,
};
}
}

415
db.ts Normal file
View File

@ -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<User> {
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<boolean> {
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<User, 'password_hash'>[] {
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<Session, 'token'>[] {
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<Session, 'token'>[];
}
// 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);
}

464
library.ts Normal file
View File

@ -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<string, string>(); // trackId -> filepath
private trackInfo = new Map<string, LibraryTrack>(); // trackId -> full info
private watcher: FSWatcher | null = null;
private eventListeners = new Map<LibraryEventType, Set<LibraryEventCallback>>();
private pendingFiles = new Map<string, NodeJS.Timeout>(); // 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<string> {
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<LibraryTrack | null> {
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<void> {
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<void> {
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<void> {
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);
}
}

View File

17
package.json Normal file
View File

@ -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"
}
}

264
public/audioCache.js Normal file
View File

@ -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;
};
})();

132
public/auth.js Normal file
View File

@ -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");
}
};
})();

414
public/channelSync.js Normal file
View File

@ -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]) =>
`<div class="listener">${name}${count > 1 ? ` <span class="listener-mult">x${count}</span>` : ""}</div>`
).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 ? `<button class="btn-delete-channel" title="Delete channel">×</button>` : "";
div.innerHTML = `
<div class="channel-header">
<span class="channel-name">${ch.name}</span>
${deleteBtn}
<span class="listener-count">${ch.listenerCount}</span>
</div>
<div class="channel-listeners">${listenersHtml}</div>
`;
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 = `<span class="spinner"></span>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();
};
})();

240
public/controls.js vendored Normal file
View File

@ -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);
}
}
};
})();

67
public/core.js Normal file
View File

@ -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: ""
};

156
public/index.html Normal file
View File

@ -0,0 +1,156 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NeoRose</title>
<link rel="stylesheet" href="styles.css?v=20">
</head>
<body>
<div id="app">
<h1>Blastoise <span id="sync-indicator"></span></h1>
<div id="login-panel">
<h2>Sign in to continue</h2>
<div class="tabs">
<button id="tab-login" class="active">Login</button>
<button id="tab-signup">Sign Up</button>
</div>
<div id="login-fields" class="form-group">
<input type="text" id="login-username" placeholder="Username">
<input type="password" id="login-password" placeholder="Password">
<button class="submit-btn" id="btn-login">Login</button>
<div id="auth-error"></div>
</div>
<div id="signup-fields" class="form-group hidden">
<input type="text" id="signup-username" placeholder="Username (min 3 chars)">
<input type="password" id="signup-password" placeholder="Password (min 6 chars)">
<button class="submit-btn" id="btn-signup">Sign Up</button>
<div id="signup-error"></div>
</div>
<div id="guest-section" class="hidden">
<div class="divider"><span>or</span></div>
<button class="guest-btn" id="btn-guest">Continue as Guest</button>
</div>
</div>
<div id="player-content">
<div id="header-row">
<div id="auth-section">
<div class="user-info">
<span class="username" id="current-username"></span>
<span class="admin-badge" id="admin-badge" style="display:none;">Admin</span>
<button id="btn-kick-others" title="Disconnect all other devices">Disconnect all my devices</button>
<button id="btn-logout">logout</button>
</div>
</div>
<div id="stream-select"></div>
</div>
<div id="main-content">
<div id="channels-panel">
<div class="panel-header">
<h3>Channels</h3>
<button id="btn-new-channel" title="New channel">+</button>
</div>
<div id="channels-list"></div>
</div>
<div id="library-panel">
<div class="panel-tabs">
<button class="panel-tab active" data-tab="library">Library</button>
<button class="panel-tab" data-tab="tasks">Tasks</button>
</div>
<div class="panel-views">
<div id="library-view" class="panel-view active">
<div class="panel-header">
<input type="text" id="library-search" placeholder="Search..." class="search-input">
<input type="file" id="file-input" multiple accept=".mp3,.ogg,.flac,.wav,.m4a,.aac,.opus,.wma,.mp4" style="display:none">
</div>
<div id="scan-progress" class="scan-progress hidden"></div>
<div id="library"></div>
<div id="add-panel" class="add-panel hidden">
<button id="btn-add-close" class="add-panel-close">Close</button>
<div class="add-panel-content">
<button id="btn-upload-files" class="add-option">Upload files...</button>
<button id="btn-fetch-url" class="add-option">Fetch from website...</button>
</div>
</div>
<div id="fetch-dialog" class="fetch-dialog hidden">
<div class="fetch-dialog-header">
<span>Fetch from URL</span>
<button id="btn-fetch-close" class="fetch-dialog-close">×</button>
</div>
<div class="fetch-dialog-content">
<input type="text" id="fetch-url-input" class="fetch-url-input" placeholder="https://youtube.com/watch?v=...">
<button id="btn-fetch-submit" class="fetch-submit-btn">Fetch</button>
</div>
</div>
<button id="btn-add" class="add-btn">Add to library...</button>
<div id="upload-dropzone" class="upload-dropzone hidden">
<div class="dropzone-content">Drop audio files here</div>
</div>
</div>
<div id="tasks-view" class="panel-view">
<div id="tasks-list"></div>
<div id="tasks-empty" class="tasks-empty">No active tasks</div>
</div>
</div>
</div>
<div id="queue-panel">
<h3 id="queue-title">Queue</h3>
<div id="now-playing-bar" class="now-playing-bar hidden" title="Click to scroll to current track"></div>
<div id="queue"></div>
</div>
</div>
<div id="player-bar">
<div id="now-playing">
<div id="channel-name"></div>
<div id="track-name" class="empty">
<span class="marquee-inner"><span id="track-title">Loading...</span></span>
</div>
</div>
<div id="player-controls">
<div id="progress-row">
<span id="btn-sync" title="Toggle sync">sync</span>
<span id="btn-prev" title="Previous track"></span>
<span id="status-icon"></span>
<span id="btn-next" title="Next track"></span>
<span id="btn-mode" class="mode-repeat-all" title="Playback mode">repeat</span>
<div id="progress-container"><div id="progress-bar"></div><div id="seek-tooltip"></div></div>
<div id="time"><span id="time-current">0:00</span>/<span id="time-total">0:00</span></div>
</div>
<div id="buffer-bar"></div>
<div id="download-speed"></div>
</div>
<div id="volume-controls">
<span id="btn-stream-only" title="Toggle stream-only mode (no caching)">stream</span>
<span id="btn-mute" title="Toggle mute">🔊</span>
<input type="range" id="volume" min="0" max="1" step="0.01" value="1">
</div>
</div>
<div id="status"></div>
</div>
<div id="toast-container"></div>
<button id="btn-history" title="Show notification history">recent notifications</button>
<div id="toast-history" class="hidden">
<div class="history-header">
<span>Notification History</span>
<button id="btn-close-history">×</button>
</div>
<div id="toast-history-list"></div>
</div>
</div>
<script src="trackStorage.js"></script>
<script src="core.js"></script>
<script src="utils.js"></script>
<script src="audioCache.js"></script>
<script src="channelSync.js"></script>
<script src="ui.js"></script>
<script src="queue.js"></script>
<script src="controls.js"></script>
<script src="auth.js"></script>
<script src="upload.js"></script>
<script src="init.js"></script>
</body>
</html>

80
public/init.js Normal file
View File

@ -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();
}
});
})();

896
public/queue.js Normal file
View File

@ -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 = '<div class="empty drop-zone">Queue empty - drag tracks here</div>';
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 ? `<span class="track-checkmark">✓</span>` : '';
const trackNum = `<span class="track-number">${i + 1}.</span>`;
div.innerHTML = `${checkmark}<span class="cache-indicator"></span>${trackNum}<span class="track-title">${title}</span><span class="track-actions"><span class="duration">${M.fmt(track.duration)}</span></span>`;
// 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 = `<span class="label">Now playing:</span> ${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 = '<div class="empty">No tracks discovered</div>';
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 = '<div class="empty">No matches</div>';
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 ? `<span class="track-checkmark">✓</span>` : '';
div.innerHTML = `${checkmark}<span class="cache-indicator"></span><span class="track-title">${title}</span><span class="track-actions"><span class="duration">${M.fmt(track.duration)}</span></span>`;
// 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();
});
}
});
})();

252
public/styles.css Normal file
View File

@ -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; }

167
public/trackStorage.js Normal file
View File

@ -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
};
})();

165
public/ui.js Normal file
View File

@ -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);
})();

359
public/upload.js Normal file
View File

@ -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<id, taskHandle>
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 = `
<span class="task-spinner"></span>
<span class="task-icon"></span>
<span class="task-name">${filename}</span>
<span class="task-progress">0%</span>
<div class="task-bar" style="width: 0%"></div>
`;
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`);
}
}
});
})();

121
public/utils.js Normal file
View File

@ -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 = `<span class="history-time">${time}</span> ${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 = `<span id="track-title">${title}</span>`;
// 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 = `<span id="track-title">${title}</span><span class="marquee-spacer">&nbsp;&nbsp;&nbsp;•&nbsp;&nbsp;&nbsp;</span><span>${title}</span><span class="marquee-spacer">&nbsp;&nbsp;&nbsp;•&nbsp;&nbsp;&nbsp;</span>`;
}
});
};
// 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"
);
};
})();

1191
server.ts Normal file

File diff suppressed because it is too large Load Diff

10
tsconfig.json Normal file
View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"types": ["bun-types"],
"strict": true,
"esModuleInterop": true
}
}

416
ytdlp.ts Normal file
View File

@ -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<typeof setTimeout> | 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<YtdlpStatus> {
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<string> {
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<PlaylistInfo | SingleVideoInfo> {
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<void> {
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<void>((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);
}