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 = '
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"); - div.className = "track" + (i === M.currentIndex ? " active" : ""); - const title = track.title?.trim() || (track.id || track.filename || "Unknown").replace(/\.[^.]+$/, ""); 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.innerHTML = `${title}${removeBtn}${M.fmt(track.duration)}`; div.querySelector(".track-title").onclick = async () => { if (M.synced && M.currentStreamId) { @@ -30,9 +140,10 @@ 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.currentFilename = trackId; + M.currentTrackId = trackId; M.serverTrackDuration = track.duration; M.$("#track-title").textContent = title; M.loadingSegments.clear(); @@ -68,15 +179,16 @@ const canAdd = M.selectedPlaylistId && M.selectedPlaylistId !== "all"; M.library.forEach((track) => { const div = document.createElement("div"); - div.className = "track"; + 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.innerHTML = `${title}${addBtn}${M.fmt(track.duration)}`; div.querySelector(".track-title").onclick = async () => { // Play directly from library (uses track ID) if (!M.synced) { - M.currentFilename = track.id; + M.currentTrackId = track.id; M.serverTrackDuration = track.duration; M.$("#track-title").textContent = title; M.loadingSegments.clear(); diff --git a/public/streamSync.js b/public/streamSync.js index 66abaa4..1d216d0 100644 --- a/public/streamSync.js +++ b/public/streamSync.js @@ -117,13 +117,28 @@ M.renderPlaylist(); } - // Cache track info for local mode - const isNewTrack = data.track.filename !== M.currentFilename; + // Cache track info for local mode - use track.id (content hash) as the identifier + const trackId = data.track.id || data.track.filename; // Fallback for compatibility + const isNewTrack = trackId !== M.currentTrackId; if (isNewTrack) { - M.currentFilename = data.track.filename; + M.currentTrackId = trackId; M.currentTitle = data.track.title; M.$("#track-title").textContent = data.track.title; M.loadingSegments.clear(); + + // Debug: log cache state for this track + const trackCache = M.trackCaches.get(trackId); + console.log("[Playback] Starting track:", data.track.title, { + trackId: trackId, + segments: trackCache ? [...trackCache] : [], + segmentCount: trackCache ? trackCache.size : 0, + inCachedTracks: M.cachedTracks.has(trackId), + bulkStarted: M.bulkDownloadStarted.get(trackId) || false, + hasBlobUrl: M.trackBlobs.has(trackId) + }); + + // Check if this track already has all segments cached + M.checkAndCacheComplete(trackId); } if (M.synced) { @@ -131,8 +146,8 @@ // Server is playing - ensure we're playing and synced if (isNewTrack || !M.audio.src) { // Try cache first - const cachedUrl = await M.loadTrackBlob(M.currentFilename); - M.audio.src = cachedUrl || M.getTrackUrl(M.currentFilename); + const cachedUrl = await M.loadTrackBlob(M.currentTrackId); + M.audio.src = cachedUrl || M.getTrackUrl(M.currentTrackId); } if (M.audio.paused) { M.audio.currentTime = data.currentTimestamp; diff --git a/public/styles.css b/public/styles.css index c4606de..df52a15 100644 --- a/public/styles.css +++ b/public/styles.css @@ -33,9 +33,12 @@ h3 { font-size: 0.9rem; color: #666; margin-bottom: 0.5rem; text-transform: uppe .btn-submit-playlist { 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 { 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; } +#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; } +.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; } .track-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .track-actions { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; } .track-actions .duration { color: #666; font-size: 0.8rem; } diff --git a/public/ui.js b/public/ui.js index 486b178..c81ba1e 100644 --- a/public/ui.js +++ b/public/ui.js @@ -98,17 +98,20 @@ const segments = M.$("#buffer-bar").children; const segmentDur = dur / M.SEGMENTS; let availableCount = 0; + const trackCache = M.getTrackCache(M.currentTrackId); for (let i = 0; i < M.SEGMENTS; i++) { const segStart = i * segmentDur; const segEnd = (i + 1) * segmentDur; - const trackCache = M.getTrackCache(M.currentFilename); let available = trackCache.has(i); // Check our cache first if (!available) { + // Check browser's native buffer for (let j = 0; j < M.audio.buffered.length; j++) { const bufStart = M.audio.buffered.start(j); const bufEnd = M.audio.buffered.end(j); if (bufStart <= segStart && bufEnd >= segEnd) { available = true; + // Sync browser buffer to our trackCache + trackCache.add(i); break; } } @@ -121,6 +124,11 @@ if (shouldBeLoading !== isLoading) segments[i].classList.toggle("loading", shouldBeLoading); } + // Check if all segments now cached - trigger full cache + if (trackCache.size >= M.SEGMENTS && !M.cachedTracks.has(M.currentTrackId)) { + M.checkAndCacheComplete(M.currentTrackId); + } + // Update download speed display const kbps = M.downloadSpeed > 0 ? M.downloadSpeed * 8 / 1000 : 0; const bufferPct = Math.round(availableCount / M.SEGMENTS * 100); @@ -137,8 +145,19 @@ // Prefetch loop (1s interval) setInterval(() => { - if (M.currentFilename && M.audio.src) { + if (M.currentTrackId && M.audio.src) { M.prefetchSegments(); } }, 1000); + + // Cache status check (5s interval) - updates indicators when tracks finish caching + let lastCacheSize = 0; + setInterval(async () => { + const currentSize = M.cachedTracks.size; + if (currentSize !== lastCacheSize) { + lastCacheSize = currentSize; + M.renderPlaylist(); + M.renderLibrary(); + } + }, 5000); })(); diff --git a/server.ts b/server.ts index dfc227e..6300706 100644 --- a/server.ts +++ b/server.ts @@ -64,16 +64,31 @@ const library = new Library(MUSIC_DIR); // Load track metadata (for stream initialization - converts library tracks to stream format) async function loadTrack(filename: string): Promise { + // First check if this track is in the library (has content hash) + const allTracks = library.getAllTracks(); + const libTrack = allTracks.find(t => t.filename === filename); + + if (libTrack) { + console.log(`Track: ${filename} | duration: ${libTrack.duration}s | title: ${libTrack.title} | id: ${libTrack.id.slice(0, 8)}...`); + return { + id: libTrack.id, + filename: libTrack.filename, + title: libTrack.title || filename.replace(/\.[^.]+$/, ""), + duration: libTrack.duration + }; + } + + // Fallback: load metadata directly (shouldn't happen if library is scanned first) const filepath = join(MUSIC_DIR, filename); try { const metadata = await parseFile(filepath, { duration: true }); const duration = metadata.format.duration ?? 0; const title = metadata.common.title?.trim() || filename.replace(/\.[^.]+$/, ""); - console.log(`Track: ${filename} | duration: ${duration}s | title: ${title}`); - return { filename, title, duration }; + console.log(`Track: ${filename} | duration: ${duration}s | title: ${title} | id: (no hash)`); + return { id: filename, filename, title, duration }; // Use filename as fallback ID } catch (e) { console.warn(`Could not read metadata for ${filename}, skipping`); - return { filename, title: filename.replace(/\.[^.]+$/, ""), duration: 0 }; + return { id: filename, filename, title: filename.replace(/\.[^.]+$/, ""), duration: 0 }; } } @@ -683,15 +698,14 @@ serve({ 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" }, - }); + // Serve JS files from public directory + if (path.endsWith(".js")) { + const jsFile = file(join(PUBLIC_DIR, path.slice(1))); + if (await jsFile.exists()) { + return new Response(jsFile, { + headers: { "Content-Type": "application/javascript" }, + }); + } } return new Response("Not found", { status: 404 }); diff --git a/stream.ts b/stream.ts index ef603cd..1e5a52c 100644 --- a/stream.ts +++ b/stream.ts @@ -1,8 +1,9 @@ import type { ServerWebSocket } from "bun"; export interface Track { - filename: string; - title: string; + id: string; // Content hash (primary key) + filename: string; // Original filename + title: string; // Display title duration: number; }