saving
This commit is contained in:
parent
8fdd64de61
commit
a910ec195f
|
|
@ -34,3 +34,5 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
|||
.DS_Store
|
||||
|
||||
tmp/
|
||||
library_cache.db
|
||||
|
||||
|
|
|
|||
39
AGENTS.md
39
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
|
||||
|
|
|
|||
BIN
musicroom.db
BIN
musicroom.db
Binary file not shown.
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = '<div class="empty">Playlist empty</div>';
|
||||
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 ? `<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 () => {
|
||||
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 ? `<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 () => {
|
||||
// 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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
23
public/ui.js
23
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);
|
||||
})();
|
||||
|
|
|
|||
32
server.ts
32
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<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);
|
||||
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")), {
|
||||
// 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" },
|
||||
});
|
||||
}
|
||||
if (path === "/app.js") {
|
||||
return new Response(file(join(PUBLIC_DIR, "app.js")), {
|
||||
headers: { "Content-Type": "application/javascript" },
|
||||
});
|
||||
}
|
||||
|
||||
return new Response("Not found", { status: 404 });
|
||||
|
|
|
|||
Loading…
Reference in New Issue