diff --git a/db.ts b/db.ts index 15a6f93..c680d41 100644 --- a/db.ts +++ b/db.ts @@ -55,6 +55,47 @@ db.run(` ) `); +// Content-addressed tracks table +db.run(` + CREATE TABLE IF NOT EXISTS tracks ( + id TEXT PRIMARY KEY, + title TEXT, + artist TEXT, + album TEXT, + duration REAL NOT NULL, + size INTEGER NOT NULL, + created_at INTEGER DEFAULT (unixepoch()) + ) +`); + +// 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; @@ -82,6 +123,34 @@ export interface Permission { permission: string; } +export interface Track { + id: string; + title: string | null; + artist: string | null; + album: string | null; + duration: number; + size: number; + 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 { const password_hash = await Bun.password.hash(password); @@ -250,3 +319,153 @@ export function getUserSessions(userId: number): Omit[] { // Cleanup expired sessions periodically setInterval(() => deleteExpiredSessions(), 60 * 60 * 1000); // Every hour + +// Track functions +export function upsertTrack(track: Omit): void { + db.query(` + INSERT INTO tracks (id, title, artist, album, duration, size) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + title = COALESCE(excluded.title, title), + artist = COALESCE(excluded.artist, artist), + album = COALESCE(excluded.album, album) + `).run(track.id, track.title, track.artist, track.album, track.duration, track.size); +} + +export function getTrack(id: string): Track | null { + return db.query("SELECT * FROM tracks WHERE id = ?").get(id) as 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> +): 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); +} diff --git a/library.ts b/library.ts new file mode 100644 index 0000000..078e294 --- /dev/null +++ b/library.ts @@ -0,0 +1,329 @@ +import { Database } from "bun:sqlite"; +import { createHash } from "crypto"; +import { watch, type FSWatcher } from "fs"; +import { readdir, stat } from "fs/promises"; +import { join, relative } from "path"; +import { parseFile } from "music-metadata"; +import { upsertTrack, type Track } from "./db"; + +const HASH_CHUNK_SIZE = 64 * 1024; // 64KB +const AUDIO_EXTENSIONS = new Set([".mp3", ".ogg", ".flac", ".wav", ".m4a", ".aac", ".opus", ".wma"]); + +export interface LibraryTrack extends Track { + filename: string; + filepath: string; + available: boolean; +} + +interface CacheEntry { + path: string; + track_id: string; + size: number; + mtime_ms: number; + cached_at: number; +} + +type LibraryEventType = "added" | "removed" | "changed"; +type LibraryEventCallback = (track: LibraryTrack) => void; + +export class Library { + private cacheDb: Database; + private musicDir: string; + private trackMap = new Map(); // trackId -> filepath + private trackInfo = new Map(); // trackId -> full info + private watcher: FSWatcher | null = null; + private eventListeners = new Map>(); + private pendingFiles = new Map(); // filepath -> debounce timer + + constructor(musicDir: string, cacheDbPath: string = "./library_cache.db") { + this.musicDir = musicDir; + this.cacheDb = new Database(cacheDbPath); + this.initCacheDb(); + } + + private initCacheDb(): void { + this.cacheDb.run(` + CREATE TABLE IF NOT EXISTS file_cache ( + path TEXT PRIMARY KEY, + track_id TEXT NOT NULL, + size INTEGER NOT NULL, + mtime_ms INTEGER NOT NULL, + cached_at INTEGER DEFAULT (unixepoch()) + ) + `); + this.cacheDb.run(`CREATE INDEX IF NOT EXISTS idx_file_cache_track ON file_cache(track_id)`); + } + + async computeTrackId(filePath: string): Promise { + const file = Bun.file(filePath); + const size = file.size; + + // Read first 64KB + const chunk = await file.slice(0, HASH_CHUNK_SIZE).arrayBuffer(); + + // Get duration from metadata + let duration = 0; + try { + const metadata = await parseFile(filePath, { duration: true }); + duration = metadata.format.duration || 0; + } catch { + // If metadata parsing fails, use size only + } + + // Hash: size + duration + first 64KB + const hash = createHash("sha256"); + hash.update(`${size}:${duration.toFixed(3)}:`); + hash.update(new Uint8Array(chunk)); + + return "sha256:" + hash.digest("hex").substring(0, 32); + } + + private getCacheEntry(relativePath: string): CacheEntry | null { + return this.cacheDb.query("SELECT * FROM file_cache WHERE path = ?").get(relativePath) as CacheEntry | null; + } + + private setCacheEntry(relativePath: string, trackId: string, size: number, mtimeMs: number): void { + this.cacheDb.query(` + INSERT OR REPLACE INTO file_cache (path, track_id, size, mtime_ms, cached_at) + VALUES (?, ?, ?, ?, unixepoch()) + `).run(relativePath, trackId, size, mtimeMs); + } + + private removeCacheEntry(relativePath: string): void { + this.cacheDb.query("DELETE FROM file_cache WHERE path = ?").run(relativePath); + } + + private isAudioFile(filename: string): boolean { + const ext = filename.substring(filename.lastIndexOf(".")).toLowerCase(); + return AUDIO_EXTENSIONS.has(ext); + } + + private async processFile(filePath: string): Promise { + const relativePath = relative(this.musicDir, filePath); + const filename = filePath.substring(filePath.lastIndexOf("/") + 1).replace(/\\/g, "/").split("/").pop() || filePath; + + try { + const fileStat = await stat(filePath); + const size = fileStat.size; + const mtimeMs = fileStat.mtimeMs; + + // Check cache + const cached = this.getCacheEntry(relativePath); + let trackId: string; + + if (cached && cached.size === size && cached.mtime_ms === Math.floor(mtimeMs)) { + trackId = cached.track_id; + } else { + // Compute new hash + trackId = await this.computeTrackId(filePath); + this.setCacheEntry(relativePath, trackId, size, Math.floor(mtimeMs)); + } + + // Get metadata + let title = filename.replace(/\.[^.]+$/, ""); + let artist: string | null = null; + let album: string | null = null; + let duration = 0; + + try { + const metadata = await parseFile(filePath, { duration: true }); + title = metadata.common.title || title; + artist = metadata.common.artist || null; + album = metadata.common.album || null; + duration = metadata.format.duration || 0; + } catch { + // Use defaults + } + + const track: LibraryTrack = { + id: trackId, + filename, + filepath: filePath, + title, + artist, + album, + duration, + size, + available: true, + created_at: Math.floor(Date.now() / 1000), + }; + + // Upsert to main database + upsertTrack({ + id: trackId, + title, + artist, + album, + duration, + size, + }); + + return track; + } catch (e) { + console.error(`[Library] Failed to process ${filePath}:`, e); + return null; + } + } + + async scan(): Promise { + console.log(`[Library] Scanning ${this.musicDir}...`); + const startTime = Date.now(); + let processed = 0; + let cached = 0; + + const scanDir = async (dir: string) => { + const entries = await readdir(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = join(dir, entry.name); + + if (entry.isDirectory()) { + await scanDir(fullPath); + } else if (entry.isFile() && this.isAudioFile(entry.name)) { + const relativePath = relative(this.musicDir, fullPath); + const cacheEntry = this.getCacheEntry(relativePath); + + // Quick check if cache is valid + if (cacheEntry) { + try { + const fileStat = await stat(fullPath); + if (cacheEntry.size === fileStat.size && cacheEntry.mtime_ms === Math.floor(fileStat.mtimeMs)) { + // Cache hit - reuse existing data + const existingTrack = this.trackInfo.get(cacheEntry.track_id); + if (!existingTrack) { + // Need metadata but skip hashing + const track = await this.processFile(fullPath); + if (track) { + this.trackMap.set(track.id, fullPath); + this.trackInfo.set(track.id, track); + } + } else { + this.trackMap.set(cacheEntry.track_id, fullPath); + } + cached++; + continue; + } + } catch {} + } + + const track = await this.processFile(fullPath); + if (track) { + this.trackMap.set(track.id, fullPath); + this.trackInfo.set(track.id, track); + processed++; + } + } + } + }; + + await scanDir(this.musicDir); + + const elapsed = Date.now() - startTime; + console.log(`[Library] Scan complete: ${this.trackMap.size} tracks (${processed} processed, ${cached} cached) in ${elapsed}ms`); + } + + startWatching(): void { + if (this.watcher) return; + + console.log(`[Library] Watching ${this.musicDir} for changes...`); + + this.watcher = watch(this.musicDir, { recursive: true }, async (event, filename) => { + if (!filename) return; + + // Normalize path separators + const normalizedFilename = filename.replace(/\\/g, "/"); + if (!this.isAudioFile(normalizedFilename)) return; + + const fullPath = join(this.musicDir, filename); + + // Debounce: wait 5 seconds after last change before processing + const existing = this.pendingFiles.get(fullPath); + if (existing) clearTimeout(existing); + + this.pendingFiles.set(fullPath, setTimeout(async () => { + this.pendingFiles.delete(fullPath); + await this.processFileChange(fullPath); + }, 5000)); + }); + } + + private async processFileChange(fullPath: string): Promise { + try { + const exists = await Bun.file(fullPath).exists(); + + if (exists) { + // File added or modified + const track = await this.processFile(fullPath); + if (track) { + const wasNew = !this.trackMap.has(track.id); + this.trackMap.set(track.id, fullPath); + this.trackInfo.set(track.id, track); + this.emit(wasNew ? "added" : "changed", track); + console.log(`[Library] ${wasNew ? "Added" : "Updated"}: ${track.title}`); + } + } else { + // File deleted + const relativePath = relative(this.musicDir, fullPath); + const cacheEntry = this.getCacheEntry(relativePath); + + if (cacheEntry) { + const track = this.trackInfo.get(cacheEntry.track_id); + if (track) { + track.available = false; + this.trackMap.delete(cacheEntry.track_id); + this.emit("removed", track); + console.log(`[Library] Removed: ${track.title}`); + } + this.removeCacheEntry(relativePath); + } + } + } catch (e) { + console.error(`[Library] Watch error for ${fullPath}:`, e); + } + } + + stopWatching(): void { + if (this.watcher) { + this.watcher.close(); + this.watcher = null; + } + } + + // Event handling + on(event: LibraryEventType, callback: LibraryEventCallback): void { + if (!this.eventListeners.has(event)) { + this.eventListeners.set(event, new Set()); + } + this.eventListeners.get(event)!.add(callback); + } + + off(event: LibraryEventType, callback: LibraryEventCallback): void { + this.eventListeners.get(event)?.delete(callback); + } + + private emit(event: LibraryEventType, track: LibraryTrack): void { + this.eventListeners.get(event)?.forEach((cb) => cb(track)); + } + + // Accessors + getTrack(id: string): LibraryTrack | null { + return this.trackInfo.get(id) || null; + } + + getFilePath(id: string): string | null { + return this.trackMap.get(id) || null; + } + + getAllTracks(): LibraryTrack[] { + return Array.from(this.trackInfo.values()).filter((t) => t.available); + } + + getTrackCount(): number { + return this.trackMap.size; + } + + // Check if a track ID is available (file exists) + isAvailable(id: string): boolean { + return this.trackMap.has(id); + } +} diff --git a/musicroom.db b/musicroom.db index 4fd9579..1389c47 100644 Binary files a/musicroom.db and b/musicroom.db differ diff --git a/public/app.js b/public/app.js index 4232185..7e64a62 100644 --- a/public/app.js +++ b/public/app.js @@ -17,6 +17,8 @@ let currentUser = null; let serverStatus = null; let library = []; + let userPlaylists = []; + let selectedPlaylistId = null; let prefetchController = null; let loadingSegments = new Set(); let trackCaches = new Map(); // Map of filename -> Set of cached segment indices @@ -26,6 +28,19 @@ let recentDownloads = []; // Track recent downloads for speed calculation const $ = (s) => document.querySelector(s); + + // Toast notifications + function showToast(message, duration = 4000) { + const container = $("#toast-container"); + const toast = document.createElement("div"); + toast.className = "toast"; + toast.textContent = message; + container.appendChild(toast); + setTimeout(() => { + toast.classList.add("fade-out"); + setTimeout(() => toast.remove(), 300); + }, duration); + } const SEGMENTS = 20; const STORAGE_KEY = "musicroom_volume"; @@ -34,6 +49,9 @@ if (savedVolume !== null) { audio.volume = parseFloat(savedVolume); $("#volume").value = savedVolume; + } else { + // No saved volume - sync audio to slider's default value + audio.volume = parseFloat($("#volume").value); } // Create buffer segments @@ -424,7 +442,36 @@ currentStreamId = id; const proto = location.protocol === "https:" ? "wss:" : "ws:"; ws = new WebSocket(proto + "//" + location.host + "/api/streams/" + id + "/ws"); - ws.onmessage = (e) => handleUpdate(JSON.parse(e.data)); + ws.onmessage = (e) => { + const data = JSON.parse(e.data); + // Handle library updates + if (data.type === "track_added") { + showToast(`"${data.track.title}" is now available`); + if (data.library) { + library = data.library; + renderLibrary(); + if (selectedPlaylistId === "all") { + playlist = [...library]; + renderPlaylist(); + } + } + return; + } + if (data.type === "track_removed") { + showToast(`"${data.track.title}" was removed`); + if (data.library) { + library = data.library; + renderLibrary(); + if (selectedPlaylistId === "all") { + playlist = [...library]; + renderPlaylist(); + } + } + return; + } + // Normal stream update + handleUpdate(data); + }; ws.onclose = () => { synced = false; ws = null; @@ -461,9 +508,14 @@ playlist.forEach((track, i) => { const div = document.createElement("div"); div.className = "track" + (i === currentIndex ? " active" : ""); - const title = track.title?.trim() || track.filename.replace(/\.[^.]+$/, ""); - div.innerHTML = `${title}${fmt(track.duration)}`; - div.onclick = async () => { + const title = track.title?.trim() || (track.id || track.filename || "Unknown").replace(/\.[^.]+$/, ""); + const trackId = track.id || track.filename; + + // Show remove button only for user playlists (not stream playlists) + const removeBtn = selectedPlaylistId ? `×` : ""; + div.innerHTML = `${title}${removeBtn}${fmt(track.duration)}`; + + div.querySelector(".track-title").onclick = async () => { if (synced && currentStreamId) { const res = await fetch("/api/streams/" + currentStreamId + "/jump", { method: "POST", @@ -473,20 +525,27 @@ if (res.status === 403) flashPermissionDenied(); } else { currentIndex = i; - currentFilename = track.filename; + currentFilename = trackId; serverTrackDuration = track.duration; $("#track-title").textContent = title; - // Reset loading state for new track (cache persists) loadingSegments.clear(); - // Try to load from cache first - const cachedUrl = await loadTrackBlob(track.filename); - audio.src = cachedUrl || getTrackUrl(track.filename); + const cachedUrl = await loadTrackBlob(trackId); + audio.src = cachedUrl || getTrackUrl(trackId); audio.currentTime = 0; localTimestamp = 0; audio.play(); renderPlaylist(); } }; + + const removeEl = div.querySelector(".btn-remove"); + if (removeEl) { + removeEl.onclick = (e) => { + e.stopPropagation(); + removeTrackFromCurrentPlaylist(i); + }; + } + container.appendChild(div); }); } @@ -498,25 +557,37 @@ container.innerHTML = '
No tracks discovered
'; return; } + const canAdd = selectedPlaylistId && selectedPlaylistId !== "all"; library.forEach((track) => { const div = document.createElement("div"); div.className = "track"; const title = track.title?.trim() || track.filename.replace(/\.[^.]+$/, ""); - div.innerHTML = `${title}${fmt(track.duration)}`; - div.onclick = async () => { - // In local mode, play directly from library + const addBtn = canAdd ? `+` : ""; + div.innerHTML = `${title}${addBtn}${fmt(track.duration)}`; + + div.querySelector(".track-title").onclick = async () => { + // Play directly from library (uses track ID) if (!synced) { - currentFilename = track.filename; + currentFilename = track.id; // Use track ID instead of filename serverTrackDuration = track.duration; $("#track-title").textContent = title; loadingSegments.clear(); - const cachedUrl = await loadTrackBlob(track.filename); - audio.src = cachedUrl || getTrackUrl(track.filename); + const cachedUrl = await loadTrackBlob(track.id); + audio.src = cachedUrl || getTrackUrl(track.id); audio.currentTime = 0; localTimestamp = 0; audio.play(); } }; + + const addBtnEl = div.querySelector(".btn-add"); + if (addBtnEl) { + addBtnEl.onclick = (e) => { + e.stopPropagation(); + addTrackToCurrentPlaylist(track.id); + }; + } + container.appendChild(div); }); } @@ -531,6 +602,177 @@ } } + async function loadPlaylists() { + try { + const res = await fetch("/api/playlists"); + userPlaylists = await res.json(); + renderPlaylistSelector(); + } catch (e) { + console.warn("Failed to load playlists"); + } + } + + function renderPlaylistSelector() { + const list = $("#playlists-list"); + if (!list) return; + list.innerHTML = ""; + // Add "All Tracks" as default option + const allItem = document.createElement("div"); + allItem.className = "playlist-item" + (selectedPlaylistId === "all" ? " active" : ""); + allItem.textContent = "All Tracks"; + allItem.onclick = () => loadSelectedPlaylist("all"); + list.appendChild(allItem); + // Add user playlists + for (const pl of userPlaylists) { + const item = document.createElement("div"); + item.className = "playlist-item" + (pl.id === selectedPlaylistId ? " active" : ""); + item.textContent = pl.name; + item.onclick = () => loadSelectedPlaylist(pl.id); + list.appendChild(item); + } + // Update playlist panel title + const titleEl = $("#playlist-title"); + if (selectedPlaylistId === "all") { + titleEl.textContent = "Playlist - All Tracks"; + } else if (selectedPlaylistId) { + const pl = userPlaylists.find(p => p.id === selectedPlaylistId); + titleEl.textContent = pl ? "Playlist - " + pl.name : "Playlist"; + } else { + titleEl.textContent = "Playlist"; + } + } + + async function loadSelectedPlaylist(playlistId) { + if (!playlistId) { + playlist = []; + selectedPlaylistId = null; + renderPlaylist(); + renderPlaylistSelector(); + renderLibrary(); + return; + } + if (playlistId === "all") { + // Use library as playlist + playlist = [...library]; + selectedPlaylistId = "all"; + currentIndex = 0; + renderPlaylist(); + renderPlaylistSelector(); + 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(); + playlist = data.tracks || []; + selectedPlaylistId = playlistId; + currentIndex = 0; + renderPlaylist(); + renderPlaylistSelector(); + renderLibrary(); + } catch (e) { + console.warn("Failed to load playlist:", e); + } + } + + async function createNewPlaylist() { + const header = $("#playlists-panel .panel-header"); + const btn = $("#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 loadPlaylists(); + selectedPlaylistId = pl.id; + renderPlaylistSelector(); + await 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); + }; + } + + async function addTrackToCurrentPlaylist(trackId) { + if (!selectedPlaylistId || selectedPlaylistId === "all") { + alert("Select or create a playlist first"); + return; + } + try { + const res = await fetch("/api/playlists/" + 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 loadSelectedPlaylist(selectedPlaylistId); + } catch (e) { + console.warn("Failed to add track:", e); + } + } + + async function removeTrackFromCurrentPlaylist(position) { + if (!selectedPlaylistId || selectedPlaylistId === "all") return; + try { + const res = await fetch("/api/playlists/" + selectedPlaylistId + "/tracks/" + position, { + method: "DELETE" + }); + if (!res.ok) throw new Error("Failed to remove track"); + await loadSelectedPlaylist(selectedPlaylistId); + } catch (e) { + console.warn("Failed to remove track:", e); + } + } + async function handleUpdate(data) { if (!data.track) { $("#track-title").textContent = "No tracks"; @@ -844,6 +1086,15 @@ } }; + // Playlist selector handlers + $("#btn-new-playlist").onclick = () => { + if (!currentUser || currentUser.isGuest) { + alert("Sign in to create playlists"); + return; + } + createNewPlaylist(); + }; + // Fetch server status async function loadServerStatus() { try { @@ -864,10 +1115,13 @@ } // Initialize - Promise.all([initStorage(), loadServerStatus()]).then(() => { - loadLibrary(); - loadCurrentUser().then(() => { - if (currentUser) loadStreams(); - }); + Promise.all([initStorage(), loadServerStatus()]).then(async () => { + await loadLibrary(); + loadSelectedPlaylist("all"); // Default to All Tracks + await loadCurrentUser(); + if (currentUser) { + loadStreams(); + loadPlaylists(); + } }); })(); diff --git a/public/index.html b/public/index.html index 8651708..63995cd 100644 --- a/public/index.html +++ b/public/index.html @@ -52,9 +52,16 @@
-

Playlist

+

Playlist

+
+
+

Playlists

+ +
+
+
@@ -83,6 +90,7 @@
+
diff --git a/public/styles.css b/public/styles.css index 89bcab8..c4606de 100644 --- a/public/styles.css +++ b/public/styles.css @@ -17,12 +17,32 @@ h3 { font-size: 0.9rem; color: #666; margin-bottom: 0.5rem; text-transform: uppe /* Main content - library and playlist */ #main-content { display: flex; gap: 1rem; flex: 1; min-height: 0; margin-bottom: 1rem; } -#library-panel, #playlist-panel { flex: 1; background: #1a1a1a; border-radius: 8px; padding: 1rem; display: flex; flex-direction: column; min-height: 300px; max-height: 60vh; } +#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; } +.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 { 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 { background: #2a4a3a; color: #4e8; border: 1px solid #3a5a4a; width: 28px; height: 28px; border-radius: 4px; cursor: pointer; font-size: 1.2rem; line-height: 1; padding: 0; } +.btn-submit-playlist:hover { background: #3a5a4a; } #library, #playlist { flex: 1; overflow-y: auto; } -#library .track, #playlist .track { padding: 0.5rem 0.75rem; border-radius: 4px; cursor: pointer; font-size: 0.9rem; display: flex; justify-content: space-between; } +#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:hover, #playlist .track:hover { background: #222; } #playlist .track.active { background: #2a4a3a; color: #4e8; } -#library .track .duration, #playlist .track .duration { color: #666; font-size: 0.8rem; } +.track-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.track-actions { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; } +.track-actions .duration { color: #666; font-size: 0.8rem; } +.track-actions .btn-add, .track-actions .btn-remove { width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; background: #333; border-radius: 3px; font-size: 0.9rem; opacity: 0; transition: opacity 0.2s; } +.track:hover .btn-add, .track:hover .btn-remove { opacity: 0.6; } +.track-actions .btn-add:hover, .track-actions .btn-remove:hover { opacity: 1; background: #444; } +.track-actions .btn-remove { color: #e44; } /* Player bar */ #player-bar { background: #1a1a1a; border-radius: 8px; padding: 1rem; display: flex; gap: 1rem; align-items: center; } @@ -91,3 +111,10 @@ button:hover { background: #333; } ::-webkit-scrollbar-track { background-color: #111; border-radius: 4px; } ::-webkit-scrollbar-thumb { background-color: #333; border-radius: 4px; } ::-webkit-scrollbar-thumb:hover { background-color: #555; } + +/* Toast notifications */ +#toast-container { position: fixed; top: 1rem; left: 1rem; display: flex; flex-direction: column; gap: 0.5rem; z-index: 1000; pointer-events: none; } +.toast { background: #1a3a2a; color: #4e8; padding: 0.75rem 1rem; border-radius: 6px; border: 1px solid #4e8; box-shadow: 0 4px 12px rgba(0,0,0,0.4); font-size: 0.9rem; animation: toast-in 0.3s ease-out; max-width: 300px; } +.toast.fade-out { animation: toast-out 0.3s ease-in forwards; } +@keyframes toast-in { from { opacity: 0; transform: translateX(-20px); } to { opacity: 1; transform: translateX(0); } } +@keyframes toast-out { from { opacity: 1; transform: translateX(0); } to { opacity: 0; transform: translateX(-20px); } } diff --git a/server.ts b/server.ts index c34e411..dfc227e 100644 --- a/server.ts +++ b/server.ts @@ -17,6 +17,18 @@ import { grantPermission, revokePermission, findUserById, + createPlaylist, + getPlaylist, + updatePlaylist, + deletePlaylist, + getVisiblePlaylists, + canViewPlaylist, + canEditPlaylist, + getPlaylistTracks, + addTrackToPlaylist, + removeTrackFromPlaylist, + reorderPlaylistTrack, + type Playlist, } from "./db"; import { getUser, @@ -26,6 +38,7 @@ import { clearSessionCookie, getClientInfo, } from "./auth"; +import { Library } from "./library"; // Load config interface Config { @@ -46,7 +59,10 @@ const PUBLIC_DIR = join(import.meta.dir, "public"); console.log(`Config loaded: port=${config.port}, musicDir=${MUSIC_DIR}, allowGuests=${config.allowGuests}`); -// Load track metadata +// Initialize library +const library = new Library(MUSIC_DIR); + +// Load track metadata (for stream initialization - converts library tracks to stream format) async function loadTrack(filename: string): Promise { const filepath = join(MUSIC_DIR, filename); try { @@ -71,19 +87,17 @@ async function discoverTracks(): Promise { } } -// Store all library tracks -let libraryTracks: Track[] = []; - -// Initialize streams and library +// Initialize streams async function init(): Promise> { + // Scan library first + await library.scan(); + library.startWatching(); + const playlistData = await file(PLAYLIST_PATH).json(); const streams = new Map(); - // Load all tracks in music directory for library + // Get all discovered files for streams const allFiles = await discoverTracks(); - const allTracks = await Promise.all(allFiles.map(loadTrack)); - libraryTracks = allTracks.filter((t) => t.duration > 0); - console.log(`Library: ${libraryTracks.length} tracks discovered`); for (const cfg of playlistData.streams) { let trackFiles: string[] = cfg.tracks; @@ -107,6 +121,45 @@ async function init(): Promise> { const streams = await init(); +// Broadcast to all connected clients across all streams +function broadcastToAll(message: object) { + const data = JSON.stringify(message); + for (const stream of streams.values()) { + for (const ws of stream.clients) { + ws.send(data); + } + } +} + +// Listen for library changes and notify clients +library.on("added", (track) => { + console.log(`New track detected: ${track.title}`); + const allTracks = library.getAllTracks().map(t => ({ + id: t.id, + title: t.title, + duration: t.duration + })); + broadcastToAll({ + type: "track_added", + track: { id: track.id, title: track.title, duration: track.duration }, + library: allTracks + }); +}); + +library.on("removed", (track) => { + console.log(`Track removed: ${track.title}`); + const allTracks = library.getAllTracks().map(t => ({ + id: t.id, + title: t.title, + duration: t.duration + })); + broadcastToAll({ + type: "track_removed", + track: { id: track.id, title: track.title }, + library: allTracks + }); +}); + // Tick interval: advance tracks when needed, broadcast every 30s let tickCount = 0; setInterval(() => { @@ -203,7 +256,176 @@ serve({ if (!user) { return Response.json({ error: "Authentication required" }, { status: 401 }); } - return Response.json(libraryTracks, { headers }); + const tracks = library.getAllTracks().map(t => ({ + id: t.id, + filename: t.filename, + title: t.title, + artist: t.artist, + album: t.album, + duration: t.duration, + available: t.available, + })); + 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 @@ -396,15 +618,27 @@ serve({ } // API: serve audio file (requires auth or guest) + // Supports both filename and track ID (sha256:...) const trackMatch = path.match(/^\/api\/tracks\/(.+)$/); if (trackMatch) { const { user } = getOrCreateUser(req, server); if (!user) { return Response.json({ error: "Authentication required" }, { status: 401 }); } - const filename = decodeURIComponent(trackMatch[1]); - if (filename.includes("..")) return new Response("Forbidden", { status: 403 }); - const filepath = join(MUSIC_DIR, filename); + const identifier = decodeURIComponent(trackMatch[1]); + if (identifier.includes("..")) return new Response("Forbidden", { status: 403 }); + + let filepath: string; + if (identifier.startsWith("sha256:")) { + // Track ID - look up in library + const trackPath = library.getFilePath(identifier); + if (!trackPath) return new Response("Not found", { status: 404 }); + filepath = trackPath; + } else { + // Filename - direct path + filepath = join(MUSIC_DIR, identifier); + } + const f = file(filepath); if (!(await f.exists())) return new Response("Not found", { status: 404 }); @@ -474,26 +708,42 @@ serve({ }, message(ws: ServerWebSocket, message: string | Buffer) { const stream = streams.get(ws.data.streamId); - if (!stream) return; + if (!stream) { + console.log("[WS] No stream found for:", ws.data.streamId); + return; + } // Check permission for control actions const userId = ws.data.userId; - if (!userId) return; + if (!userId) { + console.log("[WS] No userId on connection"); + return; + } const user = findUserById(userId); - if (!user) return; + if (!user) { + console.log("[WS] User not found:", userId); + return; + } // Guests can never control playback - if (user.is_guest) return; + if (user.is_guest) { + console.log("[WS] Guest cannot control playback"); + return; + } // Check default permissions or user-specific permissions const canControl = user.is_admin || config.defaultPermissions.stream?.includes("control") || hasPermission(userId, "stream", ws.data.streamId, "control"); - if (!canControl) return; + if (!canControl) { + console.log("[WS] User lacks control permission:", user.username); + return; + } try { const data = JSON.parse(String(message)); + console.log("[WS] Control action:", data.action, "from", user.username); if (data.action === "pause") stream.pause(); else if (data.action === "unpause") stream.unpause(); } catch {}