diff --git a/.gitignore b/.gitignore index 90d6690..96e844b 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,5 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json .DS_Store tmp/ +library_cache.db + diff --git a/AGENTS.md b/AGENTS.md index 15db987..99167b9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,23 @@ The server does NOT decode or play audio. It tracks time: - When `currentTimestamp >= track.duration`, advance to next track, reset `startedAt` - A 1s `setInterval` checks if tracks need advancing and broadcasts state every 30s +## Content-Addressed Tracks + +All tracks are identified by a **content hash** (SHA-256 of first 64KB), not by filename: +- `track.id` = Content hash (primary key in database, used for caching, API requests) +- `track.filename` = Original filename (display only) +- `track.title` = Metadata title or filename without extension (display only) + +This allows: +- Deduplication (same file with different names = same track) +- Renaming files without breaking playlists +- Reliable client-side caching by content hash + +The client must use `track.id` for: +- Caching tracks in IndexedDB (`TrackStorage.set(track.id, blob)`) +- Fetching audio (`/api/tracks/:id`) +- Checking cache status + ## Routes ``` @@ -16,24 +33,32 @@ GET / → Serves public/index.html GET /api/streams → List active streams (id, name, trackCount) GET /api/streams/:id → Current stream state (track, currentTimestamp, streamName) WS /api/streams/:id/ws → WebSocket: pushes state on connect, every 30s, and on track change -GET /api/tracks/:filename → Serve audio file from ./music/ with Range request support +GET /api/tracks/:id → Serve audio file by content hash with Range request support +GET /api/library → List all tracks with id, filename, title, duration ``` ## Files -- **server.ts** — Bun entrypoint. Loads playlist config, reads track metadata via `music-metadata`, sets up HTTP routes and WebSocket handlers. Auto-discovers audio files in `./music/` when playlist tracks array is empty. -- **stream.ts** — `Stream` class. Holds playlist, current index, startedAt timestamp, connected WebSocket clients. Manages time tracking, track advancement, and broadcasting state to clients. -- **playlist.json** — Config file. Array of stream definitions, each with id, name, and tracks array (empty = auto-discover). -- **public/index.html** — Single-file client with inline JS/CSS. Connects via WebSocket, receives state updates, fetches audio, syncs playback. Has progress bar, track info, play/pause button, volume slider. +- **server.ts** — Bun entrypoint. HTTP routes and WebSocket handlers. +- **stream.ts** — `Stream` class. Playlist, current index, time tracking, broadcasting. +- **library.ts** — `Library` class. Scans music directory, computes content hashes, caches metadata. +- **db.ts** — SQLite database for users, sessions, playlists, tracks. +- **playlist.json** — Config file. Stream definitions. +- **public/** — Client files (modular JS: core.js, utils.js, audioCache.js, etc.) - **music/** — Directory for audio files (.mp3, .ogg, .flac, .wav, .m4a, .aac). ## Key types ```ts -interface Track { filename: string; title: string; duration: number } +interface Track { + id: string; // Content hash (primary key) + filename: string; // Original filename + title: string; // Display title + duration: number; +} // Stream.getState() returns: -{ track: Track | null, currentTimestamp: number, streamName: string } +{ track: Track | null, currentTimestamp: number, streamName: string, paused: boolean } ``` ## Client sync logic diff --git a/musicroom.db b/musicroom.db index 1389c47..dbc96e0 100644 Binary files a/musicroom.db and b/musicroom.db differ diff --git a/public/audioCache.js b/public/audioCache.js index b8abca3..5350804 100644 --- a/public/audioCache.js +++ b/public/audioCache.js @@ -5,64 +5,87 @@ const M = window.MusicRoom; // Get or create cache for a track - M.getTrackCache = function(filename) { - if (!filename) return new Set(); - if (!M.trackCaches.has(filename)) { - M.trackCaches.set(filename, new Set()); + M.getTrackCache = function(trackId) { + if (!trackId) return new Set(); + if (!M.trackCaches.has(trackId)) { + M.trackCaches.set(trackId, new Set()); } - return M.trackCaches.get(filename); + return M.trackCaches.get(trackId); }; // Get track URL - prefers cached blob, falls back to API - M.getTrackUrl = function(filename) { - return M.trackBlobs.get(filename) || "/api/tracks/" + encodeURIComponent(filename); + M.getTrackUrl = function(trackId) { + return M.trackBlobs.get(trackId) || "/api/tracks/" + encodeURIComponent(trackId); }; // Load a track blob from storage or fetch from server - M.loadTrackBlob = async function(filename) { + M.loadTrackBlob = async function(trackId) { // Check if already in memory - if (M.trackBlobs.has(filename)) { - return M.trackBlobs.get(filename); + if (M.trackBlobs.has(trackId)) { + return M.trackBlobs.get(trackId); } // Check persistent storage - const cached = await TrackStorage.get(filename); + const cached = await TrackStorage.get(trackId); if (cached) { const blobUrl = URL.createObjectURL(cached.blob); - M.trackBlobs.set(filename, blobUrl); + M.trackBlobs.set(trackId, blobUrl); // Mark all segments as cached - const trackCache = M.getTrackCache(filename); + const trackCache = M.getTrackCache(trackId); for (let i = 0; i < M.SEGMENTS; i++) trackCache.add(i); - M.bulkDownloadStarted.set(filename, true); + M.bulkDownloadStarted.set(trackId, true); + // Update cache status indicator + if (!M.cachedTracks.has(trackId)) { + M.cachedTracks.add(trackId); + M.renderPlaylist(); + M.renderLibrary(); + } return blobUrl; } return null; }; + // Check if track has all segments and trigger full cache if so + M.checkAndCacheComplete = function(trackId) { + if (!trackId || M.cachedTracks.has(trackId)) return; + + const trackCache = M.trackCaches.get(trackId); + if (trackCache && trackCache.size >= M.SEGMENTS) { + console.log("[Cache] Track has all segments, triggering full cache:", trackId.slice(0, 16) + "..."); + M.downloadAndCacheTrack(trackId); + } + }; + // Download and cache a full track - M.downloadAndCacheTrack = async function(filename) { - if (M.bulkDownloadStarted.get(filename)) return M.trackBlobs.get(filename); - M.bulkDownloadStarted.set(filename, true); + M.downloadAndCacheTrack = async function(trackId) { + if (M.bulkDownloadStarted.get(trackId)) return M.trackBlobs.get(trackId); + M.bulkDownloadStarted.set(trackId, true); try { const startTime = performance.now(); - const res = await fetch("/api/tracks/" + encodeURIComponent(filename)); + const res = await fetch("/api/tracks/" + encodeURIComponent(trackId)); const data = await res.arrayBuffer(); const elapsed = (performance.now() - startTime) / 1000; // Mark all segments as cached - const trackCache = M.getTrackCache(filename); + const trackCache = M.getTrackCache(trackId); for (let i = 0; i < M.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); - M.trackBlobs.set(filename, blobUrl); + M.trackBlobs.set(trackId, blobUrl); // Persist to storage - await TrackStorage.set(filename, blob, contentType); + await TrackStorage.set(trackId, blob, contentType); + + // Update cache status and re-render lists + console.log("[Cache] Track cached:", trackId.slice(0, 16) + "...", "| size:", (data.byteLength / 1024 / 1024).toFixed(2) + "MB"); + M.cachedTracks.add(trackId); + M.renderPlaylist(); + M.renderLibrary(); // Update download speed if (elapsed > 0 && data.byteLength > 0) { @@ -73,21 +96,22 @@ return blobUrl; } catch (e) { - M.bulkDownloadStarted.set(filename, false); + M.bulkDownloadStarted.set(trackId, false); return null; } }; // Fetch a single segment with range request async function fetchSegment(i, segStart, segEnd) { - const trackCache = M.getTrackCache(M.currentFilename); + const trackId = M.currentTrackId; + const trackCache = M.getTrackCache(trackId); if (M.loadingSegments.has(i) || trackCache.has(i)) return; M.loadingSegments.add(i); try { const byteStart = Math.floor(segStart * M.audioBytesPerSecond); const byteEnd = Math.floor(segEnd * M.audioBytesPerSecond); const startTime = performance.now(); - const res = await fetch("/api/tracks/" + encodeURIComponent(M.currentFilename), { + const res = await fetch("/api/tracks/" + encodeURIComponent(trackId), { headers: { "Range": `bytes=${byteStart}-${byteEnd}` } }); const data = await res.arrayBuffer(); @@ -96,6 +120,13 @@ // Mark segment as cached trackCache.add(i); + // Check if all segments are now cached - if so, trigger full cache + if (trackCache.size >= M.SEGMENTS && !M.cachedTracks.has(trackId)) { + console.log("[Cache] All segments complete for:", trackId.slice(0, 16) + "...", "- triggering full cache"); + // Download full track to persist to storage + M.downloadAndCacheTrack(trackId); + } + // Update audio bitrate estimate const bytesReceived = data.byteLength; const durationCovered = segEnd - segStart; @@ -115,13 +146,13 @@ // Background bulk download - runs independently async function startBulkDownload() { - const filename = M.currentFilename; - if (!filename || M.bulkDownloadStarted.get(filename)) return; + const trackId = M.currentTrackId; + if (!trackId || M.bulkDownloadStarted.get(trackId)) return; - const blobUrl = await M.downloadAndCacheTrack(filename); + const blobUrl = await M.downloadAndCacheTrack(trackId); // Switch to blob URL if still on this track - if (blobUrl && M.currentFilename === filename && M.audio.src && !M.audio.src.startsWith("blob:")) { + if (blobUrl && M.currentTrackId === trackId && M.audio.src && !M.audio.src.startsWith("blob:")) { const currentTime = M.audio.currentTime; const wasPlaying = !M.audio.paused; M.audio.src = blobUrl; @@ -134,12 +165,12 @@ let prefetching = false; M.prefetchSegments = async function() { - if (prefetching || !M.currentFilename || !M.audio.src || M.serverTrackDuration <= 0) return; + if (prefetching || !M.currentTrackId || !M.audio.src || M.serverTrackDuration <= 0) return; prefetching = true; const segmentDur = M.serverTrackDuration / M.SEGMENTS; const missingSegments = []; - const trackCache = M.getTrackCache(M.currentFilename); + const trackCache = M.getTrackCache(M.currentTrackId); // Find all missing segments (not in audio buffer AND not in our cache) for (let i = 0; i < M.SEGMENTS; i++) { @@ -161,7 +192,7 @@ if (missingSegments.length > 0) { // Fast connection: also start bulk download in background - if (M.downloadSpeed >= M.FAST_THRESHOLD && !M.bulkDownloadStarted.get(M.currentFilename)) { + if (M.downloadSpeed >= M.FAST_THRESHOLD && !M.bulkDownloadStarted.get(M.currentTrackId)) { startBulkDownload(); // Fire and forget } // Always fetch segments one at a time for seek support diff --git a/public/controls.js b/public/controls.js index 8ba874c..3e9c715 100644 --- a/public/controls.js +++ b/public/controls.js @@ -16,7 +16,7 @@ // Toggle play/pause function togglePlayback() { - if (!M.currentFilename) return; + if (!M.currentTrackId) return; if (M.synced) { if (M.ws && M.ws.readyState === WebSocket.OPEN) { @@ -25,7 +25,7 @@ } else { if (M.audio.paused) { if (!M.audio.src) { - M.audio.src = M.getTrackUrl(M.currentFilename); + M.audio.src = M.getTrackUrl(M.currentTrackId); M.audio.currentTime = M.localTimestamp; } M.audio.play(); @@ -49,15 +49,17 @@ body: JSON.stringify({ index: newIndex }) }); if (res.status === 403) M.flashPermissionDenied(); + if (res.status === 400) console.warn("Jump failed: 400 - newIndex:", newIndex, "playlist length:", M.playlist.length); } else { const track = M.playlist[newIndex]; + const trackId = track.id || track.filename; M.currentIndex = newIndex; - M.currentFilename = track.filename; + M.currentTrackId = trackId; M.serverTrackDuration = track.duration; - M.$("#track-title").textContent = track.title?.trim() || track.filename.replace(/\.[^.]+$/, ""); + M.$("#track-title").textContent = track.title?.trim() || track.filename?.replace(/\.[^.]+$/, "") || "Unknown"; M.loadingSegments.clear(); - const cachedUrl = await M.loadTrackBlob(track.filename); - M.audio.src = cachedUrl || M.getTrackUrl(track.filename); + const cachedUrl = await M.loadTrackBlob(trackId); + M.audio.src = cachedUrl || M.getTrackUrl(trackId); M.audio.currentTime = 0; M.localTimestamp = 0; M.audio.play(); @@ -113,7 +115,7 @@ // Progress bar seek M.$("#progress-container").onclick = (e) => { const dur = M.synced ? M.serverTrackDuration : (M.audio.duration || M.serverTrackDuration); - if (!M.currentFilename || dur <= 0) return; + if (!M.currentTrackId || dur <= 0) return; const rect = M.$("#progress-container").getBoundingClientRect(); const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const seekTime = pct * dur; @@ -126,7 +128,7 @@ }).then(res => { if (res.status === 403) M.flashPermissionDenied(); }); } else { if (!M.audio.src) { - M.audio.src = M.getTrackUrl(M.currentFilename); + M.audio.src = M.getTrackUrl(M.currentTrackId); } M.audio.currentTime = seekTime; M.localTimestamp = seekTime; diff --git a/public/core.js b/public/core.js index 8bae519..8d910ea 100644 --- a/public/core.js +++ b/public/core.js @@ -8,7 +8,7 @@ window.MusicRoom = { // WebSocket and stream state ws: null, currentStreamId: null, - currentFilename: null, + currentTrackId: null, currentTitle: null, serverTimestamp: 0, serverTrackDuration: 0, @@ -43,6 +43,7 @@ window.MusicRoom = { trackCaches: new Map(), // Map of filename -> Set of cached segment indices trackBlobs: new Map(), // Map of filename -> Blob URL for fully cached tracks bulkDownloadStarted: new Map(), + cachedTracks: new Set(), // Set of track IDs that are fully cached locally // Download metrics audioBytesPerSecond: 20000, // Audio bitrate estimate for range requests diff --git a/public/init.js b/public/init.js index 8a9c6f5..acd7710 100644 --- a/public/init.js +++ b/public/init.js @@ -19,8 +19,8 @@ // Initialize track storage async function initStorage() { await TrackStorage.init(); - const cached = await TrackStorage.list(); - console.log(`TrackStorage: ${cached.length} tracks cached`); + await M.updateCacheStatus(); + console.log(`TrackStorage: ${M.cachedTracks.size} tracks cached`); } // Initialize the application diff --git a/public/playlist.js b/public/playlist.js index e17fe7f..22bba81 100644 --- a/public/playlist.js +++ b/public/playlist.js @@ -4,6 +4,105 @@ (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"); @@ -12,15 +111,26 @@ container.innerHTML = '