local cache. lots of profiling

This commit is contained in:
peterino2 2026-02-02 18:50:18 -08:00
parent 46391fd060
commit 6c0a96d1ba
11 changed files with 1534 additions and 190 deletions

View File

@ -1,4 +1,4 @@
# musicroom # NeoRose
To install dependencies: To install dependencies:

58
auth.ts Normal file
View File

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

217
db.ts Normal file
View File

@ -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<User> {
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<boolean> {
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<User, 'password_hash'>[] {
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<Session, 'token'>[] {
return db.query(
"SELECT user_id, expires_at, created_at, user_agent, ip_address FROM sessions WHERE user_id = ? AND expires_at > ?"
).all(userId, Math.floor(Date.now() / 1000)) as Omit<Session, 'token'>[];
}
// Cleanup expired sessions periodically
setInterval(() => deleteExpiredSessions(), 60 * 60 * 1000); // Every hour

BIN
musicroom.db Normal file

Binary file not shown.

693
public/app.js Normal file
View File

@ -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 = `<span>${title}</span><span class="duration">${fmt(track.duration)}</span>`;
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();
});
});
})();

View File

@ -3,191 +3,65 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MusicRoom</title> <title>NeoRose</title>
<style> <link rel="stylesheet" href="styles.css">
* { 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; }
#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: 1rem; }
#track-name { font-size: 1.4rem; font-weight: 600; margin-bottom: 0.3rem; }
#stream-name { font-size: 0.85rem; color: #888; }
#progress-container { background: #222; border-radius: 4px; height: 6px; margin: 1rem 0; cursor: pointer; }
#progress-bar { background: #555; height: 100%; border-radius: 4px; width: 0%; transition: width 0.3s linear; }
#progress-bar.playing { background: #4e8; }
#time { display: flex; justify-content: space-between; font-size: 0.8rem; color: #888; margin-bottom: 1rem; }
#controls { display: flex; gap: 1rem; align-items: center; }
#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; }
</style>
</head> </head>
<body> <body>
<div id="app"> <div id="app">
<h1>MusicRoom</h1> <h1>MusicRoom <span id="sync-indicator"></span></h1>
<div id="login-panel">
<h2>Sign in to continue</h2>
<div class="tabs">
<button id="tab-login" class="active">Login</button>
<button id="tab-signup">Sign Up</button>
</div>
<div id="login-fields" class="form-group">
<input type="text" id="login-username" placeholder="Username">
<input type="password" id="login-password" placeholder="Password">
<button class="submit-btn" id="btn-login">Login</button>
<div id="auth-error"></div>
</div>
<div id="signup-fields" class="form-group hidden">
<input type="text" id="signup-username" placeholder="Username (min 3 chars)">
<input type="password" id="signup-password" placeholder="Password (min 6 chars)">
<button class="submit-btn" id="btn-signup">Sign Up</button>
<div id="signup-error"></div>
</div>
</div>
<div id="player-content">
<div id="auth-section">
<div class="user-info">
<span class="username" id="current-username"></span>
<span class="admin-badge" id="admin-badge" style="display:none;">Admin</span>
</div>
<button id="btn-logout">Logout</button>
</div>
<div id="stream-select"></div> <div id="stream-select"></div>
<div id="now-playing"> <div id="now-playing">
<div id="track-name" class="empty">Loading...</div>
<div id="stream-name"></div> <div id="stream-name"></div>
<div id="track-name" class="empty">
<span id="track-title">Loading...</span>
<span id="btn-sync" title="Toggle sync">sync</span>
</div>
</div> </div>
<div id="progress-container"><div id="progress-bar"></div></div>
<div id="time"><span id="time-current">0:00</span><span id="time-total">0:00</span></div> <div id="time"><span id="time-current">0:00</span><span id="time-total">0:00</span></div>
<div id="progress-row">
<span id="status-icon"></span>
<div id="progress-container"><div id="progress-bar"></div><div id="seek-tooltip"></div></div>
</div>
<div id="buffer-bar"></div>
<div id="download-speed"></div>
<div id="controls"> <div id="controls">
<button id="btn-listen">Unlisten</button> <span id="btn-mute" title="Toggle mute">🔊</span>
<button id="btn-play">Play</button>
<button id="btn-pause" disabled>Pause</button>
<button id="btn-stop">Stop</button>
<button id="btn-server-pause">Server Pause</button>
<input type="range" id="volume" min="0" max="1" step="0.01" value="1"> <input type="range" id="volume" min="0" max="1" step="0.01" value="1">
</div> </div>
<div id="status"></div> <div id="status"></div>
<div id="playlist"></div>
</div>
</div> </div>
<script> <script src="trackStorage.js"></script>
(function() { <script src="app.js"></script>
const audio = new Audio();
let ws = null;
let currentStreamId = null;
let currentFilename = null;
let serverTimestamp = 0;
let serverTrackDuration = 0;
let lastServerUpdate = 0;
let serverPaused = false;
let listening = true;
const $ = (s) => document.querySelector(s);
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 getServerTime() {
if (serverPaused) return serverTimestamp;
return serverTimestamp + (Date.now() - lastServerUpdate) / 1000;
}
// Update progress bar from server time (always), use audio time when playing
setInterval(() => {
if (!listening || serverTrackDuration <= 0) return;
const t = audio.paused ? getServerTime() : audio.currentTime;
const dur = audio.paused ? serverTrackDuration : (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);
}, 250);
// Load streams
async function loadStreams() {
const res = await fetch("/api/streams");
const streams = await res.json();
if (streams.length === 0) {
$("#track-name").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);
}
function connectStream(id) {
if (ws) ws.close();
currentStreamId = id;
listening = true;
$("#btn-listen").textContent = "Unlisten";
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 = () => { $("#status").textContent = "Disconnected. Reconnecting..."; setTimeout(() => { if (listening) connectStream(id); }, 3000); };
ws.onopen = () => { $("#status").textContent = "Connected"; };
}
function handleUpdate(data) {
if (!data.track) {
$("#track-name").textContent = "No tracks";
return;
}
$("#stream-name").textContent = data.streamName || "";
serverTimestamp = data.currentTimestamp;
serverTrackDuration = data.track.duration;
lastServerUpdate = Date.now();
serverPaused = data.paused || false;
if (data.track.filename !== currentFilename) {
// New track
currentFilename = data.track.filename;
$("#track-name").textContent = data.track.title;
if (!audio.paused) {
audio.src = "/api/tracks/" + encodeURIComponent(data.track.filename);
audio.currentTime = data.currentTimestamp;
audio.play().catch(() => {});
}
} else if (!audio.paused) {
// Same track - check drift
const drift = Math.abs(audio.currentTime - data.currentTimestamp);
if (drift >= 2) {
audio.currentTime = data.currentTimestamp;
}
}
}
$("#btn-listen").onclick = () => {
if (listening) {
listening = false;
if (ws) ws.close();
$("#btn-listen").textContent = "Listen";
$("#status").textContent = "Not listening";
} else {
connectStream(currentStreamId);
}
};
$("#btn-play").onclick = () => {
if (!currentFilename) return;
if (!audio.src) {
audio.src = "/api/tracks/" + encodeURIComponent(currentFilename);
}
audio.currentTime = getServerTime();
audio.play();
};
$("#btn-pause").onclick = () => {
audio.pause();
};
$("#btn-stop").onclick = () => {
audio.pause();
audio.src = "";
};
$("#btn-server-pause").onclick = () => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ action: serverPaused ? "unpause" : "pause" }));
}
};
$("#volume").oninput = (e) => { audio.volume = e.target.value; };
audio.onplay = () => { $("#btn-play").disabled = true; $("#btn-pause").disabled = false; $("#progress-bar").classList.add("playing"); };
audio.onpause = () => { $("#btn-play").disabled = false; $("#btn-pause").disabled = true; $("#progress-bar").classList.remove("playing"); };
loadStreams();
})();
</script>
</body> </body>
</html> </html>

66
public/styles.css Normal file
View File

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

167
public/trackStorage.js Normal file
View File

@ -0,0 +1,167 @@
// Track Storage Abstraction Layer
// Provides a unified interface for storing/retrieving audio blobs
// Default implementation uses IndexedDB, can be swapped for Electron file API
const TrackStorage = (function() {
const DB_NAME = '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
};
})();

234
server.ts
View File

@ -3,6 +3,27 @@ import { parseFile } from "music-metadata";
import { Stream, type Track } from "./stream"; import { Stream, type Track } from "./stream";
import { readdir, stat } from "fs/promises"; import { readdir, stat } from "fs/promises";
import { join } from "path"; 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 MUSIC_DIR = join(import.meta.dir, "music");
const PLAYLIST_PATH = join(import.meta.dir, "playlist.json"); const PLAYLIST_PATH = join(import.meta.dir, "playlist.json");
@ -14,7 +35,7 @@ async function loadTrack(filename: string): Promise<Track> {
try { try {
const metadata = await parseFile(filepath, { duration: true }); const metadata = await parseFile(filepath, { duration: true });
const duration = metadata.format.duration ?? 0; 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}`); console.log(`Track: ${filename} | duration: ${duration}s | title: ${title}`);
return { filename, title, duration }; return { filename, title, duration };
} catch (e) { } catch (e) {
@ -72,7 +93,7 @@ setInterval(() => {
} }
}, 1000); }, 1000);
type WsData = { streamId: string }; type WsData = { streamId: string; userId: number | null };
serve({ serve({
port: parseInt("3001"), port: parseInt("3001"),
@ -84,13 +105,18 @@ serve({
if (path.match(/^\/api\/streams\/([^/]+)\/ws$/)) { if (path.match(/^\/api\/streams\/([^/]+)\/ws$/)) {
const id = path.split("/")[3]; const id = path.split("/")[3];
if (!streams.has(id)) return new Response("Stream not found", { status: 404 }); 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; if (ok) return undefined;
return new Response("WebSocket upgrade failed", { status: 500 }); return new Response("WebSocket upgrade failed", { status: 500 });
} }
// API: list streams // API: list streams (requires auth)
if (path === "/api/streams") { 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) => ({ const list = [...streams.values()].map((s) => ({
id: s.id, id: s.id,
name: s.name, name: s.name,
@ -99,6 +125,174 @@ serve({
return Response.json(list); 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 // API: stream state
const streamMatch = path.match(/^\/api\/streams\/([^/]+)$/); const streamMatch = path.match(/^\/api\/streams\/([^/]+)$/);
if (streamMatch) { if (streamMatch) {
@ -107,9 +301,13 @@ serve({
return Response.json(stream.getState()); return Response.json(stream.getState());
} }
// API: serve audio file // API: serve audio file (requires auth)
const trackMatch = path.match(/^\/api\/tracks\/(.+)$/); const trackMatch = path.match(/^\/api\/tracks\/(.+)$/);
if (trackMatch) { if (trackMatch) {
const user = getUser(req, server);
if (!user) {
return Response.json({ error: "Authentication required" }, { status: 401 });
}
const filename = decodeURIComponent(trackMatch[1]); const filename = decodeURIComponent(trackMatch[1]);
if (filename.includes("..")) return new Response("Forbidden", { status: 403 }); if (filename.includes("..")) return new Response("Forbidden", { status: 403 });
const filepath = join(MUSIC_DIR, filename); const filepath = join(MUSIC_DIR, filename);
@ -152,6 +350,21 @@ serve({
headers: { "Content-Type": "text/html" }, 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 }); return new Response("Not found", { status: 404 });
}, },
@ -168,6 +381,17 @@ 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) 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 { try {
const data = JSON.parse(String(message)); const data = JSON.parse(String(message));
if (data.action === "pause") stream.pause(); if (data.action === "pause") stream.pause();

View File

@ -21,6 +21,8 @@ export class Stream {
clients: Set<ServerWebSocket<{ streamId: string }>> = new Set(); clients: Set<ServerWebSocket<{ streamId: string }>> = new Set();
paused: boolean = false; paused: boolean = false;
pausedAt: number = 0; pausedAt: number = 0;
private lastPlaylistBroadcast: number = 0;
private playlistDirty: boolean = false;
constructor(config: StreamConfig) { constructor(config: StreamConfig) {
this.id = config.id; this.id = config.id;
@ -56,13 +58,18 @@ export class Stream {
this.broadcast(); this.broadcast();
} }
getState() { getState(includePlaylist: boolean = false) {
return { const state: Record<string, unknown> = {
track: this.currentTrack, track: this.currentTrack,
currentTimestamp: this.currentTimestamp, currentTimestamp: this.currentTimestamp,
streamName: this.name, streamName: this.name,
paused: this.paused, paused: this.paused,
currentIndex: this.currentIndex,
}; };
if (includePlaylist) {
state.playlist = this.playlist;
}
return state;
} }
pause() { pause() {
@ -79,8 +86,42 @@ export class Stream {
this.broadcast(); 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() { 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) { for (const ws of this.clients) {
ws.send(msg); ws.send(msg);
} }
@ -88,7 +129,11 @@ export class Stream {
addClient(ws: ServerWebSocket<{ streamId: string }>) { addClient(ws: ServerWebSocket<{ streamId: string }>) {
this.clients.add(ws); 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 }>) { removeClient(ws: ServerWebSocket<{ streamId: string }>) {