local cache. lots of profiling
This commit is contained in:
parent
46391fd060
commit
6c0a96d1ba
|
|
@ -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`;
|
||||
}
|
||||
|
|
@ -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
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
|
@ -3,191 +3,65 @@
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MusicRoom</title>
|
||||
<style>
|
||||
* { 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>
|
||||
<title>NeoRose</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<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="now-playing">
|
||||
<div id="track-name" class="empty">Loading...</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 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="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">
|
||||
<button id="btn-listen">Unlisten</button>
|
||||
<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>
|
||||
<span id="btn-mute" title="Toggle mute">🔊</span>
|
||||
<input type="range" id="volume" min="0" max="1" step="0.01" value="1">
|
||||
</div>
|
||||
<div id="status"></div>
|
||||
<div id="playlist"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
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>
|
||||
<script src="trackStorage.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
@ -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
234
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<Track> {
|
|||
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<WsData>, 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();
|
||||
|
|
|
|||
53
stream.ts
53
stream.ts
|
|
@ -21,6 +21,8 @@ export class Stream {
|
|||
clients: Set<ServerWebSocket<{ streamId: string }>> = 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<string, unknown> = {
|
||||
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 }>) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue