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
|
.DS_Store
|
||||||
|
|
||||||
tmp/
|
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`
|
- 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
|
||||||
|
|
|
||||||
BIN
musicroom.db
BIN
musicroom.db
Binary file not shown.
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
23
public/ui.js
23
public/ui.js
|
|
@ -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);
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
38
server.ts
38
server.ts
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue