removed playlists as a concept added queues
This commit is contained in:
parent
12856b439c
commit
df47dd46c3
18
AGENTS.md
18
AGENTS.md
|
|
@ -1,6 +1,6 @@
|
|||
# MusicRoom
|
||||
|
||||
Synchronized music streaming server built with Bun. Manages "channels" (virtual radio stations) that play through playlists sequentially. Clients connect, receive now-playing state, download audio, and sync playback locally.
|
||||
Synchronized music streaming server built with Bun. Manages "channels" (virtual radio stations) that play through queues sequentially. Clients connect, receive now-playing state, download audio, and sync playback locally.
|
||||
|
||||
## Architecture
|
||||
|
||||
|
|
@ -20,7 +20,7 @@ interface Channel {
|
|||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
playlist: Track[];
|
||||
queue: Track[]; // tracks in playback order
|
||||
currentIndex: number;
|
||||
startedAt: number;
|
||||
paused: boolean;
|
||||
|
|
@ -36,14 +36,14 @@ interface Channel {
|
|||
|
||||
### Client
|
||||
|
||||
The player's role is simple: **play an arbitrary track by ID**. It does not manage playlists or sync logic directly.
|
||||
The player's role is simple: **play an arbitrary track by ID**. It does not manage queues or sync logic directly.
|
||||
|
||||
- Receives track ID and timestamp from server via WebSocket
|
||||
- Downloads audio from `/api/tracks/:id`
|
||||
- Syncs playback position to server timestamp
|
||||
- Caches tracks locally in IndexedDB
|
||||
|
||||
### Library & Playlist Views
|
||||
### Library & Queue Views
|
||||
|
||||
Both views display tracks with real-time status indicators:
|
||||
- **Green bar**: Track is fully cached locally (in IndexedDB)
|
||||
|
|
@ -105,17 +105,15 @@ DELETE /api/channels/:id → Delete a channel (not default)
|
|||
WS /api/channels/:id/ws → WebSocket: pushes state on connect and changes
|
||||
GET /api/tracks/:id → Serve audio by content hash (supports Range)
|
||||
GET /api/library → List all tracks with id, filename, title, duration
|
||||
GET /api/playlists → List user playlists
|
||||
POST /api/playlists → Create playlist
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
### Server
|
||||
- **server.ts** — Bun entrypoint. HTTP routes and WebSocket handlers.
|
||||
- **channel.ts** — `Channel` class. Playlist, current index, time tracking, broadcasting.
|
||||
- **channel.ts** — `Channel` class. Queue, current index, time tracking, broadcasting.
|
||||
- **library.ts** — `Library` class. Scans music directory, computes content hashes.
|
||||
- **db.ts** — SQLite database for users, sessions, playlists, tracks.
|
||||
- **db.ts** — SQLite database for users, sessions, tracks.
|
||||
|
||||
### Client (public/)
|
||||
- **core.js** — Global state namespace (`window.MusicRoom`)
|
||||
|
|
@ -123,7 +121,7 @@ POST /api/playlists → Create playlist
|
|||
- **audioCache.js** — Track caching, segment downloads, prefetching
|
||||
- **channelSync.js** — WebSocket connection, server sync, channel switching
|
||||
- **ui.js** — Progress bar, buffer display, UI updates
|
||||
- **playlist.js** — Playlist/library rendering, cache status
|
||||
- **queue.js** — Queue/library rendering, cache status
|
||||
- **controls.js** — Play, pause, seek, volume
|
||||
- **auth.js** — Login, signup, logout
|
||||
- **init.js** — App initialization
|
||||
|
|
@ -151,7 +149,7 @@ interface Track {
|
|||
channelId: string,
|
||||
description: string,
|
||||
paused: boolean,
|
||||
playlist: Track[],
|
||||
queue: Track[], // alias: playlist (for compatibility)
|
||||
currentIndex: number,
|
||||
listenerCount: number,
|
||||
listeners: string[], // usernames of connected users
|
||||
|
|
|
|||
176
db.ts
176
db.ts
|
|
@ -68,34 +68,6 @@ db.run(`
|
|||
)
|
||||
`);
|
||||
|
||||
// User playlists
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS playlists (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
visibility TEXT DEFAULT 'private' CHECK (visibility IN ('private', 'public', 'registered')),
|
||||
created_at INTEGER DEFAULT (unixepoch()),
|
||||
updated_at INTEGER DEFAULT (unixepoch())
|
||||
)
|
||||
`);
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS playlist_tracks (
|
||||
playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
|
||||
track_id TEXT NOT NULL REFERENCES tracks(id),
|
||||
position INTEGER NOT NULL,
|
||||
added_at INTEGER DEFAULT (unixepoch()),
|
||||
added_by INTEGER REFERENCES users(id),
|
||||
PRIMARY KEY (playlist_id, position)
|
||||
)
|
||||
`);
|
||||
|
||||
db.run(`CREATE INDEX IF NOT EXISTS idx_playlist_tracks_track ON playlist_tracks(track_id)`);
|
||||
db.run(`CREATE INDEX IF NOT EXISTS idx_playlists_owner ON playlists(owner_id)`);
|
||||
db.run(`CREATE INDEX IF NOT EXISTS idx_playlists_visibility ON playlists(visibility)`);
|
||||
|
||||
// Types
|
||||
export interface User {
|
||||
id: number;
|
||||
|
|
@ -133,24 +105,6 @@ export interface Track {
|
|||
created_at: number;
|
||||
}
|
||||
|
||||
export interface Playlist {
|
||||
id: string;
|
||||
owner_id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
visibility: "private" | "public" | "registered";
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface PlaylistTrack {
|
||||
playlist_id: string;
|
||||
track_id: string;
|
||||
position: number;
|
||||
added_at: number;
|
||||
added_by: number | null;
|
||||
}
|
||||
|
||||
// User functions
|
||||
export async function createUser(username: string, password: string): Promise<User> {
|
||||
const password_hash = await Bun.password.hash(password);
|
||||
|
|
@ -339,133 +293,3 @@ export function getTrack(id: string): Track | null {
|
|||
export function getAllTracks(): Track[] {
|
||||
return db.query("SELECT * FROM tracks ORDER BY title").all() as Track[];
|
||||
}
|
||||
|
||||
// Playlist functions
|
||||
export function createPlaylist(
|
||||
ownerId: number,
|
||||
name: string,
|
||||
visibility: Playlist["visibility"] = "private",
|
||||
description?: string
|
||||
): Playlist {
|
||||
const id = crypto.randomUUID();
|
||||
db.query(`
|
||||
INSERT INTO playlists (id, owner_id, name, description, visibility)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(id, ownerId, name, description ?? null, visibility);
|
||||
return getPlaylist(id)!;
|
||||
}
|
||||
|
||||
export function getPlaylist(id: string): Playlist | null {
|
||||
return db.query("SELECT * FROM playlists WHERE id = ?").get(id) as Playlist | null;
|
||||
}
|
||||
|
||||
export function updatePlaylist(
|
||||
id: string,
|
||||
updates: Partial<Pick<Playlist, "name" | "description" | "visibility">>
|
||||
): void {
|
||||
const fields: string[] = [];
|
||||
const values: any[] = [];
|
||||
|
||||
if (updates.name !== undefined) { fields.push("name = ?"); values.push(updates.name); }
|
||||
if (updates.description !== undefined) { fields.push("description = ?"); values.push(updates.description); }
|
||||
if (updates.visibility !== undefined) { fields.push("visibility = ?"); values.push(updates.visibility); }
|
||||
|
||||
if (fields.length === 0) return;
|
||||
|
||||
fields.push("updated_at = unixepoch()");
|
||||
values.push(id);
|
||||
|
||||
db.query(`UPDATE playlists SET ${fields.join(", ")} WHERE id = ?`).run(...values);
|
||||
}
|
||||
|
||||
export function deletePlaylist(id: string): void {
|
||||
db.query("DELETE FROM playlists WHERE id = ?").run(id);
|
||||
}
|
||||
|
||||
export function getUserPlaylists(userId: number): Playlist[] {
|
||||
return db.query("SELECT * FROM playlists WHERE owner_id = ? ORDER BY updated_at DESC").all(userId) as Playlist[];
|
||||
}
|
||||
|
||||
export function getVisiblePlaylists(userId: number | null, isGuest: boolean): Playlist[] {
|
||||
if (userId === null || isGuest) {
|
||||
// Guests/unauthenticated: public only
|
||||
return db.query("SELECT * FROM playlists WHERE visibility = 'public' ORDER BY updated_at DESC").all() as Playlist[];
|
||||
}
|
||||
// Logged in: own + public + registered
|
||||
return db.query(`
|
||||
SELECT * FROM playlists
|
||||
WHERE owner_id = ? OR visibility IN ('public', 'registered')
|
||||
ORDER BY updated_at DESC
|
||||
`).all(userId) as Playlist[];
|
||||
}
|
||||
|
||||
export function canViewPlaylist(playlist: Playlist, userId: number | null, isGuest: boolean): boolean {
|
||||
if (playlist.visibility === "public") return true;
|
||||
if (userId === null) return false;
|
||||
if (playlist.owner_id === userId) return true;
|
||||
if (playlist.visibility === "registered" && !isGuest) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function canEditPlaylist(playlist: Playlist, userId: number | null): boolean {
|
||||
return userId !== null && playlist.owner_id === userId;
|
||||
}
|
||||
|
||||
// Playlist track functions
|
||||
export function getPlaylistTracks(playlistId: string): (Track & { position: number })[] {
|
||||
return db.query(`
|
||||
SELECT t.*, pt.position
|
||||
FROM playlist_tracks pt
|
||||
JOIN tracks t ON t.id = pt.track_id
|
||||
WHERE pt.playlist_id = ?
|
||||
ORDER BY pt.position
|
||||
`).all(playlistId) as (Track & { position: number })[];
|
||||
}
|
||||
|
||||
export function addTrackToPlaylist(playlistId: string, trackId: string, addedBy: number | null, position?: number): void {
|
||||
// If no position, add at end
|
||||
if (position === undefined) {
|
||||
const max = db.query("SELECT MAX(position) as max FROM playlist_tracks WHERE playlist_id = ?").get(playlistId) as { max: number | null };
|
||||
position = (max?.max ?? -1) + 1;
|
||||
} else {
|
||||
// Shift existing tracks
|
||||
db.query("UPDATE playlist_tracks SET position = position + 1 WHERE playlist_id = ? AND position >= ?").run(playlistId, position);
|
||||
}
|
||||
|
||||
db.query(`
|
||||
INSERT INTO playlist_tracks (playlist_id, track_id, position, added_by)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(playlistId, trackId, position, addedBy);
|
||||
|
||||
db.query("UPDATE playlists SET updated_at = unixepoch() WHERE id = ?").run(playlistId);
|
||||
}
|
||||
|
||||
export function removeTrackFromPlaylist(playlistId: string, position: number): void {
|
||||
db.query("DELETE FROM playlist_tracks WHERE playlist_id = ? AND position = ?").run(playlistId, position);
|
||||
// Shift remaining tracks down
|
||||
db.query("UPDATE playlist_tracks SET position = position - 1 WHERE playlist_id = ? AND position > ?").run(playlistId, position);
|
||||
db.query("UPDATE playlists SET updated_at = unixepoch() WHERE id = ?").run(playlistId);
|
||||
}
|
||||
|
||||
export function reorderPlaylistTrack(playlistId: string, fromPos: number, toPos: number): void {
|
||||
if (fromPos === toPos) return;
|
||||
|
||||
// Get the track being moved
|
||||
const track = db.query("SELECT track_id FROM playlist_tracks WHERE playlist_id = ? AND position = ?").get(playlistId, fromPos) as { track_id: string } | null;
|
||||
if (!track) return;
|
||||
|
||||
// Remove from old position
|
||||
db.query("DELETE FROM playlist_tracks WHERE playlist_id = ? AND position = ?").run(playlistId, fromPos);
|
||||
|
||||
if (fromPos < toPos) {
|
||||
// Moving down: shift tracks between fromPos+1 and toPos up
|
||||
db.query("UPDATE playlist_tracks SET position = position - 1 WHERE playlist_id = ? AND position > ? AND position <= ?").run(playlistId, fromPos, toPos);
|
||||
} else {
|
||||
// Moving up: shift tracks between toPos and fromPos-1 down
|
||||
db.query("UPDATE playlist_tracks SET position = position + 1 WHERE playlist_id = ? AND position >= ? AND position < ?").run(playlistId, toPos, fromPos);
|
||||
}
|
||||
|
||||
// Insert at new position
|
||||
db.query("INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES (?, ?, ?)").run(playlistId, track.track_id, toPos);
|
||||
db.query("UPDATE playlists SET updated_at = unixepoch() WHERE id = ?").run(playlistId);
|
||||
}
|
||||
|
|
|
|||
BIN
musicroom.db
BIN
musicroom.db
Binary file not shown.
|
|
@ -116,13 +116,4 @@
|
|||
M.updateAuthUI();
|
||||
}
|
||||
};
|
||||
|
||||
// New playlist button
|
||||
M.$("#btn-new-playlist").onclick = () => {
|
||||
if (!M.currentUser || M.currentUser.isGuest) {
|
||||
alert("Sign in to create playlists");
|
||||
return;
|
||||
}
|
||||
M.createNewPlaylist();
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -183,10 +183,6 @@
|
|||
if (data.library) {
|
||||
M.library = data.library;
|
||||
M.renderLibrary();
|
||||
if (M.selectedPlaylistId === "all") {
|
||||
M.playlist = [...M.library];
|
||||
M.renderPlaylist();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -195,10 +191,6 @@
|
|||
if (data.library) {
|
||||
M.library = data.library;
|
||||
M.renderLibrary();
|
||||
if (M.selectedPlaylistId === "all") {
|
||||
M.playlist = [...M.library];
|
||||
M.renderPlaylist();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,10 +35,8 @@ window.MusicRoom = {
|
|||
currentUser: null,
|
||||
serverStatus: null,
|
||||
|
||||
// Library and playlists
|
||||
// Library (all discovered tracks)
|
||||
library: [],
|
||||
userPlaylists: [],
|
||||
selectedPlaylistId: null,
|
||||
|
||||
// Caching state
|
||||
prefetchController: null,
|
||||
|
|
|
|||
|
|
@ -58,16 +58,9 @@
|
|||
<h3>Library</h3>
|
||||
<div id="library"></div>
|
||||
</div>
|
||||
<div id="playlist-panel">
|
||||
<h3 id="playlist-title">Playlist</h3>
|
||||
<div id="playlist"></div>
|
||||
</div>
|
||||
<div id="playlists-panel">
|
||||
<div class="panel-header">
|
||||
<h3>Playlists</h3>
|
||||
<button id="btn-new-playlist" title="New playlist">+</button>
|
||||
</div>
|
||||
<div id="playlists-list"></div>
|
||||
<div id="queue-panel">
|
||||
<h3 id="queue-title">Queue</h3>
|
||||
<div id="queue"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -105,7 +98,7 @@
|
|||
<script src="audioCache.js"></script>
|
||||
<script src="channelSync.js"></script>
|
||||
<script src="ui.js"></script>
|
||||
<script src="playlist.js"></script>
|
||||
<script src="queue.js"></script>
|
||||
<script src="controls.js"></script>
|
||||
<script src="auth.js"></script>
|
||||
<script src="init.js"></script>
|
||||
|
|
|
|||
|
|
@ -26,11 +26,9 @@
|
|||
// Initialize the application
|
||||
Promise.all([initStorage(), loadServerStatus()]).then(async () => {
|
||||
await M.loadLibrary();
|
||||
M.loadSelectedPlaylist("all"); // Default to All Tracks
|
||||
await M.loadCurrentUser();
|
||||
if (M.currentUser) {
|
||||
M.loadChannels();
|
||||
M.loadPlaylists();
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -1,426 +0,0 @@
|
|||
// MusicRoom - Playlist module
|
||||
// Playlist CRUD, library rendering, playlist rendering
|
||||
|
||||
(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");
|
||||
container.innerHTML = "";
|
||||
if (M.playlist.length === 0) {
|
||||
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");
|
||||
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="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.currentChannelId) {
|
||||
const res = await fetch("/api/channels/" + M.currentChannelId + "/jump", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
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.currentTrackId = trackId;
|
||||
M.serverTrackDuration = track.duration;
|
||||
M.$("#track-title").textContent = title;
|
||||
M.loadingSegments.clear();
|
||||
const cachedUrl = await M.loadTrackBlob(trackId);
|
||||
M.audio.src = cachedUrl || M.getTrackUrl(trackId);
|
||||
M.audio.currentTime = 0;
|
||||
M.localTimestamp = 0;
|
||||
M.audio.play();
|
||||
M.renderPlaylist();
|
||||
}
|
||||
};
|
||||
|
||||
const removeEl = div.querySelector(".btn-remove");
|
||||
if (removeEl) {
|
||||
removeEl.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
M.removeTrackFromCurrentPlaylist(i);
|
||||
};
|
||||
}
|
||||
|
||||
container.appendChild(div);
|
||||
});
|
||||
};
|
||||
|
||||
// Render the library
|
||||
M.renderLibrary = function() {
|
||||
const container = M.$("#library");
|
||||
container.innerHTML = "";
|
||||
if (M.library.length === 0) {
|
||||
container.innerHTML = '<div class="empty">No tracks discovered</div>';
|
||||
return;
|
||||
}
|
||||
const canAdd = M.selectedPlaylistId && M.selectedPlaylistId !== "all";
|
||||
M.library.forEach((track) => {
|
||||
const div = document.createElement("div");
|
||||
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="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.currentTrackId = track.id;
|
||||
M.serverTrackDuration = track.duration;
|
||||
M.$("#track-title").textContent = title;
|
||||
M.loadingSegments.clear();
|
||||
const cachedUrl = await M.loadTrackBlob(track.id);
|
||||
M.audio.src = cachedUrl || M.getTrackUrl(track.id);
|
||||
M.audio.currentTime = 0;
|
||||
M.localTimestamp = 0;
|
||||
M.audio.play();
|
||||
}
|
||||
};
|
||||
|
||||
const addBtnEl = div.querySelector(".btn-add");
|
||||
if (addBtnEl) {
|
||||
addBtnEl.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
M.addTrackToCurrentPlaylist(track.id);
|
||||
};
|
||||
}
|
||||
|
||||
container.appendChild(div);
|
||||
});
|
||||
};
|
||||
|
||||
// Load library from server
|
||||
M.loadLibrary = async function() {
|
||||
try {
|
||||
const res = await fetch("/api/library");
|
||||
M.library = await res.json();
|
||||
M.renderLibrary();
|
||||
} catch (e) {
|
||||
console.warn("Failed to load library");
|
||||
}
|
||||
};
|
||||
|
||||
// Load user playlists from server
|
||||
M.loadPlaylists = async function() {
|
||||
try {
|
||||
const res = await fetch("/api/playlists");
|
||||
M.userPlaylists = await res.json();
|
||||
M.renderPlaylistSelector();
|
||||
} catch (e) {
|
||||
console.warn("Failed to load playlists");
|
||||
}
|
||||
};
|
||||
|
||||
// Render playlist selector sidebar
|
||||
M.renderPlaylistSelector = function() {
|
||||
const list = M.$("#playlists-list");
|
||||
if (!list) return;
|
||||
list.innerHTML = "";
|
||||
// Add "All Tracks" as default option
|
||||
const allItem = document.createElement("div");
|
||||
allItem.className = "playlist-item" + (M.selectedPlaylistId === "all" ? " active" : "");
|
||||
allItem.textContent = "All Tracks";
|
||||
allItem.onclick = () => M.loadSelectedPlaylist("all");
|
||||
list.appendChild(allItem);
|
||||
// Add user playlists
|
||||
for (const pl of M.userPlaylists) {
|
||||
const item = document.createElement("div");
|
||||
item.className = "playlist-item" + (pl.id === M.selectedPlaylistId ? " active" : "");
|
||||
item.textContent = pl.name;
|
||||
item.onclick = () => M.loadSelectedPlaylist(pl.id);
|
||||
list.appendChild(item);
|
||||
}
|
||||
// Update playlist panel title
|
||||
const titleEl = M.$("#playlist-title");
|
||||
if (M.selectedPlaylistId === "all") {
|
||||
titleEl.textContent = "Playlist - All Tracks";
|
||||
} else if (M.selectedPlaylistId) {
|
||||
const pl = M.userPlaylists.find(p => p.id === M.selectedPlaylistId);
|
||||
titleEl.textContent = pl ? "Playlist - " + pl.name : "Playlist";
|
||||
} else {
|
||||
titleEl.textContent = "Playlist";
|
||||
}
|
||||
};
|
||||
|
||||
// Load and display a specific playlist
|
||||
M.loadSelectedPlaylist = async function(playlistId) {
|
||||
if (!playlistId) {
|
||||
M.playlist = [];
|
||||
M.selectedPlaylistId = null;
|
||||
M.renderPlaylist();
|
||||
M.renderPlaylistSelector();
|
||||
M.renderLibrary();
|
||||
return;
|
||||
}
|
||||
|
||||
// If synced to a channel, broadcast playlist change to server
|
||||
if (M.synced && M.currentChannelId) {
|
||||
try {
|
||||
const res = await fetch("/api/channels/" + M.currentChannelId + "/playlist", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ playlistId: playlistId === "all" ? "all" : parseInt(playlistId) })
|
||||
});
|
||||
if (res.status === 403) {
|
||||
M.flashPermissionDenied();
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error("Failed to set playlist");
|
||||
M.selectedPlaylistId = playlistId;
|
||||
M.renderPlaylistSelector();
|
||||
// Server will broadcast new playlist via WebSocket
|
||||
return;
|
||||
} catch (e) {
|
||||
console.warn("Failed to set channel playlist:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Local mode - load playlist directly
|
||||
if (playlistId === "all") {
|
||||
// Use library as playlist
|
||||
M.playlist = [...M.library];
|
||||
M.selectedPlaylistId = "all";
|
||||
M.currentIndex = 0;
|
||||
M.renderPlaylist();
|
||||
M.renderPlaylistSelector();
|
||||
M.renderLibrary();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch("/api/playlists/" + playlistId);
|
||||
if (!res.ok) throw new Error("Failed to load playlist");
|
||||
const data = await res.json();
|
||||
M.playlist = data.tracks || [];
|
||||
M.selectedPlaylistId = playlistId;
|
||||
M.currentIndex = 0;
|
||||
M.renderPlaylist();
|
||||
M.renderPlaylistSelector();
|
||||
M.renderLibrary();
|
||||
} catch (e) {
|
||||
console.warn("Failed to load playlist:", e);
|
||||
}
|
||||
};
|
||||
|
||||
// Create a new playlist
|
||||
M.createNewPlaylist = async function() {
|
||||
const header = M.$("#playlists-panel .panel-header");
|
||||
const btn = M.$("#btn-new-playlist");
|
||||
|
||||
// Already in edit mode?
|
||||
if (header.querySelector(".new-playlist-input")) return;
|
||||
|
||||
// Hide button, show input
|
||||
btn.style.display = "none";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.className = "new-playlist-input";
|
||||
input.placeholder = "Playlist name...";
|
||||
|
||||
const submit = document.createElement("button");
|
||||
submit.className = "btn-submit-playlist";
|
||||
submit.textContent = "›";
|
||||
|
||||
header.appendChild(input);
|
||||
header.appendChild(submit);
|
||||
input.focus();
|
||||
|
||||
const cleanup = () => {
|
||||
input.remove();
|
||||
submit.remove();
|
||||
btn.style.display = "";
|
||||
};
|
||||
|
||||
const doCreate = async () => {
|
||||
const name = input.value.trim();
|
||||
if (!name) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch("/api/playlists", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, visibility: "private" })
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to create playlist");
|
||||
const pl = await res.json();
|
||||
await M.loadPlaylists();
|
||||
M.selectedPlaylistId = pl.id;
|
||||
M.renderPlaylistSelector();
|
||||
await M.loadSelectedPlaylist(pl.id);
|
||||
} catch (e) {
|
||||
alert("Failed to create playlist");
|
||||
}
|
||||
cleanup();
|
||||
};
|
||||
|
||||
submit.onclick = doCreate;
|
||||
input.onkeydown = (e) => {
|
||||
if (e.key === "Enter") doCreate();
|
||||
if (e.key === "Escape") cleanup();
|
||||
};
|
||||
input.onblur = (e) => {
|
||||
// Delay to allow click on submit button
|
||||
setTimeout(() => {
|
||||
if (document.activeElement !== submit) cleanup();
|
||||
}, 100);
|
||||
};
|
||||
};
|
||||
|
||||
// Add a track to current playlist
|
||||
M.addTrackToCurrentPlaylist = async function(trackId) {
|
||||
if (!M.selectedPlaylistId || M.selectedPlaylistId === "all") {
|
||||
alert("Select or create a playlist first");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch("/api/playlists/" + M.selectedPlaylistId + "/tracks", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ trackIds: [trackId] })
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to add track");
|
||||
await M.loadSelectedPlaylist(M.selectedPlaylistId);
|
||||
} catch (e) {
|
||||
console.warn("Failed to add track:", e);
|
||||
}
|
||||
};
|
||||
|
||||
// Remove a track from current playlist
|
||||
M.removeTrackFromCurrentPlaylist = async function(position) {
|
||||
if (!M.selectedPlaylistId || M.selectedPlaylistId === "all") return;
|
||||
try {
|
||||
const res = await fetch("/api/playlists/" + M.selectedPlaylistId + "/tracks/" + position, {
|
||||
method: "DELETE"
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to remove track");
|
||||
await M.loadSelectedPlaylist(M.selectedPlaylistId);
|
||||
} catch (e) {
|
||||
console.warn("Failed to remove track:", e);
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
|
@ -0,0 +1,206 @@
|
|||
// MusicRoom - Queue module
|
||||
// Queue rendering and library display
|
||||
|
||||
(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 queue 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("=== Queue Tracks ===");
|
||||
M.playlist.forEach((t, i) => {
|
||||
const id = t.id || t.filename;
|
||||
console.log(` [${i}] ${t.title?.slice(0, 30)} | id: ${id?.slice(0, 12)}... | cached: ${M.cachedTracks.has(id)}`);
|
||||
});
|
||||
console.log("=== Cached Track IDs ===");
|
||||
[...M.cachedTracks].forEach(id => {
|
||||
console.log(` ${id.slice(0, 20)}...`);
|
||||
});
|
||||
};
|
||||
|
||||
// Debug: detailed info for a specific track
|
||||
M.debugTrack = function(index) {
|
||||
const track = M.playlist[index];
|
||||
if (!track) {
|
||||
console.log("[Debug] No track at index", index);
|
||||
return;
|
||||
}
|
||||
const id = track.id || track.filename;
|
||||
console.log("[Debug Track]", {
|
||||
index,
|
||||
title: track.title,
|
||||
id,
|
||||
idPrefix: id?.slice(0, 16),
|
||||
inCachedTracks: M.cachedTracks.has(id),
|
||||
inTrackCaches: M.trackCaches.has(id),
|
||||
segmentCount: M.trackCaches.get(id)?.size || 0,
|
||||
inTrackBlobs: M.trackBlobs.has(id),
|
||||
bulkStarted: M.bulkDownloadStarted.get(id)
|
||||
});
|
||||
};
|
||||
|
||||
// Clear all caches (for debugging)
|
||||
M.clearAllCaches = async function() {
|
||||
await TrackStorage.clear();
|
||||
M.cachedTracks.clear();
|
||||
M.trackCaches.clear();
|
||||
M.trackBlobs.clear();
|
||||
M.bulkDownloadStarted.clear();
|
||||
M.renderQueue();
|
||||
M.renderLibrary();
|
||||
console.log("[Cache] All caches cleared. Refresh the page.");
|
||||
};
|
||||
|
||||
// Render the current queue (channel's playlist)
|
||||
M.renderQueue = function() {
|
||||
const container = M.$("#queue");
|
||||
if (!container) return;
|
||||
container.innerHTML = "";
|
||||
if (M.playlist.length === 0) {
|
||||
container.innerHTML = '<div class="empty">Queue 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("[Queue 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");
|
||||
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(/\.[^.]+$/, "");
|
||||
|
||||
div.innerHTML = `<span class="cache-indicator"></span><span class="track-title">${title}</span><span class="track-actions"><span class="duration">${M.fmt(track.duration)}</span></span>`;
|
||||
|
||||
div.querySelector(".track-title").onclick = async () => {
|
||||
if (M.synced && M.currentChannelId) {
|
||||
const res = await fetch("/api/channels/" + M.currentChannelId + "/jump", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ index: i })
|
||||
});
|
||||
if (res.status === 403) M.flashPermissionDenied();
|
||||
if (res.status === 400) console.warn("Jump failed: 400 - index:", i, "queue length:", M.playlist.length);
|
||||
} else {
|
||||
M.currentIndex = i;
|
||||
M.currentTrackId = trackId;
|
||||
M.serverTrackDuration = track.duration;
|
||||
M.$("#track-title").textContent = title;
|
||||
M.loadingSegments.clear();
|
||||
const cachedUrl = await M.loadTrackBlob(trackId);
|
||||
M.audio.src = cachedUrl || M.getTrackUrl(trackId);
|
||||
M.audio.currentTime = 0;
|
||||
M.localTimestamp = 0;
|
||||
M.audio.play();
|
||||
M.renderQueue();
|
||||
}
|
||||
};
|
||||
|
||||
container.appendChild(div);
|
||||
});
|
||||
};
|
||||
|
||||
// Alias for backward compatibility
|
||||
M.renderPlaylist = M.renderQueue;
|
||||
|
||||
// Render the library
|
||||
M.renderLibrary = function() {
|
||||
const container = M.$("#library");
|
||||
if (!container) return;
|
||||
container.innerHTML = "";
|
||||
if (M.library.length === 0) {
|
||||
container.innerHTML = '<div class="empty">No tracks discovered</div>';
|
||||
return;
|
||||
}
|
||||
M.library.forEach((track) => {
|
||||
const div = document.createElement("div");
|
||||
const isCached = M.cachedTracks.has(track.id);
|
||||
div.className = "track" + (isCached ? " cached" : " not-cached");
|
||||
const title = track.title?.trim() || track.filename.replace(/\.[^.]+$/, "");
|
||||
div.innerHTML = `<span class="cache-indicator"></span><span class="track-title">${title}</span><span class="track-actions"><span class="duration">${M.fmt(track.duration)}</span></span>`;
|
||||
|
||||
div.querySelector(".track-title").onclick = async () => {
|
||||
// Play directly from library (uses track ID) - only in local mode
|
||||
if (!M.synced) {
|
||||
M.currentTrackId = track.id;
|
||||
M.serverTrackDuration = track.duration;
|
||||
M.$("#track-title").textContent = title;
|
||||
M.loadingSegments.clear();
|
||||
const cachedUrl = await M.loadTrackBlob(track.id);
|
||||
M.audio.src = cachedUrl || M.getTrackUrl(track.id);
|
||||
M.audio.currentTime = 0;
|
||||
M.localTimestamp = 0;
|
||||
M.audio.play();
|
||||
}
|
||||
};
|
||||
|
||||
container.appendChild(div);
|
||||
});
|
||||
};
|
||||
|
||||
// Load library from server
|
||||
M.loadLibrary = async function() {
|
||||
try {
|
||||
const res = await fetch("/api/library");
|
||||
M.library = await res.json();
|
||||
M.renderLibrary();
|
||||
} catch (e) {
|
||||
console.warn("Failed to load library");
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
|
@ -29,25 +29,20 @@ h3 { font-size: 0.9rem; color: #666; margin-bottom: 0.5rem; text-transform: uppe
|
|||
#channels-list .listener { font-size: 0.75rem; color: #aaa; padding: 0.15rem 0; position: relative; }
|
||||
#channels-list .listener::before { content: ""; position: absolute; left: -0.5rem; top: 50%; width: 0.4rem; height: 2px; background: #333; }
|
||||
#channels-list .listener-mult { color: #666; font-size: 0.65rem; }
|
||||
#library-panel, #playlist-panel { flex: 2; background: #1a1a1a; border-radius: 8px; padding: 1rem; display: flex; flex-direction: column; min-height: 300px; max-height: 60vh; }
|
||||
#playlists-panel { flex: 1; background: #1a1a1a; border-radius: 8px; padding: 1rem; display: flex; flex-direction: column; min-height: 300px; max-height: 60vh; min-width: 180px; max-width: 250px; }
|
||||
#playlists-list { flex: 1; overflow-y: auto; }
|
||||
#playlists-list .playlist-item { padding: 0.5rem 0.75rem; border-radius: 4px; cursor: pointer; font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
#playlists-list .playlist-item:hover { background: #222; }
|
||||
#playlists-list .playlist-item.active { background: #2a3a4a; color: #8cf; }
|
||||
#playlist-title { margin: 0 0 0.5rem 0; }
|
||||
#library-panel, #queue-panel { flex: 2; background: #1a1a1a; border-radius: 8px; padding: 1rem; display: flex; flex-direction: column; min-height: 300px; max-height: 60vh; }
|
||||
#queue-title { margin: 0 0 0.5rem 0; }
|
||||
.panel-header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }
|
||||
.panel-header h3 { margin: 0; flex-shrink: 0; }
|
||||
.panel-header select { flex: 1; background: #222; color: #eee; border: 1px solid #333; padding: 0.3rem 0.5rem; border-radius: 4px; font-size: 0.8rem; }
|
||||
.panel-header button { background: #333; color: #eee; border: 1px solid #444; width: 28px; height: 28px; border-radius: 4px; cursor: pointer; font-size: 1rem; line-height: 1; padding: 0; }
|
||||
.panel-header button:hover { background: #444; }
|
||||
.new-playlist-input, .new-channel-input { flex: 1; background: #222; color: #eee; border: 1px solid #333; padding: 0.3rem 0.5rem; border-radius: 4px; font-size: 0.8rem; }
|
||||
.btn-submit-playlist, .btn-submit-channel { 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, .btn-submit-channel: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; position: relative; }
|
||||
#library .track:hover, #playlist .track:hover { background: #222; }
|
||||
#playlist .track.active { background: #2a4a3a; color: #4e8; }
|
||||
.new-channel-input { flex: 1; background: #222; color: #eee; border: 1px solid #333; padding: 0.3rem 0.5rem; border-radius: 4px; font-size: 0.8rem; }
|
||||
.btn-submit-channel { 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-channel:hover { background: #3a5a4a; }
|
||||
#library, #queue { flex: 1; overflow-y: auto; }
|
||||
#library .track, #queue .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, #queue .track:hover { background: #222; }
|
||||
#queue .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; }
|
||||
|
|
|
|||
201
server.ts
201
server.ts
|
|
@ -16,18 +16,6 @@ import {
|
|||
grantPermission,
|
||||
revokePermission,
|
||||
findUserById,
|
||||
createPlaylist,
|
||||
getPlaylist,
|
||||
updatePlaylist,
|
||||
deletePlaylist,
|
||||
getVisiblePlaylists,
|
||||
canViewPlaylist,
|
||||
canEditPlaylist,
|
||||
getPlaylistTracks,
|
||||
addTrackToPlaylist,
|
||||
removeTrackFromPlaylist,
|
||||
reorderPlaylistTrack,
|
||||
type Playlist,
|
||||
} from "./db";
|
||||
import {
|
||||
getUser,
|
||||
|
|
@ -355,166 +343,6 @@ serve({
|
|||
return Response.json(tracks, { headers });
|
||||
}
|
||||
|
||||
// Playlist API: list playlists
|
||||
if (path === "/api/playlists" && req.method === "GET") {
|
||||
const { user, headers } = getOrCreateUser(req, server);
|
||||
if (!user) {
|
||||
return Response.json({ error: "Authentication required" }, { status: 401 });
|
||||
}
|
||||
const playlists = getVisiblePlaylists(user.id, user.is_guest);
|
||||
return Response.json(playlists, { headers });
|
||||
}
|
||||
|
||||
// Playlist API: create playlist
|
||||
if (path === "/api/playlists" && req.method === "POST") {
|
||||
const { user } = getOrCreateUser(req, server);
|
||||
if (!user) {
|
||||
return Response.json({ error: "Authentication required" }, { status: 401 });
|
||||
}
|
||||
if (user.is_guest) {
|
||||
return Response.json({ error: "Guests cannot create playlists" }, { status: 403 });
|
||||
}
|
||||
try {
|
||||
const { name, description, visibility } = await req.json();
|
||||
if (!name || typeof name !== "string" || name.trim().length === 0) {
|
||||
return Response.json({ error: "Name is required" }, { status: 400 });
|
||||
}
|
||||
const playlist = createPlaylist(user.id, name.trim(), visibility || "private", description);
|
||||
return Response.json(playlist, { status: 201 });
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid request" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// Playlist API: get/update/delete single playlist
|
||||
const playlistMatch = path.match(/^\/api\/playlists\/([^/]+)$/);
|
||||
if (playlistMatch) {
|
||||
const playlistId = playlistMatch[1];
|
||||
const { user, headers } = getOrCreateUser(req, server);
|
||||
if (!user) {
|
||||
return Response.json({ error: "Authentication required" }, { status: 401 });
|
||||
}
|
||||
const playlist = getPlaylist(playlistId);
|
||||
if (!playlist) {
|
||||
return Response.json({ error: "Playlist not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (req.method === "GET") {
|
||||
if (!canViewPlaylist(playlist, user.id, user.is_guest)) {
|
||||
return Response.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
const tracks = getPlaylistTracks(playlistId);
|
||||
return Response.json({ ...playlist, tracks }, { headers });
|
||||
}
|
||||
|
||||
if (req.method === "PUT") {
|
||||
if (!canEditPlaylist(playlist, user.id)) {
|
||||
return Response.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
try {
|
||||
const { name, description, visibility } = await req.json();
|
||||
updatePlaylist(playlistId, { name, description, visibility });
|
||||
const updated = getPlaylist(playlistId);
|
||||
return Response.json(updated);
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid request" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
if (req.method === "DELETE") {
|
||||
if (!canEditPlaylist(playlist, user.id)) {
|
||||
return Response.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
deletePlaylist(playlistId);
|
||||
return Response.json({ success: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Playlist API: add tracks
|
||||
const playlistTracksMatch = path.match(/^\/api\/playlists\/([^/]+)\/tracks$/);
|
||||
if (playlistTracksMatch && req.method === "POST") {
|
||||
const playlistId = playlistTracksMatch[1];
|
||||
const { user } = getOrCreateUser(req, server);
|
||||
if (!user) {
|
||||
return Response.json({ error: "Authentication required" }, { status: 401 });
|
||||
}
|
||||
const playlist = getPlaylist(playlistId);
|
||||
if (!playlist) {
|
||||
return Response.json({ error: "Playlist not found" }, { status: 404 });
|
||||
}
|
||||
if (!canEditPlaylist(playlist, user.id)) {
|
||||
return Response.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
try {
|
||||
const { trackIds, position } = await req.json();
|
||||
if (!Array.isArray(trackIds) || trackIds.length === 0) {
|
||||
return Response.json({ error: "trackIds array required" }, { status: 400 });
|
||||
}
|
||||
for (const trackId of trackIds) {
|
||||
if (!library.getTrack(trackId)) {
|
||||
return Response.json({ error: `Track not found: ${trackId}` }, { status: 404 });
|
||||
}
|
||||
}
|
||||
let insertPos = position;
|
||||
for (const trackId of trackIds) {
|
||||
addTrackToPlaylist(playlistId, trackId, user.id, insertPos);
|
||||
if (insertPos !== undefined) insertPos++;
|
||||
}
|
||||
const tracks = getPlaylistTracks(playlistId);
|
||||
return Response.json({ tracks }, { status: 201 });
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid request" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// Playlist API: remove track
|
||||
const playlistTrackMatch = path.match(/^\/api\/playlists\/([^/]+)\/tracks\/(\d+)$/);
|
||||
if (playlistTrackMatch && req.method === "DELETE") {
|
||||
const playlistId = playlistTrackMatch[1];
|
||||
const position = parseInt(playlistTrackMatch[2]);
|
||||
const { user } = getOrCreateUser(req, server);
|
||||
if (!user) {
|
||||
return Response.json({ error: "Authentication required" }, { status: 401 });
|
||||
}
|
||||
const playlist = getPlaylist(playlistId);
|
||||
if (!playlist) {
|
||||
return Response.json({ error: "Playlist not found" }, { status: 404 });
|
||||
}
|
||||
if (!canEditPlaylist(playlist, user.id)) {
|
||||
return Response.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
removeTrackFromPlaylist(playlistId, position);
|
||||
return Response.json({ success: true });
|
||||
}
|
||||
|
||||
// Playlist API: reorder tracks
|
||||
const playlistReorderMatch = path.match(/^\/api\/playlists\/([^/]+)\/tracks\/reorder$/);
|
||||
if (playlistReorderMatch && req.method === "PUT") {
|
||||
const playlistId = playlistReorderMatch[1];
|
||||
const { user } = getOrCreateUser(req, server);
|
||||
if (!user) {
|
||||
return Response.json({ error: "Authentication required" }, { status: 401 });
|
||||
}
|
||||
const playlist = getPlaylist(playlistId);
|
||||
if (!playlist) {
|
||||
return Response.json({ error: "Playlist not found" }, { status: 404 });
|
||||
}
|
||||
if (!canEditPlaylist(playlist, user.id)) {
|
||||
return Response.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
try {
|
||||
const { from, to } = await req.json();
|
||||
if (typeof from !== "number" || typeof to !== "number") {
|
||||
return Response.json({ error: "from and to positions required" }, { status: 400 });
|
||||
}
|
||||
reorderPlaylistTrack(playlistId, from, to);
|
||||
const tracks = getPlaylistTracks(playlistId);
|
||||
return Response.json({ tracks });
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid request" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// Auth: signup
|
||||
if (path === "/api/auth/signup" && req.method === "POST") {
|
||||
try {
|
||||
|
|
@ -696,35 +524,6 @@ serve({
|
|||
}
|
||||
}
|
||||
|
||||
// API: set channel playlist
|
||||
const playlistMatch = path.match(/^\/api\/channels\/([^/]+)\/playlist$/);
|
||||
if (playlistMatch && req.method === "POST") {
|
||||
const channelId = playlistMatch[1];
|
||||
const { user } = getOrCreateUser(req, server);
|
||||
if (!userHasPermission(user, "channel", channelId, "control")) {
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
const channel = channels.get(channelId);
|
||||
if (!channel) return new Response("Not found", { status: 404 });
|
||||
try {
|
||||
const body = await req.json();
|
||||
let tracks: Track[] = [];
|
||||
if (body.playlistId === "all") {
|
||||
tracks = library.getAllTracks();
|
||||
} else if (typeof body.playlistId === "number") {
|
||||
const playlist = getPlaylist(body.playlistId);
|
||||
if (!playlist) return new Response("Playlist not found", { status: 404 });
|
||||
tracks = getPlaylistTracks(body.playlistId);
|
||||
} else {
|
||||
return new Response("Invalid playlistId", { status: 400 });
|
||||
}
|
||||
channel.setPlaylist(tracks);
|
||||
return Response.json({ success: true, trackCount: tracks.length });
|
||||
} catch {
|
||||
return new Response("Invalid JSON", { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// API: serve audio file (requires auth or guest)
|
||||
// Supports both filename and track ID (sha256:...)
|
||||
const trackMatch = path.match(/^\/api\/tracks\/(.+)$/);
|
||||
|
|
|
|||
Loading…
Reference in New Issue