This commit is contained in:
peterino2 2026-02-02 21:50:57 -08:00
parent 8fdd64de61
commit a910ec195f
13 changed files with 303 additions and 78 deletions

2
.gitignore vendored
View File

@ -34,3 +34,5 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
.DS_Store .DS_Store
tmp/ tmp/
library_cache.db

View File

@ -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` - When `currentTimestamp >= track.duration`, advance to next track, reset `startedAt`
- A 1s `setInterval` checks if tracks need advancing and broadcasts state every 30s - 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 ## Routes
``` ```
@ -16,24 +33,32 @@ GET / → Serves public/index.html
GET /api/streams → List active streams (id, name, trackCount) GET /api/streams → List active streams (id, name, trackCount)
GET /api/streams/:id → Current stream state (track, currentTimestamp, streamName) 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 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 ## 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. - **server.ts** — Bun entrypoint. HTTP routes and WebSocket handlers.
- **stream.ts**`Stream` class. Holds playlist, current index, startedAt timestamp, connected WebSocket clients. Manages time tracking, track advancement, and broadcasting state to clients. - **stream.ts**`Stream` class. Playlist, current index, time tracking, broadcasting.
- **playlist.json** — Config file. Array of stream definitions, each with id, name, and tracks array (empty = auto-discover). - **library.ts**`Library` class. Scans music directory, computes content hashes, caches metadata.
- **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. - **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). - **music/** — Directory for audio files (.mp3, .ogg, .flac, .wav, .m4a, .aac).
## Key types ## Key types
```ts ```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: // Stream.getState() returns:
{ track: Track | null, currentTimestamp: number, streamName: string } { track: Track | null, currentTimestamp: number, streamName: string, paused: boolean }
``` ```
## Client sync logic ## Client sync logic

Binary file not shown.

View File

@ -5,64 +5,87 @@
const M = window.MusicRoom; const M = window.MusicRoom;
// Get or create cache for a track // Get or create cache for a track
M.getTrackCache = function(filename) { M.getTrackCache = function(trackId) {
if (!filename) return new Set(); if (!trackId) return new Set();
if (!M.trackCaches.has(filename)) { if (!M.trackCaches.has(trackId)) {
M.trackCaches.set(filename, new Set()); 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 // Get track URL - prefers cached blob, falls back to API
M.getTrackUrl = function(filename) { M.getTrackUrl = function(trackId) {
return M.trackBlobs.get(filename) || "/api/tracks/" + encodeURIComponent(filename); return M.trackBlobs.get(trackId) || "/api/tracks/" + encodeURIComponent(trackId);
}; };
// Load a track blob from storage or fetch from server // 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 // Check if already in memory
if (M.trackBlobs.has(filename)) { if (M.trackBlobs.has(trackId)) {
return M.trackBlobs.get(filename); return M.trackBlobs.get(trackId);
} }
// Check persistent storage // Check persistent storage
const cached = await TrackStorage.get(filename); const cached = await TrackStorage.get(trackId);
if (cached) { if (cached) {
const blobUrl = URL.createObjectURL(cached.blob); const blobUrl = URL.createObjectURL(cached.blob);
M.trackBlobs.set(filename, blobUrl); M.trackBlobs.set(trackId, blobUrl);
// Mark all segments as cached // 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); 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 blobUrl;
} }
return null; 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 // Download and cache a full track
M.downloadAndCacheTrack = async function(filename) { M.downloadAndCacheTrack = async function(trackId) {
if (M.bulkDownloadStarted.get(filename)) return M.trackBlobs.get(filename); if (M.bulkDownloadStarted.get(trackId)) return M.trackBlobs.get(trackId);
M.bulkDownloadStarted.set(filename, true); M.bulkDownloadStarted.set(trackId, true);
try { try {
const startTime = performance.now(); 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 data = await res.arrayBuffer();
const elapsed = (performance.now() - startTime) / 1000; const elapsed = (performance.now() - startTime) / 1000;
// Mark all segments as cached // 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); for (let i = 0; i < M.SEGMENTS; i++) trackCache.add(i);
// Create blob and URL // Create blob and URL
const contentType = res.headers.get("Content-Type") || "audio/mpeg"; const contentType = res.headers.get("Content-Type") || "audio/mpeg";
const blob = new Blob([data], { type: contentType }); const blob = new Blob([data], { type: contentType });
const blobUrl = URL.createObjectURL(blob); const blobUrl = URL.createObjectURL(blob);
M.trackBlobs.set(filename, blobUrl); M.trackBlobs.set(trackId, blobUrl);
// Persist to storage // 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 // Update download speed
if (elapsed > 0 && data.byteLength > 0) { if (elapsed > 0 && data.byteLength > 0) {
@ -73,21 +96,22 @@
return blobUrl; return blobUrl;
} catch (e) { } catch (e) {
M.bulkDownloadStarted.set(filename, false); M.bulkDownloadStarted.set(trackId, false);
return null; return null;
} }
}; };
// Fetch a single segment with range request // Fetch a single segment with range request
async function fetchSegment(i, segStart, segEnd) { 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; if (M.loadingSegments.has(i) || trackCache.has(i)) return;
M.loadingSegments.add(i); M.loadingSegments.add(i);
try { try {
const byteStart = Math.floor(segStart * M.audioBytesPerSecond); const byteStart = Math.floor(segStart * M.audioBytesPerSecond);
const byteEnd = Math.floor(segEnd * M.audioBytesPerSecond); const byteEnd = Math.floor(segEnd * M.audioBytesPerSecond);
const startTime = performance.now(); 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}` } headers: { "Range": `bytes=${byteStart}-${byteEnd}` }
}); });
const data = await res.arrayBuffer(); const data = await res.arrayBuffer();
@ -96,6 +120,13 @@
// Mark segment as cached // Mark segment as cached
trackCache.add(i); 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 // Update audio bitrate estimate
const bytesReceived = data.byteLength; const bytesReceived = data.byteLength;
const durationCovered = segEnd - segStart; const durationCovered = segEnd - segStart;
@ -115,13 +146,13 @@
// Background bulk download - runs independently // Background bulk download - runs independently
async function startBulkDownload() { async function startBulkDownload() {
const filename = M.currentFilename; const trackId = M.currentTrackId;
if (!filename || M.bulkDownloadStarted.get(filename)) return; 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 // 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 currentTime = M.audio.currentTime;
const wasPlaying = !M.audio.paused; const wasPlaying = !M.audio.paused;
M.audio.src = blobUrl; M.audio.src = blobUrl;
@ -134,12 +165,12 @@
let prefetching = false; let prefetching = false;
M.prefetchSegments = async function() { 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; prefetching = true;
const segmentDur = M.serverTrackDuration / M.SEGMENTS; const segmentDur = M.serverTrackDuration / M.SEGMENTS;
const missingSegments = []; 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) // Find all missing segments (not in audio buffer AND not in our cache)
for (let i = 0; i < M.SEGMENTS; i++) { for (let i = 0; i < M.SEGMENTS; i++) {
@ -161,7 +192,7 @@
if (missingSegments.length > 0) { if (missingSegments.length > 0) {
// Fast connection: also start bulk download in background // 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 startBulkDownload(); // Fire and forget
} }
// Always fetch segments one at a time for seek support // Always fetch segments one at a time for seek support

18
public/controls.js vendored
View File

@ -16,7 +16,7 @@
// Toggle play/pause // Toggle play/pause
function togglePlayback() { function togglePlayback() {
if (!M.currentFilename) return; if (!M.currentTrackId) return;
if (M.synced) { if (M.synced) {
if (M.ws && M.ws.readyState === WebSocket.OPEN) { if (M.ws && M.ws.readyState === WebSocket.OPEN) {
@ -25,7 +25,7 @@
} else { } else {
if (M.audio.paused) { if (M.audio.paused) {
if (!M.audio.src) { 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.currentTime = M.localTimestamp;
} }
M.audio.play(); M.audio.play();
@ -49,15 +49,17 @@
body: JSON.stringify({ index: newIndex }) body: JSON.stringify({ index: newIndex })
}); });
if (res.status === 403) M.flashPermissionDenied(); if (res.status === 403) M.flashPermissionDenied();
if (res.status === 400) console.warn("Jump failed: 400 - newIndex:", newIndex, "playlist length:", M.playlist.length);
} else { } else {
const track = M.playlist[newIndex]; const track = M.playlist[newIndex];
const trackId = track.id || track.filename;
M.currentIndex = newIndex; M.currentIndex = newIndex;
M.currentFilename = track.filename; M.currentTrackId = trackId;
M.serverTrackDuration = track.duration; 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(); M.loadingSegments.clear();
const cachedUrl = await M.loadTrackBlob(track.filename); const cachedUrl = await M.loadTrackBlob(trackId);
M.audio.src = cachedUrl || M.getTrackUrl(track.filename); M.audio.src = cachedUrl || M.getTrackUrl(trackId);
M.audio.currentTime = 0; M.audio.currentTime = 0;
M.localTimestamp = 0; M.localTimestamp = 0;
M.audio.play(); M.audio.play();
@ -113,7 +115,7 @@
// Progress bar seek // Progress bar seek
M.$("#progress-container").onclick = (e) => { M.$("#progress-container").onclick = (e) => {
const dur = M.synced ? M.serverTrackDuration : (M.audio.duration || M.serverTrackDuration); 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 rect = M.$("#progress-container").getBoundingClientRect();
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const seekTime = pct * dur; const seekTime = pct * dur;
@ -126,7 +128,7 @@
}).then(res => { if (res.status === 403) M.flashPermissionDenied(); }); }).then(res => { if (res.status === 403) M.flashPermissionDenied(); });
} else { } else {
if (!M.audio.src) { if (!M.audio.src) {
M.audio.src = M.getTrackUrl(M.currentFilename); M.audio.src = M.getTrackUrl(M.currentTrackId);
} }
M.audio.currentTime = seekTime; M.audio.currentTime = seekTime;
M.localTimestamp = seekTime; M.localTimestamp = seekTime;

View File

@ -8,7 +8,7 @@ window.MusicRoom = {
// WebSocket and stream state // WebSocket and stream state
ws: null, ws: null,
currentStreamId: null, currentStreamId: null,
currentFilename: null, currentTrackId: null,
currentTitle: null, currentTitle: null,
serverTimestamp: 0, serverTimestamp: 0,
serverTrackDuration: 0, serverTrackDuration: 0,
@ -43,6 +43,7 @@ window.MusicRoom = {
trackCaches: new Map(), // Map of filename -> Set of cached segment indices trackCaches: new Map(), // Map of filename -> Set of cached segment indices
trackBlobs: new Map(), // Map of filename -> Blob URL for fully cached tracks trackBlobs: new Map(), // Map of filename -> Blob URL for fully cached tracks
bulkDownloadStarted: new Map(), bulkDownloadStarted: new Map(),
cachedTracks: new Set(), // Set of track IDs that are fully cached locally
// Download metrics // Download metrics
audioBytesPerSecond: 20000, // Audio bitrate estimate for range requests audioBytesPerSecond: 20000, // Audio bitrate estimate for range requests

View File

@ -19,8 +19,8 @@
// Initialize track storage // Initialize track storage
async function initStorage() { async function initStorage() {
await TrackStorage.init(); await TrackStorage.init();
const cached = await TrackStorage.list(); await M.updateCacheStatus();
console.log(`TrackStorage: ${cached.length} tracks cached`); console.log(`TrackStorage: ${M.cachedTracks.size} tracks cached`);
} }
// Initialize the application // Initialize the application

View File

@ -4,6 +4,105 @@
(function() { (function() {
const M = window.MusicRoom; 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 // Render the current playlist
M.renderPlaylist = function() { M.renderPlaylist = function() {
const container = M.$("#playlist"); const container = M.$("#playlist");
@ -12,15 +111,26 @@
container.innerHTML = '<div class="empty">Playlist empty</div>'; container.innerHTML = '<div class="empty">Playlist empty</div>';
return; 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) => { M.playlist.forEach((track, i) => {
const div = document.createElement("div"); 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 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) // Show remove button only for user playlists (not stream playlists)
const removeBtn = M.selectedPlaylistId ? `<span class="btn-remove" title="Remove">×</span>` : ""; const removeBtn = M.selectedPlaylistId ? `<span class="btn-remove" title="Remove">×</span>` : "";
div.innerHTML = `<span class="track-title">${title}</span><span class="track-actions">${removeBtn}<span class="duration">${M.fmt(track.duration)}</span></span>`; div.innerHTML = `<span class="cache-indicator"></span><span class="track-title">${title}</span><span class="track-actions">${removeBtn}<span class="duration">${M.fmt(track.duration)}</span></span>`;
div.querySelector(".track-title").onclick = async () => { div.querySelector(".track-title").onclick = async () => {
if (M.synced && M.currentStreamId) { if (M.synced && M.currentStreamId) {
@ -30,9 +140,10 @@
body: JSON.stringify({ index: i }) body: JSON.stringify({ index: i })
}); });
if (res.status === 403) M.flashPermissionDenied(); if (res.status === 403) M.flashPermissionDenied();
if (res.status === 400) console.warn("Jump failed: 400 - index:", i, "playlist length:", M.playlist.length);
} else { } else {
M.currentIndex = i; M.currentIndex = i;
M.currentFilename = trackId; M.currentTrackId = trackId;
M.serverTrackDuration = track.duration; M.serverTrackDuration = track.duration;
M.$("#track-title").textContent = title; M.$("#track-title").textContent = title;
M.loadingSegments.clear(); M.loadingSegments.clear();
@ -68,15 +179,16 @@
const canAdd = M.selectedPlaylistId && M.selectedPlaylistId !== "all"; const canAdd = M.selectedPlaylistId && M.selectedPlaylistId !== "all";
M.library.forEach((track) => { M.library.forEach((track) => {
const div = document.createElement("div"); 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 title = track.title?.trim() || track.filename.replace(/\.[^.]+$/, "");
const addBtn = canAdd ? `<span class="btn-add" title="Add to playlist">+</span>` : ""; const addBtn = canAdd ? `<span class="btn-add" title="Add to playlist">+</span>` : "";
div.innerHTML = `<span class="track-title">${title}</span><span class="track-actions">${addBtn}<span class="duration">${M.fmt(track.duration)}</span></span>`; div.innerHTML = `<span class="cache-indicator"></span><span class="track-title">${title}</span><span class="track-actions">${addBtn}<span class="duration">${M.fmt(track.duration)}</span></span>`;
div.querySelector(".track-title").onclick = async () => { div.querySelector(".track-title").onclick = async () => {
// Play directly from library (uses track ID) // Play directly from library (uses track ID)
if (!M.synced) { if (!M.synced) {
M.currentFilename = track.id; M.currentTrackId = track.id;
M.serverTrackDuration = track.duration; M.serverTrackDuration = track.duration;
M.$("#track-title").textContent = title; M.$("#track-title").textContent = title;
M.loadingSegments.clear(); M.loadingSegments.clear();

View File

@ -117,13 +117,28 @@
M.renderPlaylist(); M.renderPlaylist();
} }
// Cache track info for local mode // Cache track info for local mode - use track.id (content hash) as the identifier
const isNewTrack = data.track.filename !== M.currentFilename; const trackId = data.track.id || data.track.filename; // Fallback for compatibility
const isNewTrack = trackId !== M.currentTrackId;
if (isNewTrack) { if (isNewTrack) {
M.currentFilename = data.track.filename; M.currentTrackId = trackId;
M.currentTitle = data.track.title; M.currentTitle = data.track.title;
M.$("#track-title").textContent = data.track.title; M.$("#track-title").textContent = data.track.title;
M.loadingSegments.clear(); 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) { if (M.synced) {
@ -131,8 +146,8 @@
// Server is playing - ensure we're playing and synced // Server is playing - ensure we're playing and synced
if (isNewTrack || !M.audio.src) { if (isNewTrack || !M.audio.src) {
// Try cache first // Try cache first
const cachedUrl = await M.loadTrackBlob(M.currentFilename); const cachedUrl = await M.loadTrackBlob(M.currentTrackId);
M.audio.src = cachedUrl || M.getTrackUrl(M.currentFilename); M.audio.src = cachedUrl || M.getTrackUrl(M.currentTrackId);
} }
if (M.audio.paused) { if (M.audio.paused) {
M.audio.currentTime = data.currentTimestamp; M.audio.currentTime = data.currentTimestamp;

View File

@ -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 { 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; } .btn-submit-playlist:hover { background: #3a5a4a; }
#library, #playlist { flex: 1; overflow-y: auto; } #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; } #library .track:hover, #playlist .track:hover { background: #222; }
#playlist .track.active { background: #2a4a3a; color: #4e8; } #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-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 { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
.track-actions .duration { color: #666; font-size: 0.8rem; } .track-actions .duration { color: #666; font-size: 0.8rem; }

View File

@ -98,17 +98,20 @@
const segments = M.$("#buffer-bar").children; const segments = M.$("#buffer-bar").children;
const segmentDur = dur / M.SEGMENTS; const segmentDur = dur / M.SEGMENTS;
let availableCount = 0; let availableCount = 0;
const trackCache = M.getTrackCache(M.currentTrackId);
for (let i = 0; i < M.SEGMENTS; i++) { for (let i = 0; i < M.SEGMENTS; i++) {
const segStart = i * segmentDur; const segStart = i * segmentDur;
const segEnd = (i + 1) * segmentDur; const segEnd = (i + 1) * segmentDur;
const trackCache = M.getTrackCache(M.currentFilename);
let available = trackCache.has(i); // Check our cache first let available = trackCache.has(i); // Check our cache first
if (!available) { if (!available) {
// Check browser's native buffer
for (let j = 0; j < M.audio.buffered.length; j++) { for (let j = 0; j < M.audio.buffered.length; j++) {
const bufStart = M.audio.buffered.start(j); const bufStart = M.audio.buffered.start(j);
const bufEnd = M.audio.buffered.end(j); const bufEnd = M.audio.buffered.end(j);
if (bufStart <= segStart && bufEnd >= segEnd) { if (bufStart <= segStart && bufEnd >= segEnd) {
available = true; available = true;
// Sync browser buffer to our trackCache
trackCache.add(i);
break; break;
} }
} }
@ -121,6 +124,11 @@
if (shouldBeLoading !== isLoading) segments[i].classList.toggle("loading", shouldBeLoading); 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 // Update download speed display
const kbps = M.downloadSpeed > 0 ? M.downloadSpeed * 8 / 1000 : 0; const kbps = M.downloadSpeed > 0 ? M.downloadSpeed * 8 / 1000 : 0;
const bufferPct = Math.round(availableCount / M.SEGMENTS * 100); const bufferPct = Math.round(availableCount / M.SEGMENTS * 100);
@ -137,8 +145,19 @@
// Prefetch loop (1s interval) // Prefetch loop (1s interval)
setInterval(() => { setInterval(() => {
if (M.currentFilename && M.audio.src) { if (M.currentTrackId && M.audio.src) {
M.prefetchSegments(); M.prefetchSegments();
} }
}, 1000); }, 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);
})(); })();

View File

@ -64,16 +64,31 @@ const library = new Library(MUSIC_DIR);
// Load track metadata (for stream initialization - converts library tracks to stream format) // Load track metadata (for stream initialization - converts library tracks to stream format)
async function loadTrack(filename: string): Promise<Track> { async function loadTrack(filename: string): Promise<Track> {
// 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); const filepath = join(MUSIC_DIR, filename);
try { try {
const metadata = await parseFile(filepath, { duration: true }); const metadata = await parseFile(filepath, { duration: true });
const duration = metadata.format.duration ?? 0; const duration = metadata.format.duration ?? 0;
const title = metadata.common.title?.trim() || filename.replace(/\.[^.]+$/, ""); const title = metadata.common.title?.trim() || filename.replace(/\.[^.]+$/, "");
console.log(`Track: ${filename} | duration: ${duration}s | title: ${title}`); console.log(`Track: ${filename} | duration: ${duration}s | title: ${title} | id: (no hash)`);
return { filename, title, duration }; return { id: filename, filename, title, duration }; // Use filename as fallback ID
} catch (e) { } catch (e) {
console.warn(`Could not read metadata for ${filename}, skipping`); 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" }, headers: { "Content-Type": "text/css" },
}); });
} }
if (path === "/trackStorage.js") { // Serve JS files from public directory
return new Response(file(join(PUBLIC_DIR, "trackStorage.js")), { if (path.endsWith(".js")) {
headers: { "Content-Type": "application/javascript" }, const jsFile = file(join(PUBLIC_DIR, path.slice(1)));
}); if (await jsFile.exists()) {
} return new Response(jsFile, {
if (path === "/app.js") { headers: { "Content-Type": "application/javascript" },
return new Response(file(join(PUBLIC_DIR, "app.js")), { });
headers: { "Content-Type": "application/javascript" }, }
});
} }
return new Response("Not found", { status: 404 }); return new Response("Not found", { status: 404 });

View File

@ -1,8 +1,9 @@
import type { ServerWebSocket } from "bun"; import type { ServerWebSocket } from "bun";
export interface Track { export interface Track {
filename: string; id: string; // Content hash (primary key)
title: string; filename: string; // Original filename
title: string; // Display title
duration: number; duration: number;
} }