diff --git a/README.md b/README.md index a22fda5..00ed647 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# musicroom +# NeoRose To install dependencies: diff --git a/auth.ts b/auth.ts new file mode 100644 index 0000000..90ba6f4 --- /dev/null +++ b/auth.ts @@ -0,0 +1,58 @@ +import { validateSession, hasPermission, type User } from "./db"; + +const COOKIE_NAME = "musicroom_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 getRequestMeta(req: Request, server?: { requestIP?: (req: Request) => { address: string } | null }): { userAgent?: string; ipAddress?: string } { + const userAgent = req.headers.get("user-agent") ?? undefined; + const ipAddress = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() + ?? req.headers.get("x-real-ip") + ?? server?.requestIP?.(req)?.address + ?? undefined; + return { userAgent, ipAddress }; +} + +export function getUser(req: Request, server?: { requestIP?: (req: Request) => { address: string } | null }): User | null { + const token = getSessionToken(req); + if (!token) return null; + const { userAgent, ipAddress } = getRequestMeta(req, server); + return validateSession(token, userAgent, ipAddress); +} + +export function requireUser(req: Request, server?: { requestIP?: (req: Request) => { address: string } | null }): User { + const user = getUser(req, server); + if (!user) { + throw new Response("Unauthorized", { status: 401 }); + } + return user; +} + +export function requirePermission( + req: Request, + resourceType: string, + resourceId: string | null, + permission: string, + server?: { requestIP?: (req: Request) => { address: string } | null } +): User { + const user = requireUser(req, server); + if (!user.is_admin && !hasPermission(user.id, resourceType, resourceId, permission)) { + throw new Response("Forbidden", { status: 403 }); + } + return user; +} + +export function setSessionCookie(token: string): string { + const maxAge = 7 * 24 * 60 * 60; // 7 days + return `${COOKIE_NAME}=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}`; +} + +export function clearSessionCookie(): string { + return `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0`; +} diff --git a/db.ts b/db.ts new file mode 100644 index 0000000..49f3d90 --- /dev/null +++ b/db.ts @@ -0,0 +1,217 @@ +import { Database } from "bun:sqlite"; + +const DB_PATH = "./musicroom.db"; +const SESSION_EXPIRY_DAYS = 7; + +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, + created_at INTEGER DEFAULT (unixepoch()) + ) +`); + +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; + 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; +} + +// User functions +export async function createUser(username: string, password: string): Promise { + const password_hash = await Bun.password.hash(password); + + // First user becomes admin + const userCount = db.query("SELECT COUNT(*) as count FROM users").get() as { count: number }; + const is_admin = userCount.count === 0 ? 1 : 0; + + const result = db.query( + "INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?) RETURNING *" + ).get(username, password_hash, is_admin) as any; + + return { ...result, is_admin: !!result.is_admin }; +} + +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 }; +} + +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 }; +} + +export async function validatePassword(user: User, password: string): Promise { + return Bun.password.verify(password, user.password_hash); +} + +// Session functions +export function createSession(userId: number, userAgent?: string, ipAddress?: string): string { + const token = crypto.randomUUID(); + const expires_at = Math.floor(Date.now() / 1000) + (SESSION_EXPIRY_DAYS * 24 * 60 * 60); + + 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 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; + + // 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 newExpiry = now + (SESSION_EXPIRY_DAYS * 24 * 60 * 60); + db.query("UPDATE sessions SET expires_at = ? WHERE token = ?").run(newExpiry, token); + + return findUserById(session.user_id); +} + +export function deleteSession(token: string): void { + db.query("DELETE FROM sessions WHERE token = ?").run(token); +} + +export function deleteExpiredSessions(): void { + const now = Math.floor(Date.now() / 1000); + db.query("DELETE FROM sessions WHERE expires_at <= ?").run(now); +} + +// Permission functions +export function hasPermission( + userId: number, + resourceType: string, + resourceId: string | null, + permission: string +): boolean { + const user = findUserById(userId); + if (!user) return false; + if (user.is_admin) return true; + + const result = db.query(` + SELECT 1 FROM permissions + WHERE user_id = ? + AND resource_type = ? + AND (resource_id = ? OR resource_id IS NULL) + AND permission = ? + LIMIT 1 + `).get(userId, resourceType, resourceId, permission); + + return !!result; +} + +export function grantPermission( + userId: number, + resourceType: string, + resourceId: string | null, + permission: string +): void { + db.query(` + INSERT OR IGNORE INTO permissions (user_id, resource_type, resource_id, permission) + VALUES (?, ?, ?, ?) + `).run(userId, resourceType, resourceId, permission); +} + +export function revokePermission( + userId: number, + resourceType: string, + resourceId: string | null, + permission: string +): void { + db.query(` + DELETE FROM permissions + WHERE user_id = ? AND resource_type = ? AND resource_id IS ? AND permission = ? + `).run(userId, resourceType, resourceId, permission); +} + +export function getUserPermissions(userId: number): Permission[] { + return db.query("SELECT * FROM permissions WHERE user_id = ?").all(userId) as Permission[]; +} + +export function getAllUsers(): Omit[] { + const users = db.query("SELECT id, username, is_admin, created_at FROM users").all() as any[]; + return users.map(u => ({ ...u, is_admin: !!u.is_admin })); +} + +export function getUserSessions(userId: number): Omit[] { + return db.query( + "SELECT user_id, expires_at, created_at, user_agent, ip_address FROM sessions WHERE user_id = ? AND expires_at > ?" + ).all(userId, Math.floor(Date.now() / 1000)) as Omit[]; +} + +// Cleanup expired sessions periodically +setInterval(() => deleteExpiredSessions(), 60 * 60 * 1000); // Every hour diff --git a/music/20 Acoustic - Live at the Hollywood Bowl (Part I).m4a b/music/20 Acoustic - Live at the Hollywood Bowl (Part I).m4a new file mode 100644 index 0000000..1f820dc Binary files /dev/null and b/music/20 Acoustic - Live at the Hollywood Bowl (Part I).m4a differ diff --git a/musicroom.db b/musicroom.db new file mode 100644 index 0000000..cbbe635 Binary files /dev/null and b/musicroom.db differ diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..3b33b7d --- /dev/null +++ b/public/app.js @@ -0,0 +1,693 @@ +(function() { + const audio = new Audio(); + let ws = null; + let currentStreamId = null; + let currentFilename = null; + let currentTitle = null; + let serverTimestamp = 0; + let serverTrackDuration = 0; + let lastServerUpdate = 0; + let serverPaused = true; + let synced = false; + let preMuteVolume = 1; + let localTimestamp = 0; + let playlist = []; + let currentIndex = 0; + let currentUser = null; + let prefetchController = null; + let loadingSegments = new Set(); + let trackCaches = new Map(); // Map of filename -> Set of cached segment indices + let trackBlobs = new Map(); // Map of filename -> Blob URL for fully cached tracks + let audioBytesPerSecond = 20000; // Audio bitrate estimate for range requests + let downloadSpeed = 0; // Actual network download speed + let recentDownloads = []; // Track recent downloads for speed calculation + + const $ = (s) => document.querySelector(s); + const SEGMENTS = 20; + const STORAGE_KEY = "musicroom_volume"; + + // Load saved volume + const savedVolume = localStorage.getItem(STORAGE_KEY); + if (savedVolume !== null) { + audio.volume = parseFloat(savedVolume); + $("#volume").value = savedVolume; + } + + // Create buffer segments + for (let i = 0; i < SEGMENTS; i++) { + const seg = document.createElement("div"); + seg.className = "segment"; + $("#buffer-bar").appendChild(seg); + } + + function fmt(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"); + } + + function getTrackCache(filename) { + if (!filename) return new Set(); + if (!trackCaches.has(filename)) { + trackCaches.set(filename, new Set()); + } + return trackCaches.get(filename); + } + + // Get track URL - prefers cached blob, falls back to API + function getTrackUrl(filename) { + return trackBlobs.get(filename) || "/api/tracks/" + encodeURIComponent(filename); + } + + // Load a track blob from storage or fetch from server + async function loadTrackBlob(filename) { + // Check if already in memory + if (trackBlobs.has(filename)) { + return trackBlobs.get(filename); + } + + // Check persistent storage + const cached = await TrackStorage.get(filename); + if (cached) { + const blobUrl = URL.createObjectURL(cached.blob); + trackBlobs.set(filename, blobUrl); + // Mark all segments as cached + const trackCache = getTrackCache(filename); + for (let i = 0; i < SEGMENTS; i++) trackCache.add(i); + bulkDownloadStarted.set(filename, true); + return blobUrl; + } + + return null; + } + + // Download and cache a track + async function downloadAndCacheTrack(filename) { + if (bulkDownloadStarted.get(filename)) return trackBlobs.get(filename); + bulkDownloadStarted.set(filename, true); + + try { + const startTime = performance.now(); + const res = await fetch("/api/tracks/" + encodeURIComponent(filename)); + const data = await res.arrayBuffer(); + const elapsed = (performance.now() - startTime) / 1000; + + // Mark all segments as cached + const trackCache = getTrackCache(filename); + for (let i = 0; i < 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); + trackBlobs.set(filename, blobUrl); + + // Persist to storage + await TrackStorage.set(filename, blob, contentType); + + // Update download speed + if (elapsed > 0 && data.byteLength > 0) { + recentDownloads.push(data.byteLength / elapsed); + if (recentDownloads.length > 5) recentDownloads.shift(); + downloadSpeed = recentDownloads.reduce((a, b) => a + b, 0) / recentDownloads.length; + } + + return blobUrl; + } catch (e) { + bulkDownloadStarted.set(filename, false); + return null; + } + } + + function getServerTime() { + if (serverPaused) return serverTimestamp; + return serverTimestamp + (Date.now() - lastServerUpdate) / 1000; + } + + function canControl() { + if (!currentUser) return false; + if (currentUser.isAdmin) return true; + // Check if user has control permission for current stream + return currentUser.permissions?.some(p => + p.resource_type === "stream" && + (p.resource_id === currentStreamId || p.resource_id === null) && + p.permission === "control" + ); + } + + function updateAuthUI() { + if (currentUser) { + $("#login-panel").classList.add("hidden"); + $("#player-content").classList.add("visible"); + $("#current-username").textContent = currentUser.username; + $("#admin-badge").style.display = currentUser.isAdmin ? "inline" : "none"; + } else { + $("#login-panel").classList.remove("hidden"); + $("#player-content").classList.remove("visible"); + } + updateUI(); + } + + async function loadCurrentUser() { + try { + const res = await fetch("/api/auth/me"); + const data = await res.json(); + currentUser = data.user; + if (currentUser && data.permissions) { + currentUser.permissions = data.permissions; + } + updateAuthUI(); + } catch (e) { + currentUser = null; + updateAuthUI(); + } + } + + function updateUI() { + const isPlaying = synced ? !serverPaused : !audio.paused; + $("#btn-sync").classList.toggle("synced", synced); + $("#btn-sync").title = synced ? "Unsync" : "Sync"; + $("#status").textContent = synced ? "Synced" : "Local"; + $("#sync-indicator").classList.toggle("visible", synced); + $("#progress-bar").classList.toggle("synced", synced); + $("#progress-bar").classList.toggle("local", !synced); + $("#progress-bar").classList.toggle("muted", audio.volume === 0); + $("#btn-mute").textContent = audio.volume === 0 ? "🔇" : "🔊"; + $("#status-icon").textContent = isPlaying ? "⏸" : "▶"; + + // Show/hide controls based on permissions + const hasControl = canControl(); + $("#status-icon").style.cursor = hasControl || !synced ? "pointer" : "default"; + } + + // Update progress bar and buffer segments + setInterval(() => { + if (serverTrackDuration <= 0) return; + let t, dur; + if (synced) { + t = audio.paused ? getServerTime() : audio.currentTime; + dur = audio.duration || serverTrackDuration; + } else { + t = audio.paused ? localTimestamp : audio.currentTime; + dur = audio.duration || serverTrackDuration; + } + const pct = Math.min((t / dur) * 100, 100); + $("#progress-bar").style.width = pct + "%"; + $("#time-current").textContent = fmt(t); + $("#time-total").textContent = fmt(dur); + + // Update buffer segments + const segments = $("#buffer-bar").children; + const segmentDur = dur / SEGMENTS; + let availableCount = 0; + for (let i = 0; i < SEGMENTS; i++) { + const segStart = i * segmentDur; + const segEnd = (i + 1) * segmentDur; + const trackCache = getTrackCache(currentFilename); + let available = trackCache.has(i); // Check our cache first + if (!available) { + for (let j = 0; j < audio.buffered.length; j++) { + const bufStart = audio.buffered.start(j); + const bufEnd = audio.buffered.end(j); + if (bufStart <= segStart && bufEnd >= segEnd) { + available = true; + break; + } + } + } + if (available) availableCount++; + segments[i].classList.toggle("available", available); + segments[i].classList.toggle("loading", !available && loadingSegments.has(i)); + } + + // Update download speed display + const kbps = downloadSpeed > 0 ? downloadSpeed * 8 / 1000 : 0; + const bufferPct = Math.round(availableCount / SEGMENTS * 100); + let speedText = ""; + if (kbps > 0) { + speedText = kbps >= 1024 ? ` | ${(kbps / 1024).toFixed(1)} Mb/s` : ` | ${Math.round(kbps)} kb/s`; + } + $("#download-speed").textContent = `${bufferPct}% buffered${speedText}`; + }, 250); + + // Prefetch missing segments + let prefetching = false; + let bulkDownloadStarted = new Map(); // Track if bulk download started per filename + const FAST_THRESHOLD = 10 * 1024 * 1024; // 10 MB/s + + async function fetchSegment(i, segStart, segEnd) { + const trackCache = getTrackCache(currentFilename); + if (loadingSegments.has(i) || trackCache.has(i)) return; + loadingSegments.add(i); + try { + const byteStart = Math.floor(segStart * audioBytesPerSecond); + const byteEnd = Math.floor(segEnd * audioBytesPerSecond); + const startTime = performance.now(); + const res = await fetch("/api/tracks/" + encodeURIComponent(currentFilename), { + headers: { "Range": `bytes=${byteStart}-${byteEnd}` } + }); + const data = await res.arrayBuffer(); + const elapsed = (performance.now() - startTime) / 1000; + + // Mark segment as cached + trackCache.add(i); + + // Update audio bitrate estimate + const bytesReceived = data.byteLength; + const durationCovered = segEnd - segStart; + if (bytesReceived > 0 && durationCovered > 0) { + audioBytesPerSecond = Math.round(bytesReceived / durationCovered); + } + + // Update download speed (rolling average of last 5 downloads) + if (elapsed > 0 && bytesReceived > 0) { + recentDownloads.push(bytesReceived / elapsed); + if (recentDownloads.length > 5) recentDownloads.shift(); + downloadSpeed = recentDownloads.reduce((a, b) => a + b, 0) / recentDownloads.length; + } + } catch (e) {} + loadingSegments.delete(i); + } + + // Background bulk download - runs independently + async function startBulkDownload() { + const filename = currentFilename; + if (!filename || bulkDownloadStarted.get(filename)) return; + + const blobUrl = await downloadAndCacheTrack(filename); + + // Switch to blob URL if still on this track + if (blobUrl && currentFilename === filename && audio.src && !audio.src.startsWith("blob:")) { + const currentTime = audio.currentTime; + const wasPlaying = !audio.paused; + audio.src = blobUrl; + audio.currentTime = currentTime; + if (wasPlaying) audio.play().catch(() => {}); + } + } + + async function prefetchSegments() { + if (prefetching || !currentFilename || !audio.src || serverTrackDuration <= 0) return; + prefetching = true; + + const segmentDur = serverTrackDuration / SEGMENTS; + const missingSegments = []; + const trackCache = getTrackCache(currentFilename); + + // Find all missing segments (not in audio buffer AND not in our cache) + for (let i = 0; i < SEGMENTS; i++) { + if (trackCache.has(i) || loadingSegments.has(i)) continue; + + const segStart = i * segmentDur; + const segEnd = (i + 1) * segmentDur; + let available = false; + for (let j = 0; j < audio.buffered.length; j++) { + if (audio.buffered.start(j) <= segStart && 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 (downloadSpeed >= FAST_THRESHOLD && !bulkDownloadStarted.get(currentFilename)) { + 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; + } + + // Run prefetch loop + setInterval(() => { + if (currentFilename && audio.src) { + prefetchSegments(); + } + }, 1000); + + // Load streams and try to connect + async function loadStreams() { + try { + const res = await fetch("/api/streams"); + const streams = await res.json(); + if (streams.length === 0) { + $("#track-title").textContent = "No streams available"; + return; + } + if (streams.length > 1) { + const sel = document.createElement("select"); + for (const s of streams) { + const opt = document.createElement("option"); + opt.value = s.id; + opt.textContent = s.name; + sel.appendChild(opt); + } + sel.onchange = () => connectStream(sel.value); + $("#stream-select").appendChild(sel); + } + connectStream(streams[0].id); + } catch (e) { + $("#track-title").textContent = "Server unavailable"; + $("#status").textContent = "Local (offline)"; + synced = false; + updateUI(); + } + } + + function connectStream(id) { + if (ws) { + const oldWs = ws; + ws = null; + oldWs.onclose = null; + oldWs.close(); + } + currentStreamId = id; + const proto = location.protocol === "https:" ? "wss:" : "ws:"; + ws = new WebSocket(proto + "//" + location.host + "/api/streams/" + id + "/ws"); + ws.onmessage = (e) => handleUpdate(JSON.parse(e.data)); + ws.onclose = () => { + if (synced && ws) { + $("#status").textContent = "Disconnected. Reconnecting..."; + $("#sync-indicator").classList.add("disconnected"); + setTimeout(() => connectStream(id), 3000); + } + }; + ws.onopen = () => { + synced = true; + $("#sync-indicator").classList.remove("disconnected"); + updateUI(); + }; + } + + function renderPlaylist() { + const container = $("#playlist"); + container.innerHTML = ""; + playlist.forEach((track, i) => { + const div = document.createElement("div"); + div.className = "track" + (i === currentIndex ? " active" : ""); + const title = track.title?.trim() || track.filename.replace(/\.[^.]+$/, ""); + div.innerHTML = `${title}${fmt(track.duration)}`; + div.onclick = async () => { + if (synced && currentStreamId) { + fetch("/api/streams/" + currentStreamId + "/jump", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ index: i }) + }); + } else { + currentIndex = i; + currentFilename = track.filename; + serverTrackDuration = track.duration; + $("#track-title").textContent = title; + // Reset loading state for new track (cache persists) + loadingSegments.clear(); + // Try to load from cache first + const cachedUrl = await loadTrackBlob(track.filename); + audio.src = cachedUrl || getTrackUrl(track.filename); + audio.currentTime = 0; + localTimestamp = 0; + audio.play(); + renderPlaylist(); + } + }; + container.appendChild(div); + }); + } + + async function handleUpdate(data) { + if (!data.track) { + $("#track-title").textContent = "No tracks"; + return; + } + $("#stream-name").textContent = data.streamName || ""; + serverTimestamp = data.currentTimestamp; + serverTrackDuration = data.track.duration; + lastServerUpdate = Date.now(); + const wasServerPaused = serverPaused; + serverPaused = data.paused ?? true; + + // Update playlist if provided + if (data.playlist) { + playlist = data.playlist; + currentIndex = data.currentIndex ?? 0; + renderPlaylist(); + } else if (data.currentIndex !== undefined && data.currentIndex !== currentIndex) { + currentIndex = data.currentIndex; + renderPlaylist(); + } + + // Cache track info for local mode + const isNewTrack = data.track.filename !== currentFilename; + if (isNewTrack) { + currentFilename = data.track.filename; + currentTitle = data.track.title; + $("#track-title").textContent = data.track.title; + loadingSegments.clear(); + } + + if (synced) { + if (!serverPaused) { + // Server is playing - ensure we're playing and synced + if (isNewTrack || !audio.src) { + // Try cache first + const cachedUrl = await loadTrackBlob(currentFilename); + audio.src = cachedUrl || getTrackUrl(currentFilename); + } + if (audio.paused) { + audio.currentTime = data.currentTimestamp; + audio.play().catch(() => {}); + } else { + // Check drift + const drift = Math.abs(audio.currentTime - data.currentTimestamp); + if (drift >= 2) { + audio.currentTime = data.currentTimestamp; + } + } + } else if (!wasServerPaused && serverPaused) { + // Server just paused + audio.pause(); + } + } + updateUI(); + } + + $("#btn-sync").onclick = () => { + if (synced) { + // Unsync - go to local mode + synced = false; + localTimestamp = audio.currentTime || getServerTime(); + if (ws) ws.close(); + ws = null; + } else { + // Try to sync + if (currentStreamId) { + connectStream(currentStreamId); + } + } + updateUI(); + }; + + function togglePlayback() { + if (!currentFilename) return; + + if (synced) { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ action: serverPaused ? "unpause" : "pause" })); + } + } else { + if (audio.paused) { + if (!audio.src) { + audio.src = getTrackUrl(currentFilename); + audio.currentTime = localTimestamp; + } + audio.play(); + } else { + localTimestamp = audio.currentTime; + audio.pause(); + } + updateUI(); + } + } + + $("#status-icon").onclick = togglePlayback; + + $("#progress-container").onmousemove = (e) => { + if (serverTrackDuration <= 0) return; + const rect = $("#progress-container").getBoundingClientRect(); + const pct = (e.clientX - rect.left) / rect.width; + const hoverTime = pct * serverTrackDuration; + const tooltip = $("#seek-tooltip"); + tooltip.textContent = fmt(hoverTime); + tooltip.style.left = (pct * 100) + "%"; + tooltip.style.display = "block"; + }; + + $("#progress-container").onmouseleave = () => { + $("#seek-tooltip").style.display = "none"; + }; + + $("#progress-container").onclick = (e) => { + const dur = synced ? serverTrackDuration : (audio.duration || serverTrackDuration); + if (!currentFilename || dur <= 0) return; + const rect = $("#progress-container").getBoundingClientRect(); + const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + const seekTime = pct * dur; + + if (synced && currentStreamId) { + fetch("/api/streams/" + currentStreamId + "/seek", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ timestamp: seekTime }) + }); + } else { + if (!audio.src) { + audio.src = getTrackUrl(currentFilename); + } + audio.currentTime = seekTime; + localTimestamp = seekTime; + } + }; + + $("#btn-mute").onclick = () => { + if (audio.volume > 0) { + preMuteVolume = audio.volume; + audio.volume = 0; + $("#volume").value = 0; + } else { + audio.volume = preMuteVolume; + $("#volume").value = preMuteVolume; + } + localStorage.setItem(STORAGE_KEY, audio.volume); + updateUI(); + }; + + $("#volume").oninput = (e) => { + audio.volume = e.target.value; + localStorage.setItem(STORAGE_KEY, e.target.value); + updateUI(); + }; + + audio.onplay = () => { $("#progress-bar").classList.add("playing"); updateUI(); }; + audio.onpause = () => { $("#progress-bar").classList.remove("playing"); updateUI(); }; + + // Track loading state from audio element's progress + audio.onprogress = () => { + if (serverTrackDuration <= 0) return; + const segmentDur = serverTrackDuration / SEGMENTS; + loadingSegments.clear(); + for (let i = 0; i < SEGMENTS; i++) { + const segStart = i * segmentDur; + const segEnd = (i + 1) * segmentDur; + let fullyBuffered = false; + let partiallyBuffered = false; + for (let j = 0; j < audio.buffered.length; j++) { + const bufStart = audio.buffered.start(j); + const bufEnd = 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) { + loadingSegments.add(i); + } + } + }; + + // Auth event handlers - tab switching + $("#tab-login").onclick = () => { + $("#tab-login").classList.add("active"); + $("#tab-signup").classList.remove("active"); + $("#login-fields").classList.remove("hidden"); + $("#signup-fields").classList.add("hidden"); + $("#auth-error").textContent = ""; + $("#signup-error").textContent = ""; + }; + + $("#tab-signup").onclick = () => { + $("#tab-signup").classList.add("active"); + $("#tab-login").classList.remove("active"); + $("#signup-fields").classList.remove("hidden"); + $("#login-fields").classList.add("hidden"); + $("#auth-error").textContent = ""; + $("#signup-error").textContent = ""; + }; + + $("#btn-login").onclick = async () => { + const username = $("#login-username").value.trim(); + const password = $("#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) { + $("#auth-error").textContent = data.error || "Login failed"; + return; + } + $("#login-username").value = ""; + $("#login-password").value = ""; + await loadCurrentUser(); + loadStreams(); + } catch (e) { + $("#auth-error").textContent = "Login failed"; + } + }; + + $("#btn-signup").onclick = async () => { + const username = $("#signup-username").value.trim(); + const password = $("#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) { + $("#signup-error").textContent = data.error || "Signup failed"; + return; + } + $("#signup-username").value = ""; + $("#signup-password").value = ""; + await loadCurrentUser(); + loadStreams(); + } catch (e) { + $("#signup-error").textContent = "Signup failed"; + } + }; + + $("#btn-logout").onclick = async () => { + await fetch("/api/auth/logout", { method: "POST" }); + currentUser = null; + updateAuthUI(); + }; + + // Initialize storage and load cached tracks + async function initStorage() { + await TrackStorage.init(); + const cached = await TrackStorage.list(); + console.log(`TrackStorage: ${cached.length} tracks cached`); + } + + // Initialize + initStorage().then(() => { + loadCurrentUser().then(() => { + if (currentUser) loadStreams(); + }); + }); +})(); diff --git a/public/index.html b/public/index.html index b42c9c6..758131a 100644 --- a/public/index.html +++ b/public/index.html @@ -3,191 +3,65 @@ -MusicRoom - +NeoRose +
-

MusicRoom

-
-
-
Loading...
-
+

MusicRoom

+ +
+

Sign in to continue

+
+ + +
+
+ + + +
+
+
-
-
0:000:00
-
- - - - - - + +
+
+ + +
+
+
+
+
+ Loading... + sync +
+
+
0:000:00
+
+ +
+
+
+
+
+ 🔊 + +
+
+
-
- + + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..a2ac528 --- /dev/null +++ b/public/styles.css @@ -0,0 +1,66 @@ +* { margin: 0; padding: 0; box-sizing: border-box; } +body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #111; color: #eee; display: flex; justify-content: center; align-items: center; min-height: 100vh; } +#app { width: 100%; max-width: 480px; padding: 2rem; } +h1 { font-size: 1.2rem; color: #888; margin-bottom: 1.5rem; display: flex; align-items: center; gap: 0.5rem; } +#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; } +#stream-select { margin-bottom: 1.5rem; } +#stream-select select { background: #222; color: #eee; border: 1px solid #333; padding: 0.4rem 0.8rem; border-radius: 4px; font-size: 0.9rem; } +#now-playing { margin-bottom: 0.5rem; } +#stream-name { font-size: 0.85rem; color: #888; margin-bottom: 0.2rem; } +#track-name { font-size: 1.4rem; font-weight: 600; display: flex; align-items: center; gap: 0.5rem; } +#btn-sync { font-size: 0.75rem; cursor: pointer; color: #666; transition: color 0.2s, text-shadow 0.2s; text-transform: uppercase; letter-spacing: 0.05em; } +#btn-sync:hover { color: #888; } +#btn-sync.synced { color: #4e8; text-shadow: 0 0 8px #4e8, 0 0 12px #4e8; } +#time { display: flex; justify-content: space-between; font-size: 0.8rem; color: #888; margin-bottom: 0.3rem; } +#progress-row { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; } +#status-icon { font-size: 0.9rem; width: 1rem; text-align: center; cursor: pointer; } +#progress-container { background: #222; border-radius: 4px; height: 6px; 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: 12px; background: #333; color: #eee; padding: 2px 6px; border-radius: 3px; font-size: 0.75rem; pointer-events: none; display: none; transform: translateX(-50%); } +#buffer-bar { display: flex; gap: 2px; margin-bottom: 0.5rem; } +#buffer-bar .segment { flex: 1; height: 4px; background: #333; border-radius: 2px; } +#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.7rem; color: #666; text-align: right; margin-bottom: 0.5rem; } +#time { display: flex; justify-content: space-between; font-size: 0.8rem; color: #888; margin-bottom: 1rem; } +#controls { display: flex; gap: 0.5rem; align-items: center; } +#btn-mute { font-size: 1.2rem; cursor: pointer; opacity: 0.8; transition: opacity 0.2s; } +#btn-mute:hover { opacity: 1; } +#volume { width: 100px; accent-color: #4e8; } +button { background: #222; color: #eee; border: 1px solid #333; padding: 0.5rem 1.2rem; border-radius: 4px; cursor: pointer; font-size: 0.9rem; } +button:hover { background: #333; } +#status { margin-top: 1.5rem; font-size: 0.8rem; color: #666; } +.empty { color: #666; font-style: italic; } +#auth-section { margin-bottom: 1.5rem; display: flex; gap: 0.5rem; align-items: center; justify-content: space-between; } +#auth-section .user-info { display: flex; align-items: center; gap: 0.5rem; } +#auth-section .username { color: #4e8; font-weight: 600; } +#auth-section .admin-badge { background: #c4f; color: #111; padding: 0.1rem 0.4rem; border-radius: 3px; font-size: 0.7rem; } +#login-panel { display: flex; flex-direction: column; gap: 1rem; padding: 2rem; background: #1a1a1a; border-radius: 8px; border: 1px solid #333; } +#login-panel.hidden { display: none; } +#login-panel h2 { font-size: 1.1rem; color: #888; margin-bottom: 0.5rem; } +#login-panel .tabs { display: flex; gap: 1rem; margin-bottom: 1rem; } +#login-panel .tabs button { background: none; border: none; color: #666; font-size: 1rem; cursor: pointer; padding: 0.5rem 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.6rem; border-radius: 4px; font-size: 0.95rem; } +#login-panel .form-group { display: flex; flex-direction: column; gap: 0.5rem; } +#login-panel .form-group.hidden { display: none; } +#login-panel .submit-btn { background: #4e8; color: #111; border: none; padding: 0.6rem; border-radius: 4px; font-size: 0.95rem; cursor: pointer; font-weight: 600; } +#login-panel .submit-btn:hover { background: #5fa; } +#auth-error, #signup-error { color: #e44; font-size: 0.8rem; } +#player-content { display: none; } +#player-content.visible { display: block; } +#playlist { margin-top: 1.5rem; max-height: 300px; overflow-y: auto; } +#playlist .track { padding: 0.5rem 0.75rem; border-radius: 4px; cursor: pointer; font-size: 0.9rem; display: flex; justify-content: space-between; } +#playlist .track:hover { background: #222; } +#playlist .track.active { background: #2a4a3a; color: #4e8; } +#playlist .track .duration { color: #666; font-size: 0.8rem; } +::-webkit-scrollbar { width: 12px; } +::-webkit-scrollbar-track { background-color: #000; border-radius: 6px; } +::-webkit-scrollbar-thumb { background-color: #333; border-radius: 6px; } +::-webkit-scrollbar-thumb:hover { background-color: #555; } diff --git a/public/trackStorage.js b/public/trackStorage.js new file mode 100644 index 0000000..b658ad6 --- /dev/null +++ b/public/trackStorage.js @@ -0,0 +1,167 @@ +// Track Storage Abstraction Layer +// Provides a unified interface for storing/retrieving audio blobs +// Default implementation uses IndexedDB, can be swapped for Electron file API + +const TrackStorage = (function() { + const DB_NAME = 'musicroom'; + const DB_VERSION = 1; + const STORE_NAME = 'tracks'; + + let db = null; + let initPromise = null; + + // Initialize IndexedDB + function init() { + if (initPromise) return initPromise; + + initPromise = new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onerror = () => { + console.warn('TrackStorage: IndexedDB failed to open'); + resolve(false); + }; + + request.onsuccess = () => { + db = request.result; + resolve(true); + }; + + request.onupgradeneeded = (event) => { + const database = event.target.result; + if (!database.objectStoreNames.contains(STORE_NAME)) { + database.createObjectStore(STORE_NAME, { keyPath: 'filename' }); + } + }; + }); + + return initPromise; + } + + // Check if a track is cached + async function has(filename) { + await init(); + if (!db) return false; + + return new Promise((resolve) => { + const transaction = db.transaction(STORE_NAME, 'readonly'); + const store = transaction.objectStore(STORE_NAME); + const request = store.getKey(filename); + + request.onsuccess = () => resolve(request.result !== undefined); + request.onerror = () => resolve(false); + }); + } + + // Get a track blob, returns { blob, contentType } or null + async function get(filename) { + await init(); + if (!db) return null; + + return new Promise((resolve) => { + const transaction = db.transaction(STORE_NAME, 'readonly'); + const store = transaction.objectStore(STORE_NAME); + const request = store.get(filename); + + request.onsuccess = () => { + const result = request.result; + if (result) { + resolve({ blob: result.blob, contentType: result.contentType }); + } else { + resolve(null); + } + }; + request.onerror = () => resolve(null); + }); + } + + // Store a track blob + async function set(filename, blob, contentType) { + await init(); + if (!db) return false; + + return new Promise((resolve) => { + const transaction = db.transaction(STORE_NAME, 'readwrite'); + const store = transaction.objectStore(STORE_NAME); + const request = store.put({ filename, blob, contentType, cachedAt: Date.now() }); + + request.onsuccess = () => resolve(true); + request.onerror = () => resolve(false); + }); + } + + // Remove a track from cache + async function remove(filename) { + await init(); + if (!db) return false; + + return new Promise((resolve) => { + const transaction = db.transaction(STORE_NAME, 'readwrite'); + const store = transaction.objectStore(STORE_NAME); + const request = store.delete(filename); + + request.onsuccess = () => resolve(true); + request.onerror = () => resolve(false); + }); + } + + // Clear all cached tracks + async function clear() { + await init(); + if (!db) return false; + + return new Promise((resolve) => { + const transaction = db.transaction(STORE_NAME, 'readwrite'); + const store = transaction.objectStore(STORE_NAME); + const request = store.clear(); + + request.onsuccess = () => resolve(true); + request.onerror = () => resolve(false); + }); + } + + // List all cached track filenames + async function list() { + await init(); + if (!db) return []; + + return new Promise((resolve) => { + const transaction = db.transaction(STORE_NAME, 'readonly'); + const store = transaction.objectStore(STORE_NAME); + const request = store.getAllKeys(); + + request.onsuccess = () => resolve(request.result || []); + request.onerror = () => resolve([]); + }); + } + + // Get storage stats + async function getStats() { + await init(); + if (!db) return { count: 0, totalSize: 0 }; + + return new Promise((resolve) => { + const transaction = db.transaction(STORE_NAME, 'readonly'); + const store = transaction.objectStore(STORE_NAME); + const request = store.getAll(); + + request.onsuccess = () => { + const tracks = request.result || []; + const totalSize = tracks.reduce((sum, t) => sum + (t.blob?.size || 0), 0); + resolve({ count: tracks.length, totalSize }); + }; + request.onerror = () => resolve({ count: 0, totalSize: 0 }); + }); + } + + return { + init, + has, + get, + set, + remove, + clear, + list, + getStats + }; +})(); diff --git a/server.ts b/server.ts index 596b934..a29fb62 100644 --- a/server.ts +++ b/server.ts @@ -3,6 +3,27 @@ import { parseFile } from "music-metadata"; import { Stream, type Track } from "./stream"; import { readdir, stat } from "fs/promises"; import { join } from "path"; +import { + createUser, + findUserByUsername, + validatePassword, + createSession, + deleteSession, + validateSession, + hasPermission, + getUserPermissions, + getAllUsers, + grantPermission, + revokePermission, + findUserById, +} from "./db"; +import { + getUser, + requireUser, + requirePermission, + setSessionCookie, + clearSessionCookie, +} from "./auth"; const MUSIC_DIR = join(import.meta.dir, "music"); const PLAYLIST_PATH = join(import.meta.dir, "playlist.json"); @@ -14,7 +35,7 @@ async function loadTrack(filename: string): Promise { try { const metadata = await parseFile(filepath, { duration: true }); const duration = metadata.format.duration ?? 0; - const title = metadata.common.title ?? filename.replace(/\.[^.]+$/, ""); + const title = metadata.common.title?.trim() || filename.replace(/\.[^.]+$/, ""); console.log(`Track: ${filename} | duration: ${duration}s | title: ${title}`); return { filename, title, duration }; } catch (e) { @@ -72,7 +93,7 @@ setInterval(() => { } }, 1000); -type WsData = { streamId: string }; +type WsData = { streamId: string; userId: number | null }; serve({ port: parseInt("3001"), @@ -84,13 +105,18 @@ serve({ if (path.match(/^\/api\/streams\/([^/]+)\/ws$/)) { const id = path.split("/")[3]; if (!streams.has(id)) return new Response("Stream not found", { status: 404 }); - const ok = server.upgrade(req, { data: { streamId: id } }); + const user = getUser(req, server); + const ok = server.upgrade(req, { data: { streamId: id, userId: user?.id ?? null } }); if (ok) return undefined; return new Response("WebSocket upgrade failed", { status: 500 }); } - // API: list streams + // API: list streams (requires auth) if (path === "/api/streams") { + const user = getUser(req, server); + if (!user) { + return Response.json({ error: "Authentication required" }, { status: 401 }); + } const list = [...streams.values()].map((s) => ({ id: s.id, name: s.name, @@ -99,6 +125,174 @@ serve({ return Response.json(list); } + // Auth: signup + if (path === "/api/auth/signup" && req.method === "POST") { + try { + const { username, password } = await req.json(); + if (!username || !password) { + return Response.json({ error: "Username and password required" }, { status: 400 }); + } + if (username.length < 3 || password.length < 6) { + return Response.json({ error: "Username min 3 chars, password min 6 chars" }, { status: 400 }); + } + const existing = findUserByUsername(username); + if (existing) { + return Response.json({ error: "Username already taken" }, { status: 400 }); + } + const user = await createUser(username, password); + const userAgent = req.headers.get("user-agent") ?? undefined; + const ipAddress = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() + ?? req.headers.get("x-real-ip") + ?? server.requestIP(req)?.address + ?? undefined; + const token = createSession(user.id, userAgent, ipAddress); + console.log(`[AUTH] Signup: user="${username}" id=${user.id} admin=${user.is_admin} session=${token} ip=${ipAddress} ua="${userAgent?.slice(0, 50)}..."`); + return Response.json( + { user: { id: user.id, username: user.username, isAdmin: user.is_admin } }, + { headers: { "Set-Cookie": setSessionCookie(token) } } + ); + } catch (e) { + return Response.json({ error: "Signup failed" }, { status: 500 }); + } + } + + // Auth: login + if (path === "/api/auth/login" && req.method === "POST") { + try { + const { username, password } = await req.json(); + const user = findUserByUsername(username); + if (!user || !(await validatePassword(user, password))) { + console.log(`[AUTH] Login failed: user="${username}"`); + return Response.json({ error: "Invalid username or password" }, { status: 401 }); + } + const userAgent = req.headers.get("user-agent") ?? undefined; + const ipAddress = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() + ?? req.headers.get("x-real-ip") + ?? server.requestIP(req)?.address + ?? undefined; + const token = createSession(user.id, userAgent, ipAddress); + console.log(`[AUTH] Login: user="${username}" id=${user.id} session=${token} ip=${ipAddress} ua="${userAgent?.slice(0, 50)}..."`); + return Response.json( + { user: { id: user.id, username: user.username, isAdmin: user.is_admin } }, + { headers: { "Set-Cookie": setSessionCookie(token) } } + ); + } catch (e) { + return Response.json({ error: "Login failed" }, { status: 500 }); + } + } + + // Auth: logout + if (path === "/api/auth/logout" && req.method === "POST") { + const token = req.headers.get("cookie")?.match(/musicroom_session=([^;]+)/)?.[1]; + if (token) { + const user = validateSession(token); + console.log(`[AUTH] Logout: user="${user?.username ?? "unknown"}" session=${token}`); + deleteSession(token); + } + return Response.json( + { success: true }, + { headers: { "Set-Cookie": clearSessionCookie() } } + ); + } + + // Auth: get current user + if (path === "/api/auth/me") { + const user = getUser(req, server); + if (!user) { + return Response.json({ user: null }); + } + const permissions = getUserPermissions(user.id); + return Response.json({ + user: { id: user.id, username: user.username, isAdmin: user.is_admin }, + permissions, + }); + } + + // Admin: list users + if (path === "/api/admin/users" && req.method === "GET") { + try { + requirePermission(req, "global", null, "admin", server); + return Response.json(getAllUsers()); + } catch (e) { + if (e instanceof Response) return e; + return Response.json({ error: "Failed" }, { status: 500 }); + } + } + + // Admin: grant permission + if (path.match(/^\/api\/admin\/users\/(\d+)\/permissions$/) && req.method === "POST") { + try { + requirePermission(req, "global", null, "admin", server); + const userId = parseInt(path.split("/")[4]); + const { resourceType, resourceId, permission } = await req.json(); + grantPermission(userId, resourceType, resourceId, permission); + return Response.json({ success: true }); + } catch (e) { + if (e instanceof Response) return e; + return Response.json({ error: "Failed" }, { status: 500 }); + } + } + + // Admin: revoke permission + if (path.match(/^\/api\/admin\/users\/(\d+)\/permissions$/) && req.method === "DELETE") { + try { + requirePermission(req, "global", null, "admin", server); + const userId = parseInt(path.split("/")[4]); + const { resourceType, resourceId, permission } = await req.json(); + revokePermission(userId, resourceType, resourceId, permission); + return Response.json({ success: true }); + } catch (e) { + if (e instanceof Response) return e; + return Response.json({ error: "Failed" }, { status: 500 }); + } + } + + // API: jump to track in playlist + const jumpMatch = path.match(/^\/api\/streams\/([^/]+)\/jump$/); + if (jumpMatch && req.method === "POST") { + const streamId = jumpMatch[1]; + try { + requirePermission(req, "stream", streamId, "control", server); + } catch (e) { + if (e instanceof Response) return e; + } + const stream = streams.get(streamId); + if (!stream) return new Response("Not found", { status: 404 }); + try { + const body = await req.json(); + if (typeof body.index === "number") { + stream.jumpTo(body.index); + return Response.json({ success: true }); + } + return new Response("Invalid index", { status: 400 }); + } catch { + return new Response("Invalid JSON", { status: 400 }); + } + } + + // API: seek in stream + const seekMatch = path.match(/^\/api\/streams\/([^/]+)\/seek$/); + if (seekMatch && req.method === "POST") { + const streamId = seekMatch[1]; + try { + requirePermission(req, "stream", streamId, "control", server); + } catch (e) { + if (e instanceof Response) return e; + } + const stream = streams.get(streamId); + if (!stream) return new Response("Not found", { status: 404 }); + try { + const body = await req.json(); + if (typeof body.timestamp === "number") { + stream.seek(body.timestamp); + return Response.json({ success: true }); + } + return new Response("Invalid timestamp", { status: 400 }); + } catch { + return new Response("Invalid JSON", { status: 400 }); + } + } + // API: stream state const streamMatch = path.match(/^\/api\/streams\/([^/]+)$/); if (streamMatch) { @@ -107,9 +301,13 @@ serve({ return Response.json(stream.getState()); } - // API: serve audio file + // API: serve audio file (requires auth) const trackMatch = path.match(/^\/api\/tracks\/(.+)$/); if (trackMatch) { + const user = getUser(req, server); + if (!user) { + return Response.json({ error: "Authentication required" }, { status: 401 }); + } const filename = decodeURIComponent(trackMatch[1]); if (filename.includes("..")) return new Response("Forbidden", { status: 403 }); const filepath = join(MUSIC_DIR, filename); @@ -152,6 +350,21 @@ serve({ headers: { "Content-Type": "text/html" }, }); } + if (path === "/styles.css") { + return new Response(file(join(PUBLIC_DIR, "styles.css")), { + headers: { "Content-Type": "text/css" }, + }); + } + if (path === "/trackStorage.js") { + return new Response(file(join(PUBLIC_DIR, "trackStorage.js")), { + headers: { "Content-Type": "application/javascript" }, + }); + } + if (path === "/app.js") { + return new Response(file(join(PUBLIC_DIR, "app.js")), { + headers: { "Content-Type": "application/javascript" }, + }); + } return new Response("Not found", { status: 404 }); }, @@ -168,6 +381,17 @@ serve({ message(ws: ServerWebSocket, message: string | Buffer) { const stream = streams.get(ws.data.streamId); if (!stream) return; + + // Check permission for control actions + const userId = ws.data.userId; + if (!userId) return; // Not logged in + + const user = findUserById(userId); + if (!user) return; + + const canControl = user.is_admin || hasPermission(userId, "stream", ws.data.streamId, "control"); + if (!canControl) return; + try { const data = JSON.parse(String(message)); if (data.action === "pause") stream.pause(); diff --git a/stream.ts b/stream.ts index 13f8315..ef603cd 100644 --- a/stream.ts +++ b/stream.ts @@ -21,6 +21,8 @@ export class Stream { clients: Set> = new Set(); paused: boolean = false; pausedAt: number = 0; + private lastPlaylistBroadcast: number = 0; + private playlistDirty: boolean = false; constructor(config: StreamConfig) { this.id = config.id; @@ -56,13 +58,18 @@ export class Stream { this.broadcast(); } - getState() { - return { + getState(includePlaylist: boolean = false) { + const state: Record = { track: this.currentTrack, currentTimestamp: this.currentTimestamp, streamName: this.name, paused: this.paused, + currentIndex: this.currentIndex, }; + if (includePlaylist) { + state.playlist = this.playlist; + } + return state; } pause() { @@ -79,8 +86,42 @@ export class Stream { this.broadcast(); } + jumpTo(index: number) { + if (index < 0 || index >= this.playlist.length) return; + this.currentIndex = index; + if (this.paused) { + this.pausedAt = 0; + } else { + this.startedAt = Date.now(); + } + 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.broadcast(); + } + + markPlaylistDirty() { + this.playlistDirty = true; + } + broadcast() { - const msg = JSON.stringify(this.getState()); + const now = Date.now(); + const includePlaylist = this.playlistDirty || (now - this.lastPlaylistBroadcast >= 60000); + if (includePlaylist) { + this.lastPlaylistBroadcast = now; + this.playlistDirty = false; + } + const msg = JSON.stringify(this.getState(includePlaylist)); + for (const ws of this.clients) { ws.send(msg); } @@ -88,7 +129,11 @@ export class Stream { addClient(ws: ServerWebSocket<{ streamId: string }>) { this.clients.add(ws); - ws.send(JSON.stringify(this.getState())); + + // Always send full state with playlist on connect + ws.send(JSON.stringify(this.getState(true))); + // Reset timer so next playlist broadcast is in 60s + this.lastPlaylistBroadcast = Date.now(); } removeClient(ws: ServerWebSocket<{ streamId: string }>) {