diff --git a/AGENTS.md b/AGENTS.md index ede7a4e..429fe55 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # MusicRoom -Synchronized music streaming server built with Bun. Manages "channels" (virtual radio stations) that play through playlists sequentially. Clients connect, receive now-playing state, download audio, and sync playback locally. +Synchronized music streaming server built with Bun. Manages "channels" (virtual radio stations) that play through queues sequentially. Clients connect, receive now-playing state, download audio, and sync playback locally. ## Architecture @@ -20,7 +20,7 @@ interface Channel { id: string; name: string; description: string; - playlist: Track[]; + queue: Track[]; // tracks in playback order currentIndex: number; startedAt: number; paused: boolean; @@ -36,14 +36,14 @@ interface Channel { ### Client -The player's role is simple: **play an arbitrary track by ID**. It does not manage playlists or sync logic directly. +The player's role is simple: **play an arbitrary track by ID**. It does not manage queues or sync logic directly. - Receives track ID and timestamp from server via WebSocket - Downloads audio from `/api/tracks/:id` - Syncs playback position to server timestamp - Caches tracks locally in IndexedDB -### Library & Playlist Views +### Library & Queue Views Both views display tracks with real-time status indicators: - **Green bar**: Track is fully cached locally (in IndexedDB) @@ -105,17 +105,15 @@ DELETE /api/channels/:id → Delete a channel (not default) WS /api/channels/:id/ws → WebSocket: pushes state on connect and changes GET /api/tracks/:id → Serve audio by content hash (supports Range) GET /api/library → List all tracks with id, filename, title, duration -GET /api/playlists → List user playlists -POST /api/playlists → Create playlist ``` ## Files ### Server - **server.ts** — Bun entrypoint. HTTP routes and WebSocket handlers. -- **channel.ts** — `Channel` class. Playlist, current index, time tracking, broadcasting. +- **channel.ts** — `Channel` class. Queue, current index, time tracking, broadcasting. - **library.ts** — `Library` class. Scans music directory, computes content hashes. -- **db.ts** — SQLite database for users, sessions, playlists, tracks. +- **db.ts** — SQLite database for users, sessions, tracks. ### Client (public/) - **core.js** — Global state namespace (`window.MusicRoom`) @@ -123,7 +121,7 @@ POST /api/playlists → Create playlist - **audioCache.js** — Track caching, segment downloads, prefetching - **channelSync.js** — WebSocket connection, server sync, channel switching - **ui.js** — Progress bar, buffer display, UI updates -- **playlist.js** — Playlist/library rendering, cache status +- **queue.js** — Queue/library rendering, cache status - **controls.js** — Play, pause, seek, volume - **auth.js** — Login, signup, logout - **init.js** — App initialization @@ -151,7 +149,7 @@ interface Track { channelId: string, description: string, paused: boolean, - playlist: Track[], + queue: Track[], // alias: playlist (for compatibility) currentIndex: number, listenerCount: number, listeners: string[], // usernames of connected users diff --git a/db.ts b/db.ts index c680d41..1380c2c 100644 --- a/db.ts +++ b/db.ts @@ -68,34 +68,6 @@ db.run(` ) `); -// User playlists -db.run(` - CREATE TABLE IF NOT EXISTS playlists ( - id TEXT PRIMARY KEY, - owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - name TEXT NOT NULL, - description TEXT, - visibility TEXT DEFAULT 'private' CHECK (visibility IN ('private', 'public', 'registered')), - created_at INTEGER DEFAULT (unixepoch()), - updated_at INTEGER DEFAULT (unixepoch()) - ) -`); - -db.run(` - CREATE TABLE IF NOT EXISTS playlist_tracks ( - playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, - track_id TEXT NOT NULL REFERENCES tracks(id), - position INTEGER NOT NULL, - added_at INTEGER DEFAULT (unixepoch()), - added_by INTEGER REFERENCES users(id), - PRIMARY KEY (playlist_id, position) - ) -`); - -db.run(`CREATE INDEX IF NOT EXISTS idx_playlist_tracks_track ON playlist_tracks(track_id)`); -db.run(`CREATE INDEX IF NOT EXISTS idx_playlists_owner ON playlists(owner_id)`); -db.run(`CREATE INDEX IF NOT EXISTS idx_playlists_visibility ON playlists(visibility)`); - // Types export interface User { id: number; @@ -133,24 +105,6 @@ export interface Track { created_at: number; } -export interface Playlist { - id: string; - owner_id: number; - name: string; - description: string | null; - visibility: "private" | "public" | "registered"; - created_at: number; - updated_at: number; -} - -export interface PlaylistTrack { - playlist_id: string; - track_id: string; - position: number; - added_at: number; - added_by: number | null; -} - // User functions export async function createUser(username: string, password: string): Promise { const password_hash = await Bun.password.hash(password); @@ -339,133 +293,3 @@ export function getTrack(id: string): Track | null { export function getAllTracks(): Track[] { return db.query("SELECT * FROM tracks ORDER BY title").all() as Track[]; } - -// Playlist functions -export function createPlaylist( - ownerId: number, - name: string, - visibility: Playlist["visibility"] = "private", - description?: string -): Playlist { - const id = crypto.randomUUID(); - db.query(` - INSERT INTO playlists (id, owner_id, name, description, visibility) - VALUES (?, ?, ?, ?, ?) - `).run(id, ownerId, name, description ?? null, visibility); - return getPlaylist(id)!; -} - -export function getPlaylist(id: string): Playlist | null { - return db.query("SELECT * FROM playlists WHERE id = ?").get(id) as Playlist | null; -} - -export function updatePlaylist( - id: string, - updates: Partial> -): void { - const fields: string[] = []; - const values: any[] = []; - - if (updates.name !== undefined) { fields.push("name = ?"); values.push(updates.name); } - if (updates.description !== undefined) { fields.push("description = ?"); values.push(updates.description); } - if (updates.visibility !== undefined) { fields.push("visibility = ?"); values.push(updates.visibility); } - - if (fields.length === 0) return; - - fields.push("updated_at = unixepoch()"); - values.push(id); - - db.query(`UPDATE playlists SET ${fields.join(", ")} WHERE id = ?`).run(...values); -} - -export function deletePlaylist(id: string): void { - db.query("DELETE FROM playlists WHERE id = ?").run(id); -} - -export function getUserPlaylists(userId: number): Playlist[] { - return db.query("SELECT * FROM playlists WHERE owner_id = ? ORDER BY updated_at DESC").all(userId) as Playlist[]; -} - -export function getVisiblePlaylists(userId: number | null, isGuest: boolean): Playlist[] { - if (userId === null || isGuest) { - // Guests/unauthenticated: public only - return db.query("SELECT * FROM playlists WHERE visibility = 'public' ORDER BY updated_at DESC").all() as Playlist[]; - } - // Logged in: own + public + registered - return db.query(` - SELECT * FROM playlists - WHERE owner_id = ? OR visibility IN ('public', 'registered') - ORDER BY updated_at DESC - `).all(userId) as Playlist[]; -} - -export function canViewPlaylist(playlist: Playlist, userId: number | null, isGuest: boolean): boolean { - if (playlist.visibility === "public") return true; - if (userId === null) return false; - if (playlist.owner_id === userId) return true; - if (playlist.visibility === "registered" && !isGuest) return true; - return false; -} - -export function canEditPlaylist(playlist: Playlist, userId: number | null): boolean { - return userId !== null && playlist.owner_id === userId; -} - -// Playlist track functions -export function getPlaylistTracks(playlistId: string): (Track & { position: number })[] { - return db.query(` - SELECT t.*, pt.position - FROM playlist_tracks pt - JOIN tracks t ON t.id = pt.track_id - WHERE pt.playlist_id = ? - ORDER BY pt.position - `).all(playlistId) as (Track & { position: number })[]; -} - -export function addTrackToPlaylist(playlistId: string, trackId: string, addedBy: number | null, position?: number): void { - // If no position, add at end - if (position === undefined) { - const max = db.query("SELECT MAX(position) as max FROM playlist_tracks WHERE playlist_id = ?").get(playlistId) as { max: number | null }; - position = (max?.max ?? -1) + 1; - } else { - // Shift existing tracks - db.query("UPDATE playlist_tracks SET position = position + 1 WHERE playlist_id = ? AND position >= ?").run(playlistId, position); - } - - db.query(` - INSERT INTO playlist_tracks (playlist_id, track_id, position, added_by) - VALUES (?, ?, ?, ?) - `).run(playlistId, trackId, position, addedBy); - - db.query("UPDATE playlists SET updated_at = unixepoch() WHERE id = ?").run(playlistId); -} - -export function removeTrackFromPlaylist(playlistId: string, position: number): void { - db.query("DELETE FROM playlist_tracks WHERE playlist_id = ? AND position = ?").run(playlistId, position); - // Shift remaining tracks down - db.query("UPDATE playlist_tracks SET position = position - 1 WHERE playlist_id = ? AND position > ?").run(playlistId, position); - db.query("UPDATE playlists SET updated_at = unixepoch() WHERE id = ?").run(playlistId); -} - -export function reorderPlaylistTrack(playlistId: string, fromPos: number, toPos: number): void { - if (fromPos === toPos) return; - - // Get the track being moved - const track = db.query("SELECT track_id FROM playlist_tracks WHERE playlist_id = ? AND position = ?").get(playlistId, fromPos) as { track_id: string } | null; - if (!track) return; - - // Remove from old position - db.query("DELETE FROM playlist_tracks WHERE playlist_id = ? AND position = ?").run(playlistId, fromPos); - - if (fromPos < toPos) { - // Moving down: shift tracks between fromPos+1 and toPos up - db.query("UPDATE playlist_tracks SET position = position - 1 WHERE playlist_id = ? AND position > ? AND position <= ?").run(playlistId, fromPos, toPos); - } else { - // Moving up: shift tracks between toPos and fromPos-1 down - db.query("UPDATE playlist_tracks SET position = position + 1 WHERE playlist_id = ? AND position >= ? AND position < ?").run(playlistId, toPos, fromPos); - } - - // Insert at new position - db.query("INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES (?, ?, ?)").run(playlistId, track.track_id, toPos); - db.query("UPDATE playlists SET updated_at = unixepoch() WHERE id = ?").run(playlistId); -} diff --git a/musicroom.db b/musicroom.db index a6982e8..c32710c 100644 Binary files a/musicroom.db and b/musicroom.db differ diff --git a/public/auth.js b/public/auth.js index b16171f..32aa513 100644 --- a/public/auth.js +++ b/public/auth.js @@ -116,13 +116,4 @@ M.updateAuthUI(); } }; - - // New playlist button - M.$("#btn-new-playlist").onclick = () => { - if (!M.currentUser || M.currentUser.isGuest) { - alert("Sign in to create playlists"); - return; - } - M.createNewPlaylist(); - }; })(); diff --git a/public/channelSync.js b/public/channelSync.js index 9a664bf..68e3a3c 100644 --- a/public/channelSync.js +++ b/public/channelSync.js @@ -183,10 +183,6 @@ if (data.library) { M.library = data.library; M.renderLibrary(); - if (M.selectedPlaylistId === "all") { - M.playlist = [...M.library]; - M.renderPlaylist(); - } } return; } @@ -195,10 +191,6 @@ if (data.library) { M.library = data.library; M.renderLibrary(); - if (M.selectedPlaylistId === "all") { - M.playlist = [...M.library]; - M.renderPlaylist(); - } } return; } diff --git a/public/core.js b/public/core.js index 83c0181..ebf9585 100644 --- a/public/core.js +++ b/public/core.js @@ -35,10 +35,8 @@ window.MusicRoom = { currentUser: null, serverStatus: null, - // Library and playlists + // Library (all discovered tracks) library: [], - userPlaylists: [], - selectedPlaylistId: null, // Caching state prefetchController: null, diff --git a/public/index.html b/public/index.html index e2484a8..d8cc7de 100644 --- a/public/index.html +++ b/public/index.html @@ -58,16 +58,9 @@

Library

-
-

Playlist

-
-
-
-
-

Playlists

- -
-
+
+

Queue

+
@@ -105,7 +98,7 @@ - + diff --git a/public/init.js b/public/init.js index dac1754..b58d400 100644 --- a/public/init.js +++ b/public/init.js @@ -26,11 +26,9 @@ // Initialize the application Promise.all([initStorage(), loadServerStatus()]).then(async () => { await M.loadLibrary(); - M.loadSelectedPlaylist("all"); // Default to All Tracks await M.loadCurrentUser(); if (M.currentUser) { M.loadChannels(); - M.loadPlaylists(); } }); })(); diff --git a/public/playlist.js b/public/playlist.js deleted file mode 100644 index eb961b7..0000000 --- a/public/playlist.js +++ /dev/null @@ -1,426 +0,0 @@ -// MusicRoom - Playlist module -// Playlist CRUD, library rendering, playlist rendering - -(function() { - const M = window.MusicRoom; - - // Update cache status for all tracks - M.updateCacheStatus = async function() { - const cached = await TrackStorage.list(); - - // Migration: remove old filename-based cache entries (keep only sha256: prefixed) - const oldEntries = cached.filter(id => !id.startsWith("sha256:")); - if (oldEntries.length > 0) { - console.log("[Cache] Migrating: removing", oldEntries.length, "old filename-based entries"); - for (const oldId of oldEntries) { - await TrackStorage.remove(oldId); - } - // Re-fetch after cleanup - const updated = await TrackStorage.list(); - M.cachedTracks = new Set(updated); - } else { - M.cachedTracks = new Set(cached); - } - console.log("[Cache] Updated cache status:", M.cachedTracks.size, "tracks cached"); - }; - - // Debug: log cache status for current track - M.debugCacheStatus = function() { - if (!M.currentTrackId) { - console.log("[Cache Debug] No current track"); - return; - } - const trackCache = M.getTrackCache(M.currentTrackId); - const segmentsPct = Math.round((trackCache.size / M.SEGMENTS) * 100); - const inCachedTracks = M.cachedTracks.has(M.currentTrackId); - const hasBlobUrl = M.trackBlobs.has(M.currentTrackId); - const bulkStarted = M.bulkDownloadStarted.get(M.currentTrackId); - - console.log("[Cache Debug]", { - trackId: M.currentTrackId.slice(0, 16) + "...", - segments: `${trackCache.size}/${M.SEGMENTS} (${segmentsPct}%)`, - inCachedTracks, - hasBlobUrl, - bulkStarted, - loadingSegments: [...M.loadingSegments], - cachedTracksSize: M.cachedTracks.size - }); - }; - - // Debug: compare playlist track IDs with cached track IDs - M.debugCacheMismatch = function() { - console.log("[Cache Mismatch Debug]"); - console.log("=== Raw State ==="); - console.log("M.cachedTracks:", M.cachedTracks); - console.log("M.trackCaches:", M.trackCaches); - console.log("M.trackBlobs keys:", [...M.trackBlobs.keys()]); - console.log("M.bulkDownloadStarted:", M.bulkDownloadStarted); - console.log("=== Playlist Tracks ==="); - M.playlist.forEach((t, i) => { - const id = t.id || t.filename; - const segmentCache = M.trackCaches.get(id); - console.log(`[${i}] ${t.title?.slice(0, 25)}`, { - id: id, - segmentCache: segmentCache ? [...segmentCache] : null, - inCachedTracks: M.cachedTracks.has(id), - hasBlobUrl: M.trackBlobs.has(id), - bulkStarted: M.bulkDownloadStarted.get(id) - }); - }); - }; - - // Debug: check specific track by index - M.debugTrack = function(index) { - const track = M.playlist[index]; - if (!track) { - console.log("No track at index", index); - return; - } - const id = track.id || track.filename; - const segmentCache = M.trackCaches.get(id); - console.log("[Track Debug]", { - index, - title: track.title, - id, - segmentCache: segmentCache ? [...segmentCache] : null, - inCachedTracks: M.cachedTracks.has(id), - hasBlobUrl: M.trackBlobs.has(id), - bulkStarted: M.bulkDownloadStarted.get(id), - currentTrackId: M.currentTrackId - }); - }; - - // Clear all caches and start fresh - M.clearAllCaches = async function() { - console.log("[Cache] Clearing all caches..."); - await TrackStorage.clear(); - M.cachedTracks.clear(); - M.trackCaches.clear(); - M.trackBlobs.clear(); - M.bulkDownloadStarted.clear(); - M.renderPlaylist(); - M.renderLibrary(); - console.log("[Cache] All caches cleared. Refresh the page."); - }; - - // Render the current playlist - M.renderPlaylist = function() { - const container = M.$("#playlist"); - container.innerHTML = ""; - if (M.playlist.length === 0) { - container.innerHTML = '
Playlist empty
'; - return; - } - - // Debug: log first few track cache statuses - if (M.playlist.length > 0 && M.cachedTracks.size > 0) { - const sample = M.playlist.slice(0, 3).map(t => { - const id = t.id || t.filename; - return { title: t.title?.slice(0, 20), id: id?.slice(0, 12), cached: M.cachedTracks.has(id) }; - }); - console.log("[Playlist Render] Sample tracks:", sample, "| cachedTracks sample:", [...M.cachedTracks].slice(0, 3).map(s => s.slice(0, 12))); - } - - M.playlist.forEach((track, i) => { - const div = document.createElement("div"); - const trackId = track.id || track.filename; - const isCached = M.cachedTracks.has(trackId); - div.className = "track" + (i === M.currentIndex ? " active" : "") + (isCached ? " cached" : " not-cached"); - const title = track.title?.trim() || (track.id || track.filename || "Unknown").replace(/\.[^.]+$/, ""); - - // Show remove button only for user playlists (not stream playlists) - const removeBtn = M.selectedPlaylistId ? `×` : ""; - div.innerHTML = `${title}${removeBtn}${M.fmt(track.duration)}`; - - div.querySelector(".track-title").onclick = async () => { - if (M.synced && M.currentChannelId) { - const res = await fetch("/api/channels/" + M.currentChannelId + "/jump", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ index: i }) - }); - if (res.status === 403) M.flashPermissionDenied(); - if (res.status === 400) console.warn("Jump failed: 400 - index:", i, "playlist length:", M.playlist.length); - } else { - M.currentIndex = i; - M.currentTrackId = trackId; - M.serverTrackDuration = track.duration; - M.$("#track-title").textContent = title; - M.loadingSegments.clear(); - const cachedUrl = await M.loadTrackBlob(trackId); - M.audio.src = cachedUrl || M.getTrackUrl(trackId); - M.audio.currentTime = 0; - M.localTimestamp = 0; - M.audio.play(); - M.renderPlaylist(); - } - }; - - const removeEl = div.querySelector(".btn-remove"); - if (removeEl) { - removeEl.onclick = (e) => { - e.stopPropagation(); - M.removeTrackFromCurrentPlaylist(i); - }; - } - - container.appendChild(div); - }); - }; - - // Render the library - M.renderLibrary = function() { - const container = M.$("#library"); - container.innerHTML = ""; - if (M.library.length === 0) { - container.innerHTML = '
No tracks discovered
'; - return; - } - const canAdd = M.selectedPlaylistId && M.selectedPlaylistId !== "all"; - M.library.forEach((track) => { - const div = document.createElement("div"); - const isCached = M.cachedTracks.has(track.id); - div.className = "track" + (isCached ? " cached" : " not-cached"); - const title = track.title?.trim() || track.filename.replace(/\.[^.]+$/, ""); - const addBtn = canAdd ? `+` : ""; - div.innerHTML = `${title}${addBtn}${M.fmt(track.duration)}`; - - div.querySelector(".track-title").onclick = async () => { - // Play directly from library (uses track ID) - if (!M.synced) { - M.currentTrackId = track.id; - M.serverTrackDuration = track.duration; - M.$("#track-title").textContent = title; - M.loadingSegments.clear(); - const cachedUrl = await M.loadTrackBlob(track.id); - M.audio.src = cachedUrl || M.getTrackUrl(track.id); - M.audio.currentTime = 0; - M.localTimestamp = 0; - M.audio.play(); - } - }; - - const addBtnEl = div.querySelector(".btn-add"); - if (addBtnEl) { - addBtnEl.onclick = (e) => { - e.stopPropagation(); - M.addTrackToCurrentPlaylist(track.id); - }; - } - - container.appendChild(div); - }); - }; - - // Load library from server - M.loadLibrary = async function() { - try { - const res = await fetch("/api/library"); - M.library = await res.json(); - M.renderLibrary(); - } catch (e) { - console.warn("Failed to load library"); - } - }; - - // Load user playlists from server - M.loadPlaylists = async function() { - try { - const res = await fetch("/api/playlists"); - M.userPlaylists = await res.json(); - M.renderPlaylistSelector(); - } catch (e) { - console.warn("Failed to load playlists"); - } - }; - - // Render playlist selector sidebar - M.renderPlaylistSelector = function() { - const list = M.$("#playlists-list"); - if (!list) return; - list.innerHTML = ""; - // Add "All Tracks" as default option - const allItem = document.createElement("div"); - allItem.className = "playlist-item" + (M.selectedPlaylistId === "all" ? " active" : ""); - allItem.textContent = "All Tracks"; - allItem.onclick = () => M.loadSelectedPlaylist("all"); - list.appendChild(allItem); - // Add user playlists - for (const pl of M.userPlaylists) { - const item = document.createElement("div"); - item.className = "playlist-item" + (pl.id === M.selectedPlaylistId ? " active" : ""); - item.textContent = pl.name; - item.onclick = () => M.loadSelectedPlaylist(pl.id); - list.appendChild(item); - } - // Update playlist panel title - const titleEl = M.$("#playlist-title"); - if (M.selectedPlaylistId === "all") { - titleEl.textContent = "Playlist - All Tracks"; - } else if (M.selectedPlaylistId) { - const pl = M.userPlaylists.find(p => p.id === M.selectedPlaylistId); - titleEl.textContent = pl ? "Playlist - " + pl.name : "Playlist"; - } else { - titleEl.textContent = "Playlist"; - } - }; - - // Load and display a specific playlist - M.loadSelectedPlaylist = async function(playlistId) { - if (!playlistId) { - M.playlist = []; - M.selectedPlaylistId = null; - M.renderPlaylist(); - M.renderPlaylistSelector(); - M.renderLibrary(); - return; - } - - // If synced to a channel, broadcast playlist change to server - if (M.synced && M.currentChannelId) { - try { - const res = await fetch("/api/channels/" + M.currentChannelId + "/playlist", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ playlistId: playlistId === "all" ? "all" : parseInt(playlistId) }) - }); - if (res.status === 403) { - M.flashPermissionDenied(); - return; - } - if (!res.ok) throw new Error("Failed to set playlist"); - M.selectedPlaylistId = playlistId; - M.renderPlaylistSelector(); - // Server will broadcast new playlist via WebSocket - return; - } catch (e) { - console.warn("Failed to set channel playlist:", e); - } - } - - // Local mode - load playlist directly - if (playlistId === "all") { - // Use library as playlist - M.playlist = [...M.library]; - M.selectedPlaylistId = "all"; - M.currentIndex = 0; - M.renderPlaylist(); - M.renderPlaylistSelector(); - M.renderLibrary(); - return; - } - try { - const res = await fetch("/api/playlists/" + playlistId); - if (!res.ok) throw new Error("Failed to load playlist"); - const data = await res.json(); - M.playlist = data.tracks || []; - M.selectedPlaylistId = playlistId; - M.currentIndex = 0; - M.renderPlaylist(); - M.renderPlaylistSelector(); - M.renderLibrary(); - } catch (e) { - console.warn("Failed to load playlist:", e); - } - }; - - // Create a new playlist - M.createNewPlaylist = async function() { - const header = M.$("#playlists-panel .panel-header"); - const btn = M.$("#btn-new-playlist"); - - // Already in edit mode? - if (header.querySelector(".new-playlist-input")) return; - - // Hide button, show input - btn.style.display = "none"; - - const input = document.createElement("input"); - input.type = "text"; - input.className = "new-playlist-input"; - input.placeholder = "Playlist name..."; - - const submit = document.createElement("button"); - submit.className = "btn-submit-playlist"; - submit.textContent = "›"; - - header.appendChild(input); - header.appendChild(submit); - input.focus(); - - const cleanup = () => { - input.remove(); - submit.remove(); - btn.style.display = ""; - }; - - const doCreate = async () => { - const name = input.value.trim(); - if (!name) { - cleanup(); - return; - } - try { - const res = await fetch("/api/playlists", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name, visibility: "private" }) - }); - if (!res.ok) throw new Error("Failed to create playlist"); - const pl = await res.json(); - await M.loadPlaylists(); - M.selectedPlaylistId = pl.id; - M.renderPlaylistSelector(); - await M.loadSelectedPlaylist(pl.id); - } catch (e) { - alert("Failed to create playlist"); - } - cleanup(); - }; - - submit.onclick = doCreate; - input.onkeydown = (e) => { - if (e.key === "Enter") doCreate(); - if (e.key === "Escape") cleanup(); - }; - input.onblur = (e) => { - // Delay to allow click on submit button - setTimeout(() => { - if (document.activeElement !== submit) cleanup(); - }, 100); - }; - }; - - // Add a track to current playlist - M.addTrackToCurrentPlaylist = async function(trackId) { - if (!M.selectedPlaylistId || M.selectedPlaylistId === "all") { - alert("Select or create a playlist first"); - return; - } - try { - const res = await fetch("/api/playlists/" + M.selectedPlaylistId + "/tracks", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ trackIds: [trackId] }) - }); - if (!res.ok) throw new Error("Failed to add track"); - await M.loadSelectedPlaylist(M.selectedPlaylistId); - } catch (e) { - console.warn("Failed to add track:", e); - } - }; - - // Remove a track from current playlist - M.removeTrackFromCurrentPlaylist = async function(position) { - if (!M.selectedPlaylistId || M.selectedPlaylistId === "all") return; - try { - const res = await fetch("/api/playlists/" + M.selectedPlaylistId + "/tracks/" + position, { - method: "DELETE" - }); - if (!res.ok) throw new Error("Failed to remove track"); - await M.loadSelectedPlaylist(M.selectedPlaylistId); - } catch (e) { - console.warn("Failed to remove track:", e); - } - }; -})(); diff --git a/public/queue.js b/public/queue.js new file mode 100644 index 0000000..be572e7 --- /dev/null +++ b/public/queue.js @@ -0,0 +1,206 @@ +// MusicRoom - Queue module +// Queue rendering and library display + +(function() { + const M = window.MusicRoom; + + // Update cache status for all tracks + M.updateCacheStatus = async function() { + const cached = await TrackStorage.list(); + + // Migration: remove old filename-based cache entries (keep only sha256: prefixed) + const oldEntries = cached.filter(id => !id.startsWith("sha256:")); + if (oldEntries.length > 0) { + console.log("[Cache] Migrating: removing", oldEntries.length, "old filename-based entries"); + for (const oldId of oldEntries) { + await TrackStorage.remove(oldId); + } + // Re-fetch after cleanup + const updated = await TrackStorage.list(); + M.cachedTracks = new Set(updated); + } else { + M.cachedTracks = new Set(cached); + } + console.log("[Cache] Updated cache status:", M.cachedTracks.size, "tracks cached"); + }; + + // Debug: log cache status for current track + M.debugCacheStatus = function() { + if (!M.currentTrackId) { + console.log("[Cache Debug] No current track"); + return; + } + const trackCache = M.getTrackCache(M.currentTrackId); + const segmentsPct = Math.round((trackCache.size / M.SEGMENTS) * 100); + const inCachedTracks = M.cachedTracks.has(M.currentTrackId); + const hasBlobUrl = M.trackBlobs.has(M.currentTrackId); + const bulkStarted = M.bulkDownloadStarted.get(M.currentTrackId); + + console.log("[Cache Debug]", { + trackId: M.currentTrackId.slice(0, 16) + "...", + segments: `${trackCache.size}/${M.SEGMENTS} (${segmentsPct}%)`, + inCachedTracks, + hasBlobUrl, + bulkStarted, + loadingSegments: [...M.loadingSegments], + cachedTracksSize: M.cachedTracks.size + }); + }; + + // Debug: compare queue track IDs with cached track IDs + M.debugCacheMismatch = function() { + console.log("[Cache Mismatch Debug]"); + console.log("=== Raw State ==="); + console.log("M.cachedTracks:", M.cachedTracks); + console.log("M.trackCaches:", M.trackCaches); + console.log("M.trackBlobs keys:", [...M.trackBlobs.keys()]); + console.log("M.bulkDownloadStarted:", M.bulkDownloadStarted); + console.log("=== Queue Tracks ==="); + M.playlist.forEach((t, i) => { + const id = t.id || t.filename; + console.log(` [${i}] ${t.title?.slice(0, 30)} | id: ${id?.slice(0, 12)}... | cached: ${M.cachedTracks.has(id)}`); + }); + console.log("=== Cached Track IDs ==="); + [...M.cachedTracks].forEach(id => { + console.log(` ${id.slice(0, 20)}...`); + }); + }; + + // Debug: detailed info for a specific track + M.debugTrack = function(index) { + const track = M.playlist[index]; + if (!track) { + console.log("[Debug] No track at index", index); + return; + } + const id = track.id || track.filename; + console.log("[Debug Track]", { + index, + title: track.title, + id, + idPrefix: id?.slice(0, 16), + inCachedTracks: M.cachedTracks.has(id), + inTrackCaches: M.trackCaches.has(id), + segmentCount: M.trackCaches.get(id)?.size || 0, + inTrackBlobs: M.trackBlobs.has(id), + bulkStarted: M.bulkDownloadStarted.get(id) + }); + }; + + // Clear all caches (for debugging) + M.clearAllCaches = async function() { + await TrackStorage.clear(); + M.cachedTracks.clear(); + M.trackCaches.clear(); + M.trackBlobs.clear(); + M.bulkDownloadStarted.clear(); + M.renderQueue(); + M.renderLibrary(); + console.log("[Cache] All caches cleared. Refresh the page."); + }; + + // Render the current queue (channel's playlist) + M.renderQueue = function() { + const container = M.$("#queue"); + if (!container) return; + container.innerHTML = ""; + if (M.playlist.length === 0) { + container.innerHTML = '
Queue empty
'; + return; + } + + // Debug: log first few track cache statuses + if (M.playlist.length > 0 && M.cachedTracks.size > 0) { + const sample = M.playlist.slice(0, 3).map(t => { + const id = t.id || t.filename; + return { title: t.title?.slice(0, 20), id: id?.slice(0, 12), cached: M.cachedTracks.has(id) }; + }); + console.log("[Queue Render] Sample tracks:", sample, "| cachedTracks sample:", [...M.cachedTracks].slice(0, 3).map(s => s.slice(0, 12))); + } + + M.playlist.forEach((track, i) => { + const div = document.createElement("div"); + const trackId = track.id || track.filename; + const isCached = M.cachedTracks.has(trackId); + div.className = "track" + (i === M.currentIndex ? " active" : "") + (isCached ? " cached" : " not-cached"); + const title = track.title?.trim() || (track.id || track.filename || "Unknown").replace(/\.[^.]+$/, ""); + + div.innerHTML = `${title}${M.fmt(track.duration)}`; + + div.querySelector(".track-title").onclick = async () => { + if (M.synced && M.currentChannelId) { + const res = await fetch("/api/channels/" + M.currentChannelId + "/jump", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ index: i }) + }); + if (res.status === 403) M.flashPermissionDenied(); + if (res.status === 400) console.warn("Jump failed: 400 - index:", i, "queue length:", M.playlist.length); + } else { + M.currentIndex = i; + M.currentTrackId = trackId; + M.serverTrackDuration = track.duration; + M.$("#track-title").textContent = title; + M.loadingSegments.clear(); + const cachedUrl = await M.loadTrackBlob(trackId); + M.audio.src = cachedUrl || M.getTrackUrl(trackId); + M.audio.currentTime = 0; + M.localTimestamp = 0; + M.audio.play(); + M.renderQueue(); + } + }; + + container.appendChild(div); + }); + }; + + // Alias for backward compatibility + M.renderPlaylist = M.renderQueue; + + // Render the library + M.renderLibrary = function() { + const container = M.$("#library"); + if (!container) return; + container.innerHTML = ""; + if (M.library.length === 0) { + container.innerHTML = '
No tracks discovered
'; + return; + } + M.library.forEach((track) => { + const div = document.createElement("div"); + const isCached = M.cachedTracks.has(track.id); + div.className = "track" + (isCached ? " cached" : " not-cached"); + const title = track.title?.trim() || track.filename.replace(/\.[^.]+$/, ""); + div.innerHTML = `${title}${M.fmt(track.duration)}`; + + div.querySelector(".track-title").onclick = async () => { + // Play directly from library (uses track ID) - only in local mode + if (!M.synced) { + M.currentTrackId = track.id; + M.serverTrackDuration = track.duration; + M.$("#track-title").textContent = title; + M.loadingSegments.clear(); + const cachedUrl = await M.loadTrackBlob(track.id); + M.audio.src = cachedUrl || M.getTrackUrl(track.id); + M.audio.currentTime = 0; + M.localTimestamp = 0; + M.audio.play(); + } + }; + + container.appendChild(div); + }); + }; + + // Load library from server + M.loadLibrary = async function() { + try { + const res = await fetch("/api/library"); + M.library = await res.json(); + M.renderLibrary(); + } catch (e) { + console.warn("Failed to load library"); + } + }; +})(); diff --git a/public/styles.css b/public/styles.css index a6354c7..ba23280 100644 --- a/public/styles.css +++ b/public/styles.css @@ -29,25 +29,20 @@ h3 { font-size: 0.9rem; color: #666; margin-bottom: 0.5rem; text-transform: uppe #channels-list .listener { font-size: 0.75rem; color: #aaa; padding: 0.15rem 0; position: relative; } #channels-list .listener::before { content: ""; position: absolute; left: -0.5rem; top: 50%; width: 0.4rem; height: 2px; background: #333; } #channels-list .listener-mult { color: #666; font-size: 0.65rem; } -#library-panel, #playlist-panel { flex: 2; background: #1a1a1a; border-radius: 8px; padding: 1rem; display: flex; flex-direction: column; min-height: 300px; max-height: 60vh; } -#playlists-panel { flex: 1; background: #1a1a1a; border-radius: 8px; padding: 1rem; display: flex; flex-direction: column; min-height: 300px; max-height: 60vh; min-width: 180px; max-width: 250px; } -#playlists-list { flex: 1; overflow-y: auto; } -#playlists-list .playlist-item { padding: 0.5rem 0.75rem; border-radius: 4px; cursor: pointer; font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -#playlists-list .playlist-item:hover { background: #222; } -#playlists-list .playlist-item.active { background: #2a3a4a; color: #8cf; } -#playlist-title { margin: 0 0 0.5rem 0; } +#library-panel, #queue-panel { flex: 2; background: #1a1a1a; border-radius: 8px; padding: 1rem; display: flex; flex-direction: column; min-height: 300px; max-height: 60vh; } +#queue-title { margin: 0 0 0.5rem 0; } .panel-header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; } .panel-header h3 { margin: 0; flex-shrink: 0; } .panel-header select { flex: 1; background: #222; color: #eee; border: 1px solid #333; padding: 0.3rem 0.5rem; border-radius: 4px; font-size: 0.8rem; } .panel-header button { background: #333; color: #eee; border: 1px solid #444; width: 28px; height: 28px; border-radius: 4px; cursor: pointer; font-size: 1rem; line-height: 1; padding: 0; } .panel-header button:hover { background: #444; } -.new-playlist-input, .new-channel-input { flex: 1; background: #222; color: #eee; border: 1px solid #333; padding: 0.3rem 0.5rem; border-radius: 4px; font-size: 0.8rem; } -.btn-submit-playlist, .btn-submit-channel { background: #2a4a3a; color: #4e8; border: 1px solid #3a5a4a; width: 28px; height: 28px; border-radius: 4px; cursor: pointer; font-size: 1.2rem; line-height: 1; padding: 0; } -.btn-submit-playlist:hover, .btn-submit-channel:hover { background: #3a5a4a; } -#library, #playlist { flex: 1; overflow-y: auto; } -#library .track, #playlist .track { padding: 0.5rem 0.75rem; border-radius: 4px; cursor: pointer; font-size: 0.9rem; display: flex; justify-content: space-between; align-items: center; position: relative; } -#library .track:hover, #playlist .track:hover { background: #222; } -#playlist .track.active { background: #2a4a3a; color: #4e8; } +.new-channel-input { flex: 1; background: #222; color: #eee; border: 1px solid #333; padding: 0.3rem 0.5rem; border-radius: 4px; font-size: 0.8rem; } +.btn-submit-channel { background: #2a4a3a; color: #4e8; border: 1px solid #3a5a4a; width: 28px; height: 28px; border-radius: 4px; cursor: pointer; font-size: 1.2rem; line-height: 1; padding: 0; } +.btn-submit-channel:hover { background: #3a5a4a; } +#library, #queue { flex: 1; overflow-y: auto; } +#library .track, #queue .track { padding: 0.5rem 0.75rem; border-radius: 4px; cursor: pointer; font-size: 0.9rem; display: flex; justify-content: space-between; align-items: center; position: relative; } +#library .track:hover, #queue .track:hover { background: #222; } +#queue .track.active { background: #2a4a3a; color: #4e8; } .cache-indicator { width: 3px; height: 100%; position: absolute; left: 0; top: 0; border-radius: 4px 0 0 4px; } .track.cached .cache-indicator { background: #4e8; } .track.not-cached .cache-indicator { background: #ea4; } diff --git a/server.ts b/server.ts index 80eef5c..28e9529 100644 --- a/server.ts +++ b/server.ts @@ -16,18 +16,6 @@ import { grantPermission, revokePermission, findUserById, - createPlaylist, - getPlaylist, - updatePlaylist, - deletePlaylist, - getVisiblePlaylists, - canViewPlaylist, - canEditPlaylist, - getPlaylistTracks, - addTrackToPlaylist, - removeTrackFromPlaylist, - reorderPlaylistTrack, - type Playlist, } from "./db"; import { getUser, @@ -355,166 +343,6 @@ serve({ return Response.json(tracks, { headers }); } - // Playlist API: list playlists - if (path === "/api/playlists" && req.method === "GET") { - const { user, headers } = getOrCreateUser(req, server); - if (!user) { - return Response.json({ error: "Authentication required" }, { status: 401 }); - } - const playlists = getVisiblePlaylists(user.id, user.is_guest); - return Response.json(playlists, { headers }); - } - - // Playlist API: create playlist - if (path === "/api/playlists" && req.method === "POST") { - const { user } = getOrCreateUser(req, server); - if (!user) { - return Response.json({ error: "Authentication required" }, { status: 401 }); - } - if (user.is_guest) { - return Response.json({ error: "Guests cannot create playlists" }, { status: 403 }); - } - try { - const { name, description, visibility } = await req.json(); - if (!name || typeof name !== "string" || name.trim().length === 0) { - return Response.json({ error: "Name is required" }, { status: 400 }); - } - const playlist = createPlaylist(user.id, name.trim(), visibility || "private", description); - return Response.json(playlist, { status: 201 }); - } catch { - return Response.json({ error: "Invalid request" }, { status: 400 }); - } - } - - // Playlist API: get/update/delete single playlist - const playlistMatch = path.match(/^\/api\/playlists\/([^/]+)$/); - if (playlistMatch) { - const playlistId = playlistMatch[1]; - const { user, headers } = getOrCreateUser(req, server); - if (!user) { - return Response.json({ error: "Authentication required" }, { status: 401 }); - } - const playlist = getPlaylist(playlistId); - if (!playlist) { - return Response.json({ error: "Playlist not found" }, { status: 404 }); - } - - if (req.method === "GET") { - if (!canViewPlaylist(playlist, user.id, user.is_guest)) { - return Response.json({ error: "Access denied" }, { status: 403 }); - } - const tracks = getPlaylistTracks(playlistId); - return Response.json({ ...playlist, tracks }, { headers }); - } - - if (req.method === "PUT") { - if (!canEditPlaylist(playlist, user.id)) { - return Response.json({ error: "Access denied" }, { status: 403 }); - } - try { - const { name, description, visibility } = await req.json(); - updatePlaylist(playlistId, { name, description, visibility }); - const updated = getPlaylist(playlistId); - return Response.json(updated); - } catch { - return Response.json({ error: "Invalid request" }, { status: 400 }); - } - } - - if (req.method === "DELETE") { - if (!canEditPlaylist(playlist, user.id)) { - return Response.json({ error: "Access denied" }, { status: 403 }); - } - deletePlaylist(playlistId); - return Response.json({ success: true }); - } - } - - // Playlist API: add tracks - const playlistTracksMatch = path.match(/^\/api\/playlists\/([^/]+)\/tracks$/); - if (playlistTracksMatch && req.method === "POST") { - const playlistId = playlistTracksMatch[1]; - const { user } = getOrCreateUser(req, server); - if (!user) { - return Response.json({ error: "Authentication required" }, { status: 401 }); - } - const playlist = getPlaylist(playlistId); - if (!playlist) { - return Response.json({ error: "Playlist not found" }, { status: 404 }); - } - if (!canEditPlaylist(playlist, user.id)) { - return Response.json({ error: "Access denied" }, { status: 403 }); - } - try { - const { trackIds, position } = await req.json(); - if (!Array.isArray(trackIds) || trackIds.length === 0) { - return Response.json({ error: "trackIds array required" }, { status: 400 }); - } - for (const trackId of trackIds) { - if (!library.getTrack(trackId)) { - return Response.json({ error: `Track not found: ${trackId}` }, { status: 404 }); - } - } - let insertPos = position; - for (const trackId of trackIds) { - addTrackToPlaylist(playlistId, trackId, user.id, insertPos); - if (insertPos !== undefined) insertPos++; - } - const tracks = getPlaylistTracks(playlistId); - return Response.json({ tracks }, { status: 201 }); - } catch { - return Response.json({ error: "Invalid request" }, { status: 400 }); - } - } - - // Playlist API: remove track - const playlistTrackMatch = path.match(/^\/api\/playlists\/([^/]+)\/tracks\/(\d+)$/); - if (playlistTrackMatch && req.method === "DELETE") { - const playlistId = playlistTrackMatch[1]; - const position = parseInt(playlistTrackMatch[2]); - const { user } = getOrCreateUser(req, server); - if (!user) { - return Response.json({ error: "Authentication required" }, { status: 401 }); - } - const playlist = getPlaylist(playlistId); - if (!playlist) { - return Response.json({ error: "Playlist not found" }, { status: 404 }); - } - if (!canEditPlaylist(playlist, user.id)) { - return Response.json({ error: "Access denied" }, { status: 403 }); - } - removeTrackFromPlaylist(playlistId, position); - return Response.json({ success: true }); - } - - // Playlist API: reorder tracks - const playlistReorderMatch = path.match(/^\/api\/playlists\/([^/]+)\/tracks\/reorder$/); - if (playlistReorderMatch && req.method === "PUT") { - const playlistId = playlistReorderMatch[1]; - const { user } = getOrCreateUser(req, server); - if (!user) { - return Response.json({ error: "Authentication required" }, { status: 401 }); - } - const playlist = getPlaylist(playlistId); - if (!playlist) { - return Response.json({ error: "Playlist not found" }, { status: 404 }); - } - if (!canEditPlaylist(playlist, user.id)) { - return Response.json({ error: "Access denied" }, { status: 403 }); - } - try { - const { from, to } = await req.json(); - if (typeof from !== "number" || typeof to !== "number") { - return Response.json({ error: "from and to positions required" }, { status: 400 }); - } - reorderPlaylistTrack(playlistId, from, to); - const tracks = getPlaylistTracks(playlistId); - return Response.json({ tracks }); - } catch { - return Response.json({ error: "Invalid request" }, { status: 400 }); - } - } - // Auth: signup if (path === "/api/auth/signup" && req.method === "POST") { try { @@ -696,35 +524,6 @@ serve({ } } - // API: set channel playlist - const playlistMatch = path.match(/^\/api\/channels\/([^/]+)\/playlist$/); - if (playlistMatch && req.method === "POST") { - const channelId = playlistMatch[1]; - const { user } = getOrCreateUser(req, server); - if (!userHasPermission(user, "channel", channelId, "control")) { - return new Response("Forbidden", { status: 403 }); - } - const channel = channels.get(channelId); - if (!channel) return new Response("Not found", { status: 404 }); - try { - const body = await req.json(); - let tracks: Track[] = []; - if (body.playlistId === "all") { - tracks = library.getAllTracks(); - } else if (typeof body.playlistId === "number") { - const playlist = getPlaylist(body.playlistId); - if (!playlist) return new Response("Playlist not found", { status: 404 }); - tracks = getPlaylistTracks(body.playlistId); - } else { - return new Response("Invalid playlistId", { status: 400 }); - } - channel.setPlaylist(tracks); - return Response.json({ success: true, trackCount: tracks.length }); - } catch { - return new Response("Invalid JSON", { status: 400 }); - } - } - // API: serve audio file (requires auth or guest) // Supports both filename and track ID (sha256:...) const trackMatch = path.match(/^\/api\/tracks\/(.+)$/);