we're starting to hit some context limits
This commit is contained in:
parent
c7ad16e9f6
commit
7f138543b4
219
db.ts
219
db.ts
|
|
@ -55,6 +55,47 @@ db.run(`
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Content-addressed tracks table
|
||||||
|
db.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())
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// User playlists
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS playlists (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
visibility TEXT DEFAULT 'private' CHECK (visibility IN ('private', 'public', 'registered')),
|
||||||
|
created_at INTEGER DEFAULT (unixepoch()),
|
||||||
|
updated_at INTEGER DEFAULT (unixepoch())
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS playlist_tracks (
|
||||||
|
playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
|
||||||
|
track_id TEXT NOT NULL REFERENCES tracks(id),
|
||||||
|
position INTEGER NOT NULL,
|
||||||
|
added_at INTEGER DEFAULT (unixepoch()),
|
||||||
|
added_by INTEGER REFERENCES users(id),
|
||||||
|
PRIMARY KEY (playlist_id, position)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.run(`CREATE INDEX IF NOT EXISTS idx_playlist_tracks_track ON playlist_tracks(track_id)`);
|
||||||
|
db.run(`CREATE INDEX IF NOT EXISTS idx_playlists_owner ON playlists(owner_id)`);
|
||||||
|
db.run(`CREATE INDEX IF NOT EXISTS idx_playlists_visibility ON playlists(visibility)`);
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export interface User {
|
export interface User {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -82,6 +123,34 @@ export interface Permission {
|
||||||
permission: string;
|
permission: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Track {
|
||||||
|
id: string;
|
||||||
|
title: string | null;
|
||||||
|
artist: string | null;
|
||||||
|
album: string | null;
|
||||||
|
duration: number;
|
||||||
|
size: number;
|
||||||
|
created_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Playlist {
|
||||||
|
id: string;
|
||||||
|
owner_id: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
visibility: "private" | "public" | "registered";
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaylistTrack {
|
||||||
|
playlist_id: string;
|
||||||
|
track_id: string;
|
||||||
|
position: number;
|
||||||
|
added_at: number;
|
||||||
|
added_by: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
// User functions
|
// User functions
|
||||||
export async function createUser(username: string, password: string): Promise<User> {
|
export async function createUser(username: string, password: string): Promise<User> {
|
||||||
const password_hash = await Bun.password.hash(password);
|
const password_hash = await Bun.password.hash(password);
|
||||||
|
|
@ -250,3 +319,153 @@ export function getUserSessions(userId: number): Omit<Session, 'token'>[] {
|
||||||
|
|
||||||
// Cleanup expired sessions periodically
|
// Cleanup expired sessions periodically
|
||||||
setInterval(() => deleteExpiredSessions(), 60 * 60 * 1000); // Every hour
|
setInterval(() => deleteExpiredSessions(), 60 * 60 * 1000); // Every hour
|
||||||
|
|
||||||
|
// Track functions
|
||||||
|
export function upsertTrack(track: Omit<Track, "created_at">): void {
|
||||||
|
db.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTrack(id: string): Track | null {
|
||||||
|
return db.query("SELECT * FROM tracks WHERE id = ?").get(id) as Track | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllTracks(): Track[] {
|
||||||
|
return db.query("SELECT * FROM tracks ORDER BY title").all() as Track[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Playlist functions
|
||||||
|
export function createPlaylist(
|
||||||
|
ownerId: number,
|
||||||
|
name: string,
|
||||||
|
visibility: Playlist["visibility"] = "private",
|
||||||
|
description?: string
|
||||||
|
): Playlist {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
db.query(`
|
||||||
|
INSERT INTO playlists (id, owner_id, name, description, visibility)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`).run(id, ownerId, name, description ?? null, visibility);
|
||||||
|
return getPlaylist(id)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlaylist(id: string): Playlist | null {
|
||||||
|
return db.query("SELECT * FROM playlists WHERE id = ?").get(id) as Playlist | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updatePlaylist(
|
||||||
|
id: string,
|
||||||
|
updates: Partial<Pick<Playlist, "name" | "description" | "visibility">>
|
||||||
|
): void {
|
||||||
|
const fields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
|
||||||
|
if (updates.name !== undefined) { fields.push("name = ?"); values.push(updates.name); }
|
||||||
|
if (updates.description !== undefined) { fields.push("description = ?"); values.push(updates.description); }
|
||||||
|
if (updates.visibility !== undefined) { fields.push("visibility = ?"); values.push(updates.visibility); }
|
||||||
|
|
||||||
|
if (fields.length === 0) return;
|
||||||
|
|
||||||
|
fields.push("updated_at = unixepoch()");
|
||||||
|
values.push(id);
|
||||||
|
|
||||||
|
db.query(`UPDATE playlists SET ${fields.join(", ")} WHERE id = ?`).run(...values);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deletePlaylist(id: string): void {
|
||||||
|
db.query("DELETE FROM playlists WHERE id = ?").run(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserPlaylists(userId: number): Playlist[] {
|
||||||
|
return db.query("SELECT * FROM playlists WHERE owner_id = ? ORDER BY updated_at DESC").all(userId) as Playlist[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVisiblePlaylists(userId: number | null, isGuest: boolean): Playlist[] {
|
||||||
|
if (userId === null || isGuest) {
|
||||||
|
// Guests/unauthenticated: public only
|
||||||
|
return db.query("SELECT * FROM playlists WHERE visibility = 'public' ORDER BY updated_at DESC").all() as Playlist[];
|
||||||
|
}
|
||||||
|
// Logged in: own + public + registered
|
||||||
|
return db.query(`
|
||||||
|
SELECT * FROM playlists
|
||||||
|
WHERE owner_id = ? OR visibility IN ('public', 'registered')
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
`).all(userId) as Playlist[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canViewPlaylist(playlist: Playlist, userId: number | null, isGuest: boolean): boolean {
|
||||||
|
if (playlist.visibility === "public") return true;
|
||||||
|
if (userId === null) return false;
|
||||||
|
if (playlist.owner_id === userId) return true;
|
||||||
|
if (playlist.visibility === "registered" && !isGuest) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canEditPlaylist(playlist: Playlist, userId: number | null): boolean {
|
||||||
|
return userId !== null && playlist.owner_id === userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Playlist track functions
|
||||||
|
export function getPlaylistTracks(playlistId: string): (Track & { position: number })[] {
|
||||||
|
return db.query(`
|
||||||
|
SELECT t.*, pt.position
|
||||||
|
FROM playlist_tracks pt
|
||||||
|
JOIN tracks t ON t.id = pt.track_id
|
||||||
|
WHERE pt.playlist_id = ?
|
||||||
|
ORDER BY pt.position
|
||||||
|
`).all(playlistId) as (Track & { position: number })[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addTrackToPlaylist(playlistId: string, trackId: string, addedBy: number | null, position?: number): void {
|
||||||
|
// If no position, add at end
|
||||||
|
if (position === undefined) {
|
||||||
|
const max = db.query("SELECT MAX(position) as max FROM playlist_tracks WHERE playlist_id = ?").get(playlistId) as { max: number | null };
|
||||||
|
position = (max?.max ?? -1) + 1;
|
||||||
|
} else {
|
||||||
|
// Shift existing tracks
|
||||||
|
db.query("UPDATE playlist_tracks SET position = position + 1 WHERE playlist_id = ? AND position >= ?").run(playlistId, position);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.query(`
|
||||||
|
INSERT INTO playlist_tracks (playlist_id, track_id, position, added_by)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`).run(playlistId, trackId, position, addedBy);
|
||||||
|
|
||||||
|
db.query("UPDATE playlists SET updated_at = unixepoch() WHERE id = ?").run(playlistId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeTrackFromPlaylist(playlistId: string, position: number): void {
|
||||||
|
db.query("DELETE FROM playlist_tracks WHERE playlist_id = ? AND position = ?").run(playlistId, position);
|
||||||
|
// Shift remaining tracks down
|
||||||
|
db.query("UPDATE playlist_tracks SET position = position - 1 WHERE playlist_id = ? AND position > ?").run(playlistId, position);
|
||||||
|
db.query("UPDATE playlists SET updated_at = unixepoch() WHERE id = ?").run(playlistId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reorderPlaylistTrack(playlistId: string, fromPos: number, toPos: number): void {
|
||||||
|
if (fromPos === toPos) return;
|
||||||
|
|
||||||
|
// Get the track being moved
|
||||||
|
const track = db.query("SELECT track_id FROM playlist_tracks WHERE playlist_id = ? AND position = ?").get(playlistId, fromPos) as { track_id: string } | null;
|
||||||
|
if (!track) return;
|
||||||
|
|
||||||
|
// Remove from old position
|
||||||
|
db.query("DELETE FROM playlist_tracks WHERE playlist_id = ? AND position = ?").run(playlistId, fromPos);
|
||||||
|
|
||||||
|
if (fromPos < toPos) {
|
||||||
|
// Moving down: shift tracks between fromPos+1 and toPos up
|
||||||
|
db.query("UPDATE playlist_tracks SET position = position - 1 WHERE playlist_id = ? AND position > ? AND position <= ?").run(playlistId, fromPos, toPos);
|
||||||
|
} else {
|
||||||
|
// Moving up: shift tracks between toPos and fromPos-1 down
|
||||||
|
db.query("UPDATE playlist_tracks SET position = position + 1 WHERE playlist_id = ? AND position >= ? AND position < ?").run(playlistId, toPos, fromPos);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert at new position
|
||||||
|
db.query("INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES (?, ?, ?)").run(playlistId, track.track_id, toPos);
|
||||||
|
db.query("UPDATE playlists SET updated_at = unixepoch() WHERE id = ?").run(playlistId);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,329 @@
|
||||||
|
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 { upsertTrack, type Track } from "./db";
|
||||||
|
|
||||||
|
const HASH_CHUNK_SIZE = 64 * 1024; // 64KB
|
||||||
|
const AUDIO_EXTENSIONS = new Set([".mp3", ".ogg", ".flac", ".wav", ".m4a", ".aac", ".opus", ".wma"]);
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
constructor(musicDir: string, cacheDbPath: string = "./library_cache.db") {
|
||||||
|
this.musicDir = musicDir;
|
||||||
|
this.cacheDb = new Database(cacheDbPath);
|
||||||
|
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)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 main database
|
||||||
|
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] Scanning ${this.musicDir}...`);
|
||||||
|
const startTime = Date.now();
|
||||||
|
let processed = 0;
|
||||||
|
let cached = 0;
|
||||||
|
|
||||||
|
const scanDir = async (dir: string) => {
|
||||||
|
const entries = await readdir(dir, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = join(dir, entry.name);
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
await scanDir(fullPath);
|
||||||
|
} else if (entry.isFile() && this.isAudioFile(entry.name)) {
|
||||||
|
const relativePath = relative(this.musicDir, fullPath);
|
||||||
|
const cacheEntry = this.getCacheEntry(relativePath);
|
||||||
|
|
||||||
|
// Quick check if cache is valid
|
||||||
|
if (cacheEntry) {
|
||||||
|
try {
|
||||||
|
const fileStat = await stat(fullPath);
|
||||||
|
if (cacheEntry.size === fileStat.size && cacheEntry.mtime_ms === Math.floor(fileStat.mtimeMs)) {
|
||||||
|
// Cache hit - reuse existing data
|
||||||
|
const existingTrack = this.trackInfo.get(cacheEntry.track_id);
|
||||||
|
if (!existingTrack) {
|
||||||
|
// Need metadata but skip hashing
|
||||||
|
const track = await this.processFile(fullPath);
|
||||||
|
if (track) {
|
||||||
|
this.trackMap.set(track.id, fullPath);
|
||||||
|
this.trackInfo.set(track.id, track);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.trackMap.set(cacheEntry.track_id, fullPath);
|
||||||
|
}
|
||||||
|
cached++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const track = await this.processFile(fullPath);
|
||||||
|
if (track) {
|
||||||
|
this.trackMap.set(track.id, fullPath);
|
||||||
|
this.trackInfo.set(track.id, track);
|
||||||
|
processed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await scanDir(this.musicDir);
|
||||||
|
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
console.log(`[Library] Scan complete: ${this.trackMap.size} tracks (${processed} processed, ${cached} cached) in ${elapsed}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
musicroom.db
BIN
musicroom.db
Binary file not shown.
294
public/app.js
294
public/app.js
|
|
@ -17,6 +17,8 @@
|
||||||
let currentUser = null;
|
let currentUser = null;
|
||||||
let serverStatus = null;
|
let serverStatus = null;
|
||||||
let library = [];
|
let library = [];
|
||||||
|
let userPlaylists = [];
|
||||||
|
let selectedPlaylistId = null;
|
||||||
let prefetchController = null;
|
let prefetchController = null;
|
||||||
let loadingSegments = new Set();
|
let loadingSegments = new Set();
|
||||||
let trackCaches = new Map(); // Map of filename -> Set of cached segment indices
|
let trackCaches = new Map(); // Map of filename -> Set of cached segment indices
|
||||||
|
|
@ -26,6 +28,19 @@
|
||||||
let recentDownloads = []; // Track recent downloads for speed calculation
|
let recentDownloads = []; // Track recent downloads for speed calculation
|
||||||
|
|
||||||
const $ = (s) => document.querySelector(s);
|
const $ = (s) => document.querySelector(s);
|
||||||
|
|
||||||
|
// Toast notifications
|
||||||
|
function showToast(message, duration = 4000) {
|
||||||
|
const container = $("#toast-container");
|
||||||
|
const toast = document.createElement("div");
|
||||||
|
toast.className = "toast";
|
||||||
|
toast.textContent = message;
|
||||||
|
container.appendChild(toast);
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.add("fade-out");
|
||||||
|
setTimeout(() => toast.remove(), 300);
|
||||||
|
}, duration);
|
||||||
|
}
|
||||||
const SEGMENTS = 20;
|
const SEGMENTS = 20;
|
||||||
const STORAGE_KEY = "musicroom_volume";
|
const STORAGE_KEY = "musicroom_volume";
|
||||||
|
|
||||||
|
|
@ -34,6 +49,9 @@
|
||||||
if (savedVolume !== null) {
|
if (savedVolume !== null) {
|
||||||
audio.volume = parseFloat(savedVolume);
|
audio.volume = parseFloat(savedVolume);
|
||||||
$("#volume").value = savedVolume;
|
$("#volume").value = savedVolume;
|
||||||
|
} else {
|
||||||
|
// No saved volume - sync audio to slider's default value
|
||||||
|
audio.volume = parseFloat($("#volume").value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create buffer segments
|
// Create buffer segments
|
||||||
|
|
@ -424,7 +442,36 @@
|
||||||
currentStreamId = id;
|
currentStreamId = id;
|
||||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
ws = new WebSocket(proto + "//" + location.host + "/api/streams/" + id + "/ws");
|
ws = new WebSocket(proto + "//" + location.host + "/api/streams/" + id + "/ws");
|
||||||
ws.onmessage = (e) => handleUpdate(JSON.parse(e.data));
|
ws.onmessage = (e) => {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
// Handle library updates
|
||||||
|
if (data.type === "track_added") {
|
||||||
|
showToast(`"${data.track.title}" is now available`);
|
||||||
|
if (data.library) {
|
||||||
|
library = data.library;
|
||||||
|
renderLibrary();
|
||||||
|
if (selectedPlaylistId === "all") {
|
||||||
|
playlist = [...library];
|
||||||
|
renderPlaylist();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.type === "track_removed") {
|
||||||
|
showToast(`"${data.track.title}" was removed`);
|
||||||
|
if (data.library) {
|
||||||
|
library = data.library;
|
||||||
|
renderLibrary();
|
||||||
|
if (selectedPlaylistId === "all") {
|
||||||
|
playlist = [...library];
|
||||||
|
renderPlaylist();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Normal stream update
|
||||||
|
handleUpdate(data);
|
||||||
|
};
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
synced = false;
|
synced = false;
|
||||||
ws = null;
|
ws = null;
|
||||||
|
|
@ -461,9 +508,14 @@
|
||||||
playlist.forEach((track, i) => {
|
playlist.forEach((track, i) => {
|
||||||
const div = document.createElement("div");
|
const div = document.createElement("div");
|
||||||
div.className = "track" + (i === currentIndex ? " active" : "");
|
div.className = "track" + (i === currentIndex ? " active" : "");
|
||||||
const title = track.title?.trim() || track.filename.replace(/\.[^.]+$/, "");
|
const title = track.title?.trim() || (track.id || track.filename || "Unknown").replace(/\.[^.]+$/, "");
|
||||||
div.innerHTML = `<span>${title}</span><span class="duration">${fmt(track.duration)}</span>`;
|
const trackId = track.id || track.filename;
|
||||||
div.onclick = async () => {
|
|
||||||
|
// Show remove button only for user playlists (not stream playlists)
|
||||||
|
const removeBtn = selectedPlaylistId ? `<span class="btn-remove" title="Remove">×</span>` : "";
|
||||||
|
div.innerHTML = `<span class="track-title">${title}</span><span class="track-actions">${removeBtn}<span class="duration">${fmt(track.duration)}</span></span>`;
|
||||||
|
|
||||||
|
div.querySelector(".track-title").onclick = async () => {
|
||||||
if (synced && currentStreamId) {
|
if (synced && currentStreamId) {
|
||||||
const res = await fetch("/api/streams/" + currentStreamId + "/jump", {
|
const res = await fetch("/api/streams/" + currentStreamId + "/jump", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -473,20 +525,27 @@
|
||||||
if (res.status === 403) flashPermissionDenied();
|
if (res.status === 403) flashPermissionDenied();
|
||||||
} else {
|
} else {
|
||||||
currentIndex = i;
|
currentIndex = i;
|
||||||
currentFilename = track.filename;
|
currentFilename = trackId;
|
||||||
serverTrackDuration = track.duration;
|
serverTrackDuration = track.duration;
|
||||||
$("#track-title").textContent = title;
|
$("#track-title").textContent = title;
|
||||||
// Reset loading state for new track (cache persists)
|
|
||||||
loadingSegments.clear();
|
loadingSegments.clear();
|
||||||
// Try to load from cache first
|
const cachedUrl = await loadTrackBlob(trackId);
|
||||||
const cachedUrl = await loadTrackBlob(track.filename);
|
audio.src = cachedUrl || getTrackUrl(trackId);
|
||||||
audio.src = cachedUrl || getTrackUrl(track.filename);
|
|
||||||
audio.currentTime = 0;
|
audio.currentTime = 0;
|
||||||
localTimestamp = 0;
|
localTimestamp = 0;
|
||||||
audio.play();
|
audio.play();
|
||||||
renderPlaylist();
|
renderPlaylist();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const removeEl = div.querySelector(".btn-remove");
|
||||||
|
if (removeEl) {
|
||||||
|
removeEl.onclick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
removeTrackFromCurrentPlaylist(i);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
container.appendChild(div);
|
container.appendChild(div);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -498,25 +557,37 @@
|
||||||
container.innerHTML = '<div class="empty">No tracks discovered</div>';
|
container.innerHTML = '<div class="empty">No tracks discovered</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const canAdd = selectedPlaylistId && selectedPlaylistId !== "all";
|
||||||
library.forEach((track) => {
|
library.forEach((track) => {
|
||||||
const div = document.createElement("div");
|
const div = document.createElement("div");
|
||||||
div.className = "track";
|
div.className = "track";
|
||||||
const title = track.title?.trim() || track.filename.replace(/\.[^.]+$/, "");
|
const title = track.title?.trim() || track.filename.replace(/\.[^.]+$/, "");
|
||||||
div.innerHTML = `<span>${title}</span><span class="duration">${fmt(track.duration)}</span>`;
|
const addBtn = canAdd ? `<span class="btn-add" title="Add to playlist">+</span>` : "";
|
||||||
div.onclick = async () => {
|
div.innerHTML = `<span class="track-title">${title}</span><span class="track-actions">${addBtn}<span class="duration">${fmt(track.duration)}</span></span>`;
|
||||||
// In local mode, play directly from library
|
|
||||||
|
div.querySelector(".track-title").onclick = async () => {
|
||||||
|
// Play directly from library (uses track ID)
|
||||||
if (!synced) {
|
if (!synced) {
|
||||||
currentFilename = track.filename;
|
currentFilename = track.id; // Use track ID instead of filename
|
||||||
serverTrackDuration = track.duration;
|
serverTrackDuration = track.duration;
|
||||||
$("#track-title").textContent = title;
|
$("#track-title").textContent = title;
|
||||||
loadingSegments.clear();
|
loadingSegments.clear();
|
||||||
const cachedUrl = await loadTrackBlob(track.filename);
|
const cachedUrl = await loadTrackBlob(track.id);
|
||||||
audio.src = cachedUrl || getTrackUrl(track.filename);
|
audio.src = cachedUrl || getTrackUrl(track.id);
|
||||||
audio.currentTime = 0;
|
audio.currentTime = 0;
|
||||||
localTimestamp = 0;
|
localTimestamp = 0;
|
||||||
audio.play();
|
audio.play();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addBtnEl = div.querySelector(".btn-add");
|
||||||
|
if (addBtnEl) {
|
||||||
|
addBtnEl.onclick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
addTrackToCurrentPlaylist(track.id);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
container.appendChild(div);
|
container.appendChild(div);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -531,6 +602,177 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadPlaylists() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/playlists");
|
||||||
|
userPlaylists = await res.json();
|
||||||
|
renderPlaylistSelector();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Failed to load playlists");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPlaylistSelector() {
|
||||||
|
const list = $("#playlists-list");
|
||||||
|
if (!list) return;
|
||||||
|
list.innerHTML = "";
|
||||||
|
// Add "All Tracks" as default option
|
||||||
|
const allItem = document.createElement("div");
|
||||||
|
allItem.className = "playlist-item" + (selectedPlaylistId === "all" ? " active" : "");
|
||||||
|
allItem.textContent = "All Tracks";
|
||||||
|
allItem.onclick = () => loadSelectedPlaylist("all");
|
||||||
|
list.appendChild(allItem);
|
||||||
|
// Add user playlists
|
||||||
|
for (const pl of userPlaylists) {
|
||||||
|
const item = document.createElement("div");
|
||||||
|
item.className = "playlist-item" + (pl.id === selectedPlaylistId ? " active" : "");
|
||||||
|
item.textContent = pl.name;
|
||||||
|
item.onclick = () => loadSelectedPlaylist(pl.id);
|
||||||
|
list.appendChild(item);
|
||||||
|
}
|
||||||
|
// Update playlist panel title
|
||||||
|
const titleEl = $("#playlist-title");
|
||||||
|
if (selectedPlaylistId === "all") {
|
||||||
|
titleEl.textContent = "Playlist - All Tracks";
|
||||||
|
} else if (selectedPlaylistId) {
|
||||||
|
const pl = userPlaylists.find(p => p.id === selectedPlaylistId);
|
||||||
|
titleEl.textContent = pl ? "Playlist - " + pl.name : "Playlist";
|
||||||
|
} else {
|
||||||
|
titleEl.textContent = "Playlist";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSelectedPlaylist(playlistId) {
|
||||||
|
if (!playlistId) {
|
||||||
|
playlist = [];
|
||||||
|
selectedPlaylistId = null;
|
||||||
|
renderPlaylist();
|
||||||
|
renderPlaylistSelector();
|
||||||
|
renderLibrary();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (playlistId === "all") {
|
||||||
|
// Use library as playlist
|
||||||
|
playlist = [...library];
|
||||||
|
selectedPlaylistId = "all";
|
||||||
|
currentIndex = 0;
|
||||||
|
renderPlaylist();
|
||||||
|
renderPlaylistSelector();
|
||||||
|
renderLibrary();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/playlists/" + playlistId);
|
||||||
|
if (!res.ok) throw new Error("Failed to load playlist");
|
||||||
|
const data = await res.json();
|
||||||
|
playlist = data.tracks || [];
|
||||||
|
selectedPlaylistId = playlistId;
|
||||||
|
currentIndex = 0;
|
||||||
|
renderPlaylist();
|
||||||
|
renderPlaylistSelector();
|
||||||
|
renderLibrary();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Failed to load playlist:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createNewPlaylist() {
|
||||||
|
const header = $("#playlists-panel .panel-header");
|
||||||
|
const btn = $("#btn-new-playlist");
|
||||||
|
|
||||||
|
// Already in edit mode?
|
||||||
|
if (header.querySelector(".new-playlist-input")) return;
|
||||||
|
|
||||||
|
// Hide button, show input
|
||||||
|
btn.style.display = "none";
|
||||||
|
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "text";
|
||||||
|
input.className = "new-playlist-input";
|
||||||
|
input.placeholder = "Playlist name...";
|
||||||
|
|
||||||
|
const submit = document.createElement("button");
|
||||||
|
submit.className = "btn-submit-playlist";
|
||||||
|
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 {
|
||||||
|
const res = await fetch("/api/playlists", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name, visibility: "private" })
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to create playlist");
|
||||||
|
const pl = await res.json();
|
||||||
|
await loadPlaylists();
|
||||||
|
selectedPlaylistId = pl.id;
|
||||||
|
renderPlaylistSelector();
|
||||||
|
await loadSelectedPlaylist(pl.id);
|
||||||
|
} catch (e) {
|
||||||
|
alert("Failed to create playlist");
|
||||||
|
}
|
||||||
|
cleanup();
|
||||||
|
};
|
||||||
|
|
||||||
|
submit.onclick = doCreate;
|
||||||
|
input.onkeydown = (e) => {
|
||||||
|
if (e.key === "Enter") doCreate();
|
||||||
|
if (e.key === "Escape") cleanup();
|
||||||
|
};
|
||||||
|
input.onblur = (e) => {
|
||||||
|
// Delay to allow click on submit button
|
||||||
|
setTimeout(() => {
|
||||||
|
if (document.activeElement !== submit) cleanup();
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addTrackToCurrentPlaylist(trackId) {
|
||||||
|
if (!selectedPlaylistId || selectedPlaylistId === "all") {
|
||||||
|
alert("Select or create a playlist first");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/playlists/" + selectedPlaylistId + "/tracks", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ trackIds: [trackId] })
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to add track");
|
||||||
|
await loadSelectedPlaylist(selectedPlaylistId);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Failed to add track:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeTrackFromCurrentPlaylist(position) {
|
||||||
|
if (!selectedPlaylistId || selectedPlaylistId === "all") return;
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/playlists/" + selectedPlaylistId + "/tracks/" + position, {
|
||||||
|
method: "DELETE"
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to remove track");
|
||||||
|
await loadSelectedPlaylist(selectedPlaylistId);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Failed to remove track:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleUpdate(data) {
|
async function handleUpdate(data) {
|
||||||
if (!data.track) {
|
if (!data.track) {
|
||||||
$("#track-title").textContent = "No tracks";
|
$("#track-title").textContent = "No tracks";
|
||||||
|
|
@ -844,6 +1086,15 @@
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Playlist selector handlers
|
||||||
|
$("#btn-new-playlist").onclick = () => {
|
||||||
|
if (!currentUser || currentUser.isGuest) {
|
||||||
|
alert("Sign in to create playlists");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createNewPlaylist();
|
||||||
|
};
|
||||||
|
|
||||||
// Fetch server status
|
// Fetch server status
|
||||||
async function loadServerStatus() {
|
async function loadServerStatus() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -864,10 +1115,13 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
Promise.all([initStorage(), loadServerStatus()]).then(() => {
|
Promise.all([initStorage(), loadServerStatus()]).then(async () => {
|
||||||
loadLibrary();
|
await loadLibrary();
|
||||||
loadCurrentUser().then(() => {
|
loadSelectedPlaylist("all"); // Default to All Tracks
|
||||||
if (currentUser) loadStreams();
|
await loadCurrentUser();
|
||||||
});
|
if (currentUser) {
|
||||||
|
loadStreams();
|
||||||
|
loadPlaylists();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -52,9 +52,16 @@
|
||||||
<div id="library"></div>
|
<div id="library"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="playlist-panel">
|
<div id="playlist-panel">
|
||||||
<h3>Playlist</h3>
|
<h3 id="playlist-title">Playlist</h3>
|
||||||
<div id="playlist"></div>
|
<div id="playlist"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="playlists-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h3>Playlists</h3>
|
||||||
|
<button id="btn-new-playlist" title="New playlist">+</button>
|
||||||
|
</div>
|
||||||
|
<div id="playlists-list"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="player-bar">
|
<div id="player-bar">
|
||||||
|
|
@ -83,6 +90,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div id="status"></div>
|
<div id="status"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="toast-container"></div>
|
||||||
</div>
|
</div>
|
||||||
<script src="trackStorage.js"></script>
|
<script src="trackStorage.js"></script>
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,32 @@ h3 { font-size: 0.9rem; color: #666; margin-bottom: 0.5rem; text-transform: uppe
|
||||||
|
|
||||||
/* Main content - library and playlist */
|
/* Main content - library and playlist */
|
||||||
#main-content { display: flex; gap: 1rem; flex: 1; min-height: 0; margin-bottom: 1rem; }
|
#main-content { display: flex; gap: 1rem; flex: 1; min-height: 0; margin-bottom: 1rem; }
|
||||||
#library-panel, #playlist-panel { flex: 1; background: #1a1a1a; border-radius: 8px; padding: 1rem; display: flex; flex-direction: column; min-height: 300px; max-height: 60vh; }
|
#library-panel, #playlist-panel { flex: 2; background: #1a1a1a; border-radius: 8px; padding: 1rem; display: flex; flex-direction: column; min-height: 300px; max-height: 60vh; }
|
||||||
|
#playlists-panel { flex: 1; background: #1a1a1a; border-radius: 8px; padding: 1rem; display: flex; flex-direction: column; min-height: 300px; max-height: 60vh; min-width: 180px; max-width: 250px; }
|
||||||
|
#playlists-list { flex: 1; overflow-y: auto; }
|
||||||
|
#playlists-list .playlist-item { padding: 0.5rem 0.75rem; border-radius: 4px; cursor: pointer; font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
#playlists-list .playlist-item:hover { background: #222; }
|
||||||
|
#playlists-list .playlist-item.active { background: #2a3a4a; color: #8cf; }
|
||||||
|
#playlist-title { margin: 0 0 0.5rem 0; }
|
||||||
|
.panel-header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }
|
||||||
|
.panel-header h3 { margin: 0; flex-shrink: 0; }
|
||||||
|
.panel-header select { flex: 1; background: #222; color: #eee; border: 1px solid #333; padding: 0.3rem 0.5rem; border-radius: 4px; font-size: 0.8rem; }
|
||||||
|
.panel-header button { background: #333; color: #eee; border: 1px solid #444; width: 28px; height: 28px; border-radius: 4px; cursor: pointer; font-size: 1rem; line-height: 1; padding: 0; }
|
||||||
|
.panel-header button:hover { background: #444; }
|
||||||
|
.new-playlist-input { flex: 1; background: #222; color: #eee; border: 1px solid #333; padding: 0.3rem 0.5rem; border-radius: 4px; font-size: 0.8rem; }
|
||||||
|
.btn-submit-playlist { background: #2a4a3a; color: #4e8; border: 1px solid #3a5a4a; width: 28px; height: 28px; border-radius: 4px; cursor: pointer; font-size: 1.2rem; line-height: 1; padding: 0; }
|
||||||
|
.btn-submit-playlist:hover { background: #3a5a4a; }
|
||||||
#library, #playlist { flex: 1; overflow-y: auto; }
|
#library, #playlist { flex: 1; overflow-y: auto; }
|
||||||
#library .track, #playlist .track { padding: 0.5rem 0.75rem; border-radius: 4px; cursor: pointer; font-size: 0.9rem; display: flex; justify-content: space-between; }
|
#library .track, #playlist .track { padding: 0.5rem 0.75rem; border-radius: 4px; cursor: pointer; font-size: 0.9rem; display: flex; justify-content: space-between; align-items: center; }
|
||||||
#library .track:hover, #playlist .track:hover { background: #222; }
|
#library .track:hover, #playlist .track:hover { background: #222; }
|
||||||
#playlist .track.active { background: #2a4a3a; color: #4e8; }
|
#playlist .track.active { background: #2a4a3a; color: #4e8; }
|
||||||
#library .track .duration, #playlist .track .duration { color: #666; font-size: 0.8rem; }
|
.track-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.track-actions { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
|
||||||
|
.track-actions .duration { color: #666; font-size: 0.8rem; }
|
||||||
|
.track-actions .btn-add, .track-actions .btn-remove { width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; background: #333; border-radius: 3px; font-size: 0.9rem; opacity: 0; transition: opacity 0.2s; }
|
||||||
|
.track:hover .btn-add, .track:hover .btn-remove { opacity: 0.6; }
|
||||||
|
.track-actions .btn-add:hover, .track-actions .btn-remove:hover { opacity: 1; background: #444; }
|
||||||
|
.track-actions .btn-remove { color: #e44; }
|
||||||
|
|
||||||
/* Player bar */
|
/* Player bar */
|
||||||
#player-bar { background: #1a1a1a; border-radius: 8px; padding: 1rem; display: flex; gap: 1rem; align-items: center; }
|
#player-bar { background: #1a1a1a; border-radius: 8px; padding: 1rem; display: flex; gap: 1rem; align-items: center; }
|
||||||
|
|
@ -91,3 +111,10 @@ button:hover { background: #333; }
|
||||||
::-webkit-scrollbar-track { background-color: #111; border-radius: 4px; }
|
::-webkit-scrollbar-track { background-color: #111; border-radius: 4px; }
|
||||||
::-webkit-scrollbar-thumb { background-color: #333; border-radius: 4px; }
|
::-webkit-scrollbar-thumb { background-color: #333; border-radius: 4px; }
|
||||||
::-webkit-scrollbar-thumb:hover { background-color: #555; }
|
::-webkit-scrollbar-thumb:hover { background-color: #555; }
|
||||||
|
|
||||||
|
/* Toast notifications */
|
||||||
|
#toast-container { position: fixed; top: 1rem; left: 1rem; display: flex; flex-direction: column; gap: 0.5rem; z-index: 1000; pointer-events: none; }
|
||||||
|
.toast { background: #1a3a2a; color: #4e8; padding: 0.75rem 1rem; border-radius: 6px; border: 1px solid #4e8; box-shadow: 0 4px 12px rgba(0,0,0,0.4); font-size: 0.9rem; animation: toast-in 0.3s ease-out; max-width: 300px; }
|
||||||
|
.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); } }
|
||||||
|
|
|
||||||
286
server.ts
286
server.ts
|
|
@ -17,6 +17,18 @@ import {
|
||||||
grantPermission,
|
grantPermission,
|
||||||
revokePermission,
|
revokePermission,
|
||||||
findUserById,
|
findUserById,
|
||||||
|
createPlaylist,
|
||||||
|
getPlaylist,
|
||||||
|
updatePlaylist,
|
||||||
|
deletePlaylist,
|
||||||
|
getVisiblePlaylists,
|
||||||
|
canViewPlaylist,
|
||||||
|
canEditPlaylist,
|
||||||
|
getPlaylistTracks,
|
||||||
|
addTrackToPlaylist,
|
||||||
|
removeTrackFromPlaylist,
|
||||||
|
reorderPlaylistTrack,
|
||||||
|
type Playlist,
|
||||||
} from "./db";
|
} from "./db";
|
||||||
import {
|
import {
|
||||||
getUser,
|
getUser,
|
||||||
|
|
@ -26,6 +38,7 @@ import {
|
||||||
clearSessionCookie,
|
clearSessionCookie,
|
||||||
getClientInfo,
|
getClientInfo,
|
||||||
} from "./auth";
|
} from "./auth";
|
||||||
|
import { Library } from "./library";
|
||||||
|
|
||||||
// Load config
|
// Load config
|
||||||
interface Config {
|
interface Config {
|
||||||
|
|
@ -46,7 +59,10 @@ const PUBLIC_DIR = join(import.meta.dir, "public");
|
||||||
|
|
||||||
console.log(`Config loaded: port=${config.port}, musicDir=${MUSIC_DIR}, allowGuests=${config.allowGuests}`);
|
console.log(`Config loaded: port=${config.port}, musicDir=${MUSIC_DIR}, allowGuests=${config.allowGuests}`);
|
||||||
|
|
||||||
// Load track metadata
|
// Initialize library
|
||||||
|
const library = new Library(MUSIC_DIR);
|
||||||
|
|
||||||
|
// Load track metadata (for stream initialization - converts library tracks to stream format)
|
||||||
async function loadTrack(filename: string): Promise<Track> {
|
async function loadTrack(filename: string): Promise<Track> {
|
||||||
const filepath = join(MUSIC_DIR, filename);
|
const filepath = join(MUSIC_DIR, filename);
|
||||||
try {
|
try {
|
||||||
|
|
@ -71,19 +87,17 @@ async function discoverTracks(): Promise<string[]> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store all library tracks
|
// Initialize streams
|
||||||
let libraryTracks: Track[] = [];
|
|
||||||
|
|
||||||
// Initialize streams and library
|
|
||||||
async function init(): Promise<Map<string, Stream>> {
|
async function init(): Promise<Map<string, Stream>> {
|
||||||
|
// Scan library first
|
||||||
|
await library.scan();
|
||||||
|
library.startWatching();
|
||||||
|
|
||||||
const playlistData = await file(PLAYLIST_PATH).json();
|
const playlistData = await file(PLAYLIST_PATH).json();
|
||||||
const streams = new Map<string, Stream>();
|
const streams = new Map<string, Stream>();
|
||||||
|
|
||||||
// Load all tracks in music directory for library
|
// Get all discovered files for streams
|
||||||
const allFiles = await discoverTracks();
|
const allFiles = await discoverTracks();
|
||||||
const allTracks = await Promise.all(allFiles.map(loadTrack));
|
|
||||||
libraryTracks = allTracks.filter((t) => t.duration > 0);
|
|
||||||
console.log(`Library: ${libraryTracks.length} tracks discovered`);
|
|
||||||
|
|
||||||
for (const cfg of playlistData.streams) {
|
for (const cfg of playlistData.streams) {
|
||||||
let trackFiles: string[] = cfg.tracks;
|
let trackFiles: string[] = cfg.tracks;
|
||||||
|
|
@ -107,6 +121,45 @@ async function init(): Promise<Map<string, Stream>> {
|
||||||
|
|
||||||
const streams = await init();
|
const streams = await init();
|
||||||
|
|
||||||
|
// Broadcast to all connected clients across all streams
|
||||||
|
function broadcastToAll(message: object) {
|
||||||
|
const data = JSON.stringify(message);
|
||||||
|
for (const stream of streams.values()) {
|
||||||
|
for (const ws of stream.clients) {
|
||||||
|
ws.send(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for library changes and notify clients
|
||||||
|
library.on("added", (track) => {
|
||||||
|
console.log(`New track detected: ${track.title}`);
|
||||||
|
const allTracks = library.getAllTracks().map(t => ({
|
||||||
|
id: t.id,
|
||||||
|
title: t.title,
|
||||||
|
duration: t.duration
|
||||||
|
}));
|
||||||
|
broadcastToAll({
|
||||||
|
type: "track_added",
|
||||||
|
track: { id: track.id, title: track.title, duration: track.duration },
|
||||||
|
library: allTracks
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
library.on("removed", (track) => {
|
||||||
|
console.log(`Track removed: ${track.title}`);
|
||||||
|
const allTracks = library.getAllTracks().map(t => ({
|
||||||
|
id: t.id,
|
||||||
|
title: t.title,
|
||||||
|
duration: t.duration
|
||||||
|
}));
|
||||||
|
broadcastToAll({
|
||||||
|
type: "track_removed",
|
||||||
|
track: { id: track.id, title: track.title },
|
||||||
|
library: allTracks
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Tick interval: advance tracks when needed, broadcast every 30s
|
// Tick interval: advance tracks when needed, broadcast every 30s
|
||||||
let tickCount = 0;
|
let tickCount = 0;
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
|
|
@ -203,7 +256,176 @@ serve({
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return Response.json({ error: "Authentication required" }, { status: 401 });
|
return Response.json({ error: "Authentication required" }, { status: 401 });
|
||||||
}
|
}
|
||||||
return Response.json(libraryTracks, { headers });
|
const tracks = library.getAllTracks().map(t => ({
|
||||||
|
id: t.id,
|
||||||
|
filename: t.filename,
|
||||||
|
title: t.title,
|
||||||
|
artist: t.artist,
|
||||||
|
album: t.album,
|
||||||
|
duration: t.duration,
|
||||||
|
available: t.available,
|
||||||
|
}));
|
||||||
|
return Response.json(tracks, { headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Playlist API: list playlists
|
||||||
|
if (path === "/api/playlists" && req.method === "GET") {
|
||||||
|
const { user, headers } = getOrCreateUser(req, server);
|
||||||
|
if (!user) {
|
||||||
|
return Response.json({ error: "Authentication required" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const playlists = getVisiblePlaylists(user.id, user.is_guest);
|
||||||
|
return Response.json(playlists, { headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Playlist API: create playlist
|
||||||
|
if (path === "/api/playlists" && req.method === "POST") {
|
||||||
|
const { user } = getOrCreateUser(req, server);
|
||||||
|
if (!user) {
|
||||||
|
return Response.json({ error: "Authentication required" }, { status: 401 });
|
||||||
|
}
|
||||||
|
if (user.is_guest) {
|
||||||
|
return Response.json({ error: "Guests cannot create playlists" }, { status: 403 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { name, description, visibility } = await req.json();
|
||||||
|
if (!name || typeof name !== "string" || name.trim().length === 0) {
|
||||||
|
return Response.json({ error: "Name is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const playlist = createPlaylist(user.id, name.trim(), visibility || "private", description);
|
||||||
|
return Response.json(playlist, { status: 201 });
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: "Invalid request" }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Playlist API: get/update/delete single playlist
|
||||||
|
const playlistMatch = path.match(/^\/api\/playlists\/([^/]+)$/);
|
||||||
|
if (playlistMatch) {
|
||||||
|
const playlistId = playlistMatch[1];
|
||||||
|
const { user, headers } = getOrCreateUser(req, server);
|
||||||
|
if (!user) {
|
||||||
|
return Response.json({ error: "Authentication required" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const playlist = getPlaylist(playlistId);
|
||||||
|
if (!playlist) {
|
||||||
|
return Response.json({ error: "Playlist not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "GET") {
|
||||||
|
if (!canViewPlaylist(playlist, user.id, user.is_guest)) {
|
||||||
|
return Response.json({ error: "Access denied" }, { status: 403 });
|
||||||
|
}
|
||||||
|
const tracks = getPlaylistTracks(playlistId);
|
||||||
|
return Response.json({ ...playlist, tracks }, { headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "PUT") {
|
||||||
|
if (!canEditPlaylist(playlist, user.id)) {
|
||||||
|
return Response.json({ error: "Access denied" }, { status: 403 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { name, description, visibility } = await req.json();
|
||||||
|
updatePlaylist(playlistId, { name, description, visibility });
|
||||||
|
const updated = getPlaylist(playlistId);
|
||||||
|
return Response.json(updated);
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: "Invalid request" }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "DELETE") {
|
||||||
|
if (!canEditPlaylist(playlist, user.id)) {
|
||||||
|
return Response.json({ error: "Access denied" }, { status: 403 });
|
||||||
|
}
|
||||||
|
deletePlaylist(playlistId);
|
||||||
|
return Response.json({ success: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Playlist API: add tracks
|
||||||
|
const playlistTracksMatch = path.match(/^\/api\/playlists\/([^/]+)\/tracks$/);
|
||||||
|
if (playlistTracksMatch && req.method === "POST") {
|
||||||
|
const playlistId = playlistTracksMatch[1];
|
||||||
|
const { user } = getOrCreateUser(req, server);
|
||||||
|
if (!user) {
|
||||||
|
return Response.json({ error: "Authentication required" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const playlist = getPlaylist(playlistId);
|
||||||
|
if (!playlist) {
|
||||||
|
return Response.json({ error: "Playlist not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (!canEditPlaylist(playlist, user.id)) {
|
||||||
|
return Response.json({ error: "Access denied" }, { status: 403 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { trackIds, position } = await req.json();
|
||||||
|
if (!Array.isArray(trackIds) || trackIds.length === 0) {
|
||||||
|
return Response.json({ error: "trackIds array required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
for (const trackId of trackIds) {
|
||||||
|
if (!library.getTrack(trackId)) {
|
||||||
|
return Response.json({ error: `Track not found: ${trackId}` }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let insertPos = position;
|
||||||
|
for (const trackId of trackIds) {
|
||||||
|
addTrackToPlaylist(playlistId, trackId, user.id, insertPos);
|
||||||
|
if (insertPos !== undefined) insertPos++;
|
||||||
|
}
|
||||||
|
const tracks = getPlaylistTracks(playlistId);
|
||||||
|
return Response.json({ tracks }, { status: 201 });
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: "Invalid request" }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Playlist API: remove track
|
||||||
|
const playlistTrackMatch = path.match(/^\/api\/playlists\/([^/]+)\/tracks\/(\d+)$/);
|
||||||
|
if (playlistTrackMatch && req.method === "DELETE") {
|
||||||
|
const playlistId = playlistTrackMatch[1];
|
||||||
|
const position = parseInt(playlistTrackMatch[2]);
|
||||||
|
const { user } = getOrCreateUser(req, server);
|
||||||
|
if (!user) {
|
||||||
|
return Response.json({ error: "Authentication required" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const playlist = getPlaylist(playlistId);
|
||||||
|
if (!playlist) {
|
||||||
|
return Response.json({ error: "Playlist not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (!canEditPlaylist(playlist, user.id)) {
|
||||||
|
return Response.json({ error: "Access denied" }, { status: 403 });
|
||||||
|
}
|
||||||
|
removeTrackFromPlaylist(playlistId, position);
|
||||||
|
return Response.json({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Playlist API: reorder tracks
|
||||||
|
const playlistReorderMatch = path.match(/^\/api\/playlists\/([^/]+)\/tracks\/reorder$/);
|
||||||
|
if (playlistReorderMatch && req.method === "PUT") {
|
||||||
|
const playlistId = playlistReorderMatch[1];
|
||||||
|
const { user } = getOrCreateUser(req, server);
|
||||||
|
if (!user) {
|
||||||
|
return Response.json({ error: "Authentication required" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const playlist = getPlaylist(playlistId);
|
||||||
|
if (!playlist) {
|
||||||
|
return Response.json({ error: "Playlist not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (!canEditPlaylist(playlist, user.id)) {
|
||||||
|
return Response.json({ error: "Access denied" }, { status: 403 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { from, to } = await req.json();
|
||||||
|
if (typeof from !== "number" || typeof to !== "number") {
|
||||||
|
return Response.json({ error: "from and to positions required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
reorderPlaylistTrack(playlistId, from, to);
|
||||||
|
const tracks = getPlaylistTracks(playlistId);
|
||||||
|
return Response.json({ tracks });
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: "Invalid request" }, { status: 400 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth: signup
|
// Auth: signup
|
||||||
|
|
@ -396,15 +618,27 @@ serve({
|
||||||
}
|
}
|
||||||
|
|
||||||
// API: serve audio file (requires auth or guest)
|
// API: serve audio file (requires auth or guest)
|
||||||
|
// Supports both filename and track ID (sha256:...)
|
||||||
const trackMatch = path.match(/^\/api\/tracks\/(.+)$/);
|
const trackMatch = path.match(/^\/api\/tracks\/(.+)$/);
|
||||||
if (trackMatch) {
|
if (trackMatch) {
|
||||||
const { user } = getOrCreateUser(req, server);
|
const { user } = getOrCreateUser(req, server);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return Response.json({ error: "Authentication required" }, { status: 401 });
|
return Response.json({ error: "Authentication required" }, { status: 401 });
|
||||||
}
|
}
|
||||||
const filename = decodeURIComponent(trackMatch[1]);
|
const identifier = decodeURIComponent(trackMatch[1]);
|
||||||
if (filename.includes("..")) return new Response("Forbidden", { status: 403 });
|
if (identifier.includes("..")) return new Response("Forbidden", { status: 403 });
|
||||||
const filepath = join(MUSIC_DIR, filename);
|
|
||||||
|
let filepath: string;
|
||||||
|
if (identifier.startsWith("sha256:")) {
|
||||||
|
// Track ID - look up in library
|
||||||
|
const trackPath = library.getFilePath(identifier);
|
||||||
|
if (!trackPath) return new Response("Not found", { status: 404 });
|
||||||
|
filepath = trackPath;
|
||||||
|
} else {
|
||||||
|
// Filename - direct path
|
||||||
|
filepath = join(MUSIC_DIR, identifier);
|
||||||
|
}
|
||||||
|
|
||||||
const f = file(filepath);
|
const f = file(filepath);
|
||||||
if (!(await f.exists())) return new Response("Not found", { status: 404 });
|
if (!(await f.exists())) return new Response("Not found", { status: 404 });
|
||||||
|
|
||||||
|
|
@ -474,26 +708,42 @@ serve({
|
||||||
},
|
},
|
||||||
message(ws: ServerWebSocket<WsData>, message: string | Buffer) {
|
message(ws: ServerWebSocket<WsData>, message: string | Buffer) {
|
||||||
const stream = streams.get(ws.data.streamId);
|
const stream = streams.get(ws.data.streamId);
|
||||||
if (!stream) return;
|
if (!stream) {
|
||||||
|
console.log("[WS] No stream found for:", ws.data.streamId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check permission for control actions
|
// Check permission for control actions
|
||||||
const userId = ws.data.userId;
|
const userId = ws.data.userId;
|
||||||
if (!userId) return;
|
if (!userId) {
|
||||||
|
console.log("[WS] No userId on connection");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const user = findUserById(userId);
|
const user = findUserById(userId);
|
||||||
if (!user) return;
|
if (!user) {
|
||||||
|
console.log("[WS] User not found:", userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Guests can never control playback
|
// Guests can never control playback
|
||||||
if (user.is_guest) return;
|
if (user.is_guest) {
|
||||||
|
console.log("[WS] Guest cannot control playback");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check default permissions or user-specific permissions
|
// Check default permissions or user-specific permissions
|
||||||
const canControl = user.is_admin
|
const canControl = user.is_admin
|
||||||
|| config.defaultPermissions.stream?.includes("control")
|
|| config.defaultPermissions.stream?.includes("control")
|
||||||
|| hasPermission(userId, "stream", ws.data.streamId, "control");
|
|| hasPermission(userId, "stream", ws.data.streamId, "control");
|
||||||
if (!canControl) return;
|
if (!canControl) {
|
||||||
|
console.log("[WS] User lacks control permission:", user.username);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(String(message));
|
const data = JSON.parse(String(message));
|
||||||
|
console.log("[WS] Control action:", data.action, "from", user.username);
|
||||||
if (data.action === "pause") stream.pause();
|
if (data.action === "pause") stream.pause();
|
||||||
else if (data.action === "unpause") stream.unpause();
|
else if (data.action === "unpause") stream.unpause();
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue