Merge pull request 'dev/playlists' (#13) from dev/playlists into integration

Reviewed-on: #13
This commit is contained in:
peterino 2026-02-09 18:58:41 +00:00
commit c58e30b30d
22 changed files with 3332 additions and 800 deletions

View File

@ -98,13 +98,25 @@ M.trackBlobs // Map<trackId, blobUrl> - blob URLs for cached tracks
```
GET / → Serves public/index.html
GET /listen/:trackId → Serves index.html (direct track link)
GET /api/channels → List all channels with listener counts
POST /api/channels → Create a new channel
GET /api/channels/:id → Get channel state
PATCH /api/channels/:id → Rename channel
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's + shared playlists
POST /api/playlists → Create new playlist
GET /api/playlists/:id → Get playlist details
PATCH /api/playlists/:id → Update playlist (name, description, public)
DELETE /api/playlists/:id → Delete playlist
PATCH /api/playlists/:id/tracks → Modify tracks (add/remove/set)
POST /api/playlists/:id/share → Generate share token
DELETE /api/playlists/:id/share → Remove sharing
GET /api/playlists/shared/:token → Get shared playlist by token
POST /api/playlists/shared/:token → Copy shared playlist to own
```
## Files
@ -129,6 +141,7 @@ GET /api/library → List all tracks with id, filename, title, dura
- **channels.ts** — Channel CRUD and control (list, create, delete, jump, seek, queue, mode).
- **tracks.ts** — Library listing, file upload, audio serving with range support.
- **fetch.ts** — yt-dlp fetch endpoints (check URL, confirm playlist, queue status).
- **playlists.ts** — Playlist CRUD and track management.
- **static.ts** — Static file serving (index.html, styles.css, JS files).
### Client (public/)
@ -137,7 +150,8 @@ GET /api/library → List all tracks with id, filename, title, dura
- **audioCache.js** — Track caching, segment downloads, prefetching
- **channelSync.js** — WebSocket connection, server sync, channel switching
- **ui.js** — Progress bar, buffer display, UI updates
- **queue.js** — Queue/library rendering, cache status
- **queue.js** — Queue/library rendering, cache status, context menus
- **playlists.js** — Playlist UI, create/edit/delete, add tracks
- **controls.js** — Play, pause, seek, volume
- **auth.js** — Login, signup, logout
- **init.js** — App initialization
@ -260,13 +274,55 @@ Tracks in Library and Queue should behave identically where applicable:
- ▶ Play track (local mode, single track)
- ⏭ Play next (insert after current)
- Add to queue (append to end)
- 📁 Add to Playlist... (submenu)
- 🔗 Generate listening link
**Queue tracks:**
- ▶ Play track (jump to track)
- ⏭ Play next (re-add after current)
- Add again (duplicate at end)
- 📁 Add to Playlist... (submenu)
- 🔗 Generate listening link
- ✕ Remove from queue
## Playlists
Playlists are reusable collections of tracks that can be added to the queue.
### Data Model
```ts
interface Playlist {
id: string;
name: string;
description: string;
ownerId: number;
isPublic: boolean;
shareToken: string | null;
trackIds: string[];
}
```
### UI Structure
The Playlists tab has a dual-panel layout:
- **Left panel**: List of playlists (My Playlists + Shared)
- **Right panel**: Selected playlist's track list
### Context Menu Options
**Playlist (in list):**
- ▶ Add to Queue
- ⏭ Play Next
- ✏️ Rename (owner only)
- 🌐 Make Public / 🔒 Make Private
- 🔗 Copy Share Link / Generate Share Link
- 📋 Copy to My Playlists (shared only)
- 🗑️ Delete (owner only)
**Track (in playlist):**
- ▶ Play
- Add to Queue
- ⏭ Play Next
- 🗑️ Remove from Playlist (owner only)
### Mobile/Touch Support
- Larger touch targets (min 44px)
- No hover-dependent features (always show action buttons)

View File

@ -305,6 +305,44 @@ export class Channel {
this.broadcast();
}
moveTracks(indices: number[], targetIndex: number) {
if (indices.length === 0) return;
// Get the tracks being moved
const sorted = [...indices].sort((a, b) => a - b);
const tracksToMove = sorted.map(i => this.queue[i]).filter(Boolean);
if (tracksToMove.length === 0) return;
const currentTrackId = this.currentTrack?.id;
// Remove tracks from their current positions (from end to preserve indices)
for (let i = sorted.length - 1; i >= 0; i--) {
this.queue.splice(sorted[i], 1);
}
// Adjust target index for removed items that were before it
let adjustedTarget = targetIndex;
for (const idx of sorted) {
if (idx < targetIndex) adjustedTarget--;
}
// Insert at new position
this.queue.splice(adjustedTarget, 0, ...tracksToMove);
// Update currentIndex to follow the currently playing track
if (currentTrackId) {
const newIndex = this.queue.findIndex(t => t.id === currentTrackId);
if (newIndex !== -1) {
this.currentIndex = newIndex;
}
}
this.queueDirty = true;
this.persistQueue();
this.persistState();
this.broadcast();
}
broadcast() {
const now = Date.now();
const includeQueue = this.queueDirty || (now - this.lastQueueBroadcast >= 60000);

548
db.ts
View File

@ -417,3 +417,551 @@ export function loadChannelQueue(channelId: string): string[] {
export function removeTrackFromQueues(trackId: string): void {
db.query("DELETE FROM channel_queue WHERE track_id = ?").run(trackId);
}
// Playlist tables
db.run(`
CREATE TABLE IF NOT EXISTS playlists (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT DEFAULT '',
owner_id INTEGER NOT NULL,
is_public INTEGER DEFAULT 0,
share_token TEXT,
created_at INTEGER DEFAULT (unixepoch()),
updated_at INTEGER DEFAULT (unixepoch()),
FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS playlist_tracks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
playlist_id TEXT NOT NULL,
track_id TEXT NOT NULL,
position INTEGER NOT NULL,
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE
)
`);
db.run(`CREATE INDEX IF NOT EXISTS idx_playlist_tracks_playlist ON playlist_tracks(playlist_id)`);
// Playlist types
export interface PlaylistRow {
id: string;
name: string;
description: string;
owner_id: number;
is_public: number;
share_token: string | null;
created_at: number;
updated_at: number;
}
export interface Playlist {
id: string;
name: string;
description: string;
ownerId: number;
isPublic: boolean;
shareToken: string | null;
trackIds: string[];
createdAt: number;
updatedAt: number;
}
// Playlist CRUD functions
export function createPlaylist(name: string, ownerId: number, description: string = ""): Playlist {
const id = crypto.randomUUID();
const now = Math.floor(Date.now() / 1000);
db.query(`
INSERT INTO playlists (id, name, description, owner_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(id, name, description, ownerId, now, now);
return {
id,
name,
description,
ownerId,
isPublic: false,
shareToken: null,
trackIds: [],
createdAt: now,
updatedAt: now
};
}
export function getPlaylist(id: string): Playlist | null {
const row = db.query("SELECT * FROM playlists WHERE id = ?").get(id) as PlaylistRow | null;
if (!row) return null;
const tracks = db.query(
"SELECT track_id FROM playlist_tracks WHERE playlist_id = ? ORDER BY position"
).all(id) as { track_id: string }[];
return {
id: row.id,
name: row.name,
description: row.description,
ownerId: row.owner_id,
isPublic: !!row.is_public,
shareToken: row.share_token,
trackIds: tracks.map(t => t.track_id),
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
export function getPlaylistsByUser(userId: number): Playlist[] {
const rows = db.query(
"SELECT * FROM playlists WHERE owner_id = ? ORDER BY name"
).all(userId) as PlaylistRow[];
return rows.map(row => {
const tracks = db.query(
"SELECT track_id FROM playlist_tracks WHERE playlist_id = ? ORDER BY position"
).all(row.id) as { track_id: string }[];
return {
id: row.id,
name: row.name,
description: row.description,
ownerId: row.owner_id,
isPublic: !!row.is_public,
shareToken: row.share_token,
trackIds: tracks.map(t => t.track_id),
createdAt: row.created_at,
updatedAt: row.updated_at
};
});
}
export function getPublicPlaylists(excludeUserId?: number): Playlist[] {
const query = excludeUserId
? "SELECT * FROM playlists WHERE is_public = 1 AND owner_id != ? ORDER BY name"
: "SELECT * FROM playlists WHERE is_public = 1 ORDER BY name";
const rows = excludeUserId
? db.query(query).all(excludeUserId) as PlaylistRow[]
: db.query(query).all() as PlaylistRow[];
return rows.map(row => {
const tracks = db.query(
"SELECT track_id FROM playlist_tracks WHERE playlist_id = ? ORDER BY position"
).all(row.id) as { track_id: string }[];
return {
id: row.id,
name: row.name,
description: row.description,
ownerId: row.owner_id,
isPublic: !!row.is_public,
shareToken: row.share_token,
trackIds: tracks.map(t => t.track_id),
createdAt: row.created_at,
updatedAt: row.updated_at
};
});
}
export function getPlaylistByShareToken(token: string): Playlist | null {
const row = db.query(
"SELECT * FROM playlists WHERE share_token = ?"
).get(token) as PlaylistRow | null;
if (!row) return null;
const tracks = db.query(
"SELECT track_id FROM playlist_tracks WHERE playlist_id = ? ORDER BY position"
).all(row.id) as { track_id: string }[];
return {
id: row.id,
name: row.name,
description: row.description,
ownerId: row.owner_id,
isPublic: !!row.is_public,
shareToken: row.share_token,
trackIds: tracks.map(t => t.track_id),
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
export function updatePlaylist(id: string, updates: { name?: string; description?: string; isPublic?: boolean }): void {
const now = Math.floor(Date.now() / 1000);
const sets: string[] = ["updated_at = ?"];
const values: any[] = [now];
if (updates.name !== undefined) {
sets.push("name = ?");
values.push(updates.name);
}
if (updates.description !== undefined) {
sets.push("description = ?");
values.push(updates.description);
}
if (updates.isPublic !== undefined) {
sets.push("is_public = ?");
values.push(updates.isPublic ? 1 : 0);
}
values.push(id);
db.query(`UPDATE playlists SET ${sets.join(", ")} WHERE id = ?`).run(...values);
}
export function deletePlaylist(id: string): void {
db.query("DELETE FROM playlists WHERE id = ?").run(id);
}
export function setPlaylistTracks(playlistId: string, trackIds: string[]): void {
const now = Math.floor(Date.now() / 1000);
db.query("BEGIN").run();
try {
db.query("DELETE FROM playlist_tracks WHERE playlist_id = ?").run(playlistId);
const insert = db.query(
"INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES (?, ?, ?)"
);
for (let i = 0; i < trackIds.length; i++) {
insert.run(playlistId, trackIds[i], i);
}
db.query("UPDATE playlists SET updated_at = ? WHERE id = ?").run(now, playlistId);
db.query("COMMIT").run();
} catch (e) {
db.query("ROLLBACK").run();
throw e;
}
}
export function addTracksToPlaylist(playlistId: string, trackIds: string[]): void {
const now = Math.floor(Date.now() / 1000);
db.query("BEGIN").run();
try {
// Get current max position
const max = db.query(
"SELECT COALESCE(MAX(position), -1) as max_pos FROM playlist_tracks WHERE playlist_id = ?"
).get(playlistId) as { max_pos: number };
let pos = max.max_pos + 1;
const insert = db.query(
"INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES (?, ?, ?)"
);
for (const trackId of trackIds) {
insert.run(playlistId, trackId, pos++);
}
db.query("UPDATE playlists SET updated_at = ? WHERE id = ?").run(now, playlistId);
db.query("COMMIT").run();
} catch (e) {
db.query("ROLLBACK").run();
throw e;
}
}
export function removeTrackFromPlaylist(playlistId: string, position: number): void {
const now = Math.floor(Date.now() / 1000);
db.query("BEGIN").run();
try {
db.query("DELETE FROM playlist_tracks WHERE playlist_id = ? AND position = ?").run(playlistId, position);
// Reorder remaining tracks
db.query(`
UPDATE playlist_tracks
SET position = position - 1
WHERE playlist_id = ? AND position > ?
`).run(playlistId, position);
db.query("UPDATE playlists SET updated_at = ? WHERE id = ?").run(now, playlistId);
db.query("COMMIT").run();
} catch (e) {
db.query("ROLLBACK").run();
throw e;
}
}
export function removeTracksFromPlaylist(playlistId: string, positions: number[]): void {
if (positions.length === 0) return;
const now = Math.floor(Date.now() / 1000);
// Sort descending to remove from end first (preserves indices)
const sorted = [...positions].sort((a, b) => b - a);
db.query("BEGIN").run();
try {
for (const pos of sorted) {
db.query("DELETE FROM playlist_tracks WHERE playlist_id = ? AND position = ?").run(playlistId, pos);
// Reorder remaining tracks
db.query(`
UPDATE playlist_tracks
SET position = position - 1
WHERE playlist_id = ? AND position > ?
`).run(playlistId, pos);
}
db.query("UPDATE playlists SET updated_at = ? WHERE id = ?").run(now, playlistId);
db.query("COMMIT").run();
} catch (e) {
db.query("ROLLBACK").run();
throw e;
}
}
export function movePlaylistTracks(playlistId: string, fromPositions: number[], toPosition: number): void {
if (fromPositions.length === 0) return;
const now = Math.floor(Date.now() / 1000);
const sorted = [...fromPositions].sort((a, b) => a - b);
db.query("BEGIN").run();
try {
// Get tracks to move
const tracksToMove: string[] = [];
for (const pos of sorted) {
const row = db.query(
"SELECT track_id FROM playlist_tracks WHERE playlist_id = ? AND position = ?"
).get(playlistId, pos) as { track_id: string } | null;
if (row) tracksToMove.push(row.track_id);
}
if (tracksToMove.length === 0) {
db.query("ROLLBACK").run();
return;
}
// Remove tracks from current positions (from end to preserve indices)
for (let i = sorted.length - 1; i >= 0; i--) {
const pos = sorted[i];
db.query("DELETE FROM playlist_tracks WHERE playlist_id = ? AND position = ?").run(playlistId, pos);
db.query(`
UPDATE playlist_tracks
SET position = position - 1
WHERE playlist_id = ? AND position > ?
`).run(playlistId, pos);
}
// Adjust target for removed items
let adjustedTarget = toPosition;
for (const pos of sorted) {
if (pos < toPosition) adjustedTarget--;
}
// Make room at target position
db.query(`
UPDATE playlist_tracks
SET position = position + ?
WHERE playlist_id = ? AND position >= ?
`).run(tracksToMove.length, playlistId, adjustedTarget);
// Insert tracks at new position
const insert = db.query(
"INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES (?, ?, ?)"
);
for (let i = 0; i < tracksToMove.length; i++) {
insert.run(playlistId, tracksToMove[i], adjustedTarget + i);
}
db.query("UPDATE playlists SET updated_at = ? WHERE id = ?").run(now, playlistId);
db.query("COMMIT").run();
} catch (e) {
db.query("ROLLBACK").run();
throw e;
}
}
export function insertTracksToPlaylistAt(playlistId: string, trackIds: string[], position: number): void {
if (trackIds.length === 0) return;
const now = Math.floor(Date.now() / 1000);
db.query("BEGIN").run();
try {
// Make room at position
db.query(`
UPDATE playlist_tracks
SET position = position + ?
WHERE playlist_id = ? AND position >= ?
`).run(trackIds.length, playlistId, position);
// Insert tracks
const insert = db.query(
"INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES (?, ?, ?)"
);
for (let i = 0; i < trackIds.length; i++) {
insert.run(playlistId, trackIds[i], position + i);
}
db.query("UPDATE playlists SET updated_at = ? WHERE id = ?").run(now, playlistId);
db.query("COMMIT").run();
} catch (e) {
db.query("ROLLBACK").run();
throw e;
}
}
export function generatePlaylistShareToken(playlistId: string): string {
const token = crypto.randomUUID().slice(0, 12);
db.query("UPDATE playlists SET share_token = ? WHERE id = ?").run(token, playlistId);
return token;
}
export function removePlaylistShareToken(playlistId: string): void {
db.query("UPDATE playlists SET share_token = NULL WHERE id = ?").run(playlistId);
}
// Slow queue table for yt-dlp playlist downloads
db.run(`
CREATE TABLE IF NOT EXISTS slow_queue (
id TEXT PRIMARY KEY,
url TEXT NOT NULL,
title TEXT NOT NULL,
user_id INTEGER NOT NULL,
status TEXT DEFAULT 'queued',
progress REAL DEFAULT 0,
error TEXT,
playlist_id TEXT,
playlist_name TEXT,
position INTEGER,
created_at INTEGER,
completed_at INTEGER,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE SET NULL
)
`);
db.run(`CREATE INDEX IF NOT EXISTS idx_slow_queue_user ON slow_queue(user_id)`);
db.run(`CREATE INDEX IF NOT EXISTS idx_slow_queue_status ON slow_queue(status)`);
// Slow queue types
export interface SlowQueueRow {
id: string;
url: string;
title: string;
user_id: number;
status: string;
progress: number;
error: string | null;
playlist_id: string | null;
playlist_name: string | null;
position: number | null;
created_at: number;
completed_at: number | null;
}
// Slow queue CRUD functions
export function saveSlowQueueItem(item: {
id: string;
url: string;
title: string;
userId: number;
status: string;
progress: number;
error?: string;
playlistId?: string;
playlistName?: string;
position?: number;
createdAt: number;
completedAt?: number;
}): void {
db.query(`
INSERT INTO slow_queue (id, url, title, user_id, status, progress, error, playlist_id, playlist_name, position, created_at, completed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
status = excluded.status,
progress = excluded.progress,
error = excluded.error,
completed_at = excluded.completed_at
`).run(
item.id,
item.url,
item.title,
item.userId,
item.status,
item.progress,
item.error ?? null,
item.playlistId ?? null,
item.playlistName ?? null,
item.position ?? null,
item.createdAt,
item.completedAt ?? null
);
}
export function updateSlowQueueItem(id: string, updates: {
status?: string;
progress?: number;
error?: string;
completedAt?: number;
}): void {
const sets: string[] = [];
const values: any[] = [];
if (updates.status !== undefined) {
sets.push("status = ?");
values.push(updates.status);
}
if (updates.progress !== undefined) {
sets.push("progress = ?");
values.push(updates.progress);
}
if (updates.error !== undefined) {
sets.push("error = ?");
values.push(updates.error);
}
if (updates.completedAt !== undefined) {
sets.push("completed_at = ?");
values.push(updates.completedAt);
}
if (sets.length === 0) return;
values.push(id);
db.query(`UPDATE slow_queue SET ${sets.join(", ")} WHERE id = ?`).run(...values);
}
export function loadSlowQueue(): SlowQueueRow[] {
return db.query(
"SELECT * FROM slow_queue WHERE status IN ('queued', 'downloading') ORDER BY created_at"
).all() as SlowQueueRow[];
}
export function deleteSlowQueueItem(id: string): void {
db.query("DELETE FROM slow_queue WHERE id = ?").run(id);
}
export function clearCompletedSlowQueue(maxAge: number = 3600): void {
const cutoff = Math.floor(Date.now() / 1000) - maxAge;
db.query(
"DELETE FROM slow_queue WHERE status IN ('complete', 'error', 'cancelled') AND completed_at < ?"
).run(cutoff);
}
export function getSlowQueueByUser(userId: number): SlowQueueRow[] {
return db.query(
"SELECT * FROM slow_queue WHERE user_id = ? ORDER BY created_at"
).all(userId) as SlowQueueRow[];
}
export function playlistNameExists(name: string, userId: number): boolean {
const result = db.query(
"SELECT 1 FROM playlists WHERE name = ? AND owner_id = ? LIMIT 1"
).get(name, userId);
return !!result;
}
export function generateUniquePlaylistName(baseName: string, userId: number): string {
if (!playlistNameExists(baseName, userId)) {
return baseName;
}
let counter = 2;
while (playlistNameExists(`${baseName} (${counter})`, userId)) {
counter++;
}
return `${baseName} (${counter})`;
}

152
init.ts
View File

@ -8,6 +8,7 @@ import {
saveChannelQueue,
loadChannelQueue,
removeTrackFromQueues,
addTracksToPlaylist,
} from "./db";
import { config, MUSIC_DIR, DEFAULT_CONFIG } from "./config";
import { state, setLibrary } from "./state";
@ -15,6 +16,10 @@ import { broadcastToAll, broadcastChannelList, sendToUser } from "./broadcast";
import {
initYtdlp,
setProgressCallback,
setTrackReadyCallback,
skipSlowQueueItem,
getQueuedSlowItems,
type QueueItem,
} from "./ytdlp";
// Auto-discover tracks if queue is empty
@ -98,6 +103,8 @@ export async function init(): Promise<void> {
status: item.status,
progress: item.progress,
queueType: item.queueType,
playlistId: item.playlistId,
playlistName: item.playlistName,
error: item.error
});
});
@ -106,6 +113,23 @@ export async function init(): Promise<void> {
const library = new Library(MUSIC_DIR);
setLibrary(library);
// Track pending playlist additions (title -> {playlistId, playlistName, userId})
const pendingPlaylistTracks = new Map<string, { playlistId: string; playlistName: string; userId: number }>();
// When a download completes, register it for playlist addition
setTrackReadyCallback((item: QueueItem) => {
if (!item.playlistId) return;
// Store the pending addition - will be processed when library detects the file
// yt-dlp saves files as "title.mp3", so use the title as key
pendingPlaylistTracks.set(item.title.toLowerCase(), {
playlistId: item.playlistId,
playlistName: item.playlistName!,
userId: item.userId
});
console.log(`[ytdlp] Registered pending playlist addition: "${item.title}" → ${item.playlistName}`);
});
// Scan library first
await library.scan();
library.startWatching();
@ -115,16 +139,144 @@ export async function init(): Promise<void> {
broadcastToAll({ type: "scan_progress", scanning: false });
});
// Normalize string for matching (handle Windows filename character substitutions)
function normalizeForMatch(s: string): string {
return s.toLowerCase()
.replace(//g, "|") // fullwidth vertical line → pipe
.replace(//g, '"') // fullwidth quotation
.replace(//g, "*") // fullwidth asterisk
.replace(//g, "?") // fullwidth question mark
.replace(//g, "<") // fullwidth less-than
.replace(//g, ">") // fullwidth greater-than
.replace(//g, ":") // fullwidth colon
.replace(//g, "/") // fullwidth slash
.replace(//g, "\\") // fullwidth backslash
.trim();
}
// Helper to check if track matches a pending playlist addition
function checkPendingPlaylistAddition(track: { id: string; title?: string; filename?: string }) {
if (pendingPlaylistTracks.size === 0) return;
const trackTitle = normalizeForMatch(track.title || "");
const trackFilename = normalizeForMatch((track.filename || "").replace(/\.[^.]+$/, "")); // Remove extension
// Skip if both title and filename are too short
if ((!trackTitle || trackTitle.length < 5) && (!trackFilename || trackFilename.length < 5)) return;
console.log(`[ytdlp] Checking track against ${pendingPlaylistTracks.size} pending: title="${trackTitle}" filename="${trackFilename}"`);
for (const [pendingTitle, pending] of pendingPlaylistTracks) {
const normalizedPending = normalizeForMatch(pendingTitle);
// Skip if pending title is too short
if (!normalizedPending || normalizedPending.length < 5) continue;
// Match by title or filename (yt-dlp uses title as filename)
// Require exact match or very high overlap
const matches =
(trackTitle && trackTitle === normalizedPending) ||
(trackFilename && trackFilename === normalizedPending) ||
(trackTitle && normalizedPending && trackTitle.includes(normalizedPending) && normalizedPending.length >= trackTitle.length * 0.8) ||
(trackTitle && normalizedPending && normalizedPending.includes(trackTitle) && trackTitle.length >= normalizedPending.length * 0.8) ||
(trackFilename && normalizedPending && trackFilename.includes(normalizedPending) && normalizedPending.length >= trackFilename.length * 0.8) ||
(trackFilename && normalizedPending && normalizedPending.includes(trackFilename) && trackFilename.length >= normalizedPending.length * 0.8);
console.log(`[ytdlp] vs pending="${normalizedPending}" → ${matches ? "MATCH" : "no match"}`);
if (matches) {
console.log(`[ytdlp] Adding track ${track.id} to playlist ${pending.playlistId}`);
try {
addTracksToPlaylist(pending.playlistId, [track.id]);
sendToUser(pending.userId, {
type: "toast",
message: `Added to playlist: ${pending.playlistName}`,
toastType: "info"
});
pendingPlaylistTracks.delete(pendingTitle);
} catch (e) {
console.error(`[ytdlp] Failed to add track to playlist:`, e);
}
return;
}
}
}
// Broadcast when tracks are added/updated
library.on("added", (track) => {
broadcastToAll({ type: "toast", message: `Added: ${track.title || track.filename}`, toastType: "info" });
library.logActivity("scan_added", { id: track.id, filename: track.filename, title: track.title });
// Check if this track was pending playlist addition (defer to ensure DB is updated)
setTimeout(() => checkPendingPlaylistAddition(track), 100);
});
library.on("changed", (track) => {
broadcastToAll({ type: "toast", message: `Updated: ${track.title || track.filename}`, toastType: "info" });
library.logActivity("scan_updated", { id: track.id, filename: track.filename, title: track.title });
});
// Prescan slow queue to find tracks already in library
function prescanSlowQueue() {
const queuedItems = getQueuedSlowItems();
if (queuedItems.length === 0) return;
const tracks = library.getAllTracks();
if (tracks.length === 0) return;
for (const item of queuedItems) {
const itemTitle = normalizeForMatch(item.title);
// Skip if title is too short (avoid false matches)
if (!itemTitle || itemTitle.length < 5) continue;
// Check if any library track matches
for (const track of tracks) {
const trackTitle = normalizeForMatch(track.title || "");
const trackFilename = normalizeForMatch((track.filename || "").replace(/\.[^.]+$/, ""));
// Skip if both track title and filename are too short
if ((!trackTitle || trackTitle.length < 5) && (!trackFilename || trackFilename.length < 5)) continue;
// Require exact match or very high overlap (not just substring)
const matches =
(trackTitle && trackTitle === itemTitle) ||
(trackFilename && trackFilename === itemTitle) ||
// Only allow includes if the shorter string is at least 80% of the longer
(trackTitle && itemTitle && trackTitle.includes(itemTitle) && itemTitle.length >= trackTitle.length * 0.8) ||
(trackTitle && itemTitle && itemTitle.includes(trackTitle) && trackTitle.length >= itemTitle.length * 0.8) ||
(trackFilename && itemTitle && trackFilename.includes(itemTitle) && itemTitle.length >= trackFilename.length * 0.8) ||
(trackFilename && itemTitle && itemTitle.includes(trackFilename) && trackFilename.length >= itemTitle.length * 0.8);
if (matches) {
console.log(`[ytdlp] Prescan: "${item.title}" already exists as "${track.title || track.filename}"`);
// Skip download and add to playlist
const skipped = skipSlowQueueItem(item.id, track.id);
if (skipped && skipped.playlistId) {
try {
addTracksToPlaylist(skipped.playlistId, [track.id]);
sendToUser(skipped.userId, {
type: "toast",
message: `Already in library, added to: ${skipped.playlistName}`,
toastType: "info"
});
} catch (e) {
console.error(`[ytdlp] Failed to add existing track to playlist:`, e);
}
}
break;
}
}
}
}
// Run prescan periodically (every 30 seconds)
setInterval(prescanSlowQueue, 30000);
// Also run once after initial scan completes
library.onScanComplete(() => {
setTimeout(prescanSlowQueue, 1000);
});
// Load channels from database
const savedChannels = loadAllChannels();
let hasDefault = false;

View File

@ -14,6 +14,10 @@
M.currentUser.permissions = data.permissions;
}
M.updateAuthUI();
// Start slow queue polling if logged in
if (M.currentUser && !M.currentUser.isGuest && M.startSlowQueuePoll) {
M.startSlowQueuePoll();
}
} catch (e) {
M.currentUser = null;
M.updateAuthUI();
@ -108,6 +112,8 @@
const wasGuest = M.currentUser?.isGuest;
await fetch("/api/auth/logout", { method: "POST" });
M.currentUser = null;
// Stop slow queue polling on logout
if (M.stopSlowQueuePoll) M.stopSlowQueuePoll();
if (wasGuest) {
// Guest clicking "Sign In" - show login panel
M.updateAuthUI();

View File

@ -252,7 +252,6 @@
const data = JSON.parse(e.data);
// Handle channel list updates
if (data.type === "channel_list") {
console.log("[WS] Received channel_list:", data.channels.length, "channels");
M.channels = data.channels;
M.renderChannelList();
return;
@ -405,6 +404,9 @@
M.setTrackTitle(data.track.title);
M.loadingSegments.clear();
// Auto-scroll queue to current track
setTimeout(() => M.scrollToCurrentTrack(), 100);
// Debug: log cache state for this track
const trackCache = M.trackCaches.get(trackId);
console.log("[Playback] Starting track:", data.track.title, {

10
public/controls.js vendored
View File

@ -64,6 +64,7 @@
M.localTimestamp = 0;
M.audio.play();
M.renderQueue();
setTimeout(() => M.scrollToCurrentTrack(), 100);
}
}
@ -92,6 +93,9 @@
// Play/pause button
M.$("#status-icon").onclick = togglePlayback;
// Expose jumpToTrack for double-click handling
M.jumpToTrack = jumpToTrack;
// Prev/next buttons
M.$("#btn-prev").onclick = () => jumpToTrack(M.currentIndex - 1);
M.$("#btn-next").onclick = () => jumpToTrack(M.currentIndex + 1);
@ -110,9 +114,9 @@
// Playback mode button
const modeLabels = {
"once": "once",
"repeat-all": "repeat",
"repeat-one": "single",
"once": "loop(off)",
"repeat-all": "loop(all)",
"repeat-one": "loop(one)",
"shuffle": "shuffle"
};
const modeOrder = ["once", "repeat-all", "repeat-one", "shuffle"];

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -68,6 +68,7 @@
<div id="library-panel">
<div class="panel-tabs">
<button class="panel-tab active" data-tab="library">Library</button>
<button class="panel-tab" data-tab="playlists">Playlists</button>
<button class="panel-tab" data-tab="tasks">Tasks</button>
</div>
<div class="panel-views">
@ -100,6 +101,31 @@
<div class="dropzone-content">Drop audio files here</div>
</div>
</div>
<div id="playlists-view" class="panel-view">
<div class="playlists-container">
<div id="playlists-list-panel">
<div class="playlists-section">
<h4>My Playlists</h4>
<div id="my-playlists"></div>
</div>
<div class="playlists-section">
<h4>Shared</h4>
<div id="shared-playlists"></div>
</div>
<button id="btn-new-playlist" class="new-playlist-btn">+ New Playlist</button>
</div>
<div id="playlist-contents-panel">
<div id="playlist-contents-header">
<span id="selected-playlist-name">Select a playlist</span>
<div id="playlist-actions" class="hidden">
<button id="btn-playlist-add-queue" title="Add playlist to queue">Add playlist to queue</button>
<button id="btn-playlist-play-next" title="Play next">Queue playlist next</button>
</div>
</div>
<div id="playlist-tracks"></div>
</div>
</div>
</div>
<div id="tasks-view" class="panel-view">
<div id="tasks-list"></div>
<div id="tasks-empty" class="tasks-empty">No active tasks</div>
@ -107,7 +133,7 @@
</div>
</div>
<div id="queue-panel">
<h3 id="queue-title">Queue</h3>
<h3 id="queue-title">Queue <span id="queue-duration"></span></h3>
<div id="now-playing-bar" class="now-playing-bar hidden" title="Click to scroll to current track"></div>
<div id="queue"></div>
</div>
@ -154,10 +180,13 @@
<script src="/trackStorage.js"></script>
<script src="/core.js"></script>
<script src="/utils.js"></script>
<script src="/trackComponent.js"></script>
<script src="/trackContainer.js"></script>
<script src="/audioCache.js"></script>
<script src="/channelSync.js"></script>
<script src="/ui.js"></script>
<script src="/queue.js"></script>
<script src="/playlists.js"></script>
<script src="/controls.js"></script>
<script src="/auth.js"></script>
<script src="/upload.js"></script>

View File

@ -108,6 +108,11 @@
}
initPanelTabs();
// Initialize playlists
if (M.playlists?.init) {
M.playlists.init();
}
});
// Update UI based on server status
@ -128,6 +133,10 @@
await M.loadCurrentUser();
if (M.currentUser) {
M.loadChannels();
// Load playlists after auth
if (M.playlists?.load) {
M.playlists.load();
}
}
// Handle direct track link after everything is loaded

536
public/playlists.js Normal file
View File

@ -0,0 +1,536 @@
// Playlists UI module
(function() {
const M = window.MusicRoom;
const $ = M.$;
const formatTime = M.fmt;
const showToast = M.showToast;
let myPlaylists = [];
let sharedPlaylists = [];
let selectedPlaylistId = null;
let selectedPlaylist = null;
async function loadPlaylists() {
try {
const res = await fetch('/api/playlists');
if (!res.ok) {
if (res.status === 401) {
// Not logged in yet
return;
}
throw new Error('Failed to load playlists');
}
const data = await res.json();
myPlaylists = data.mine || [];
sharedPlaylists = data.shared || [];
renderPlaylistList();
} catch (err) {
console.error('Failed to load playlists:', err);
}
}
function renderPlaylistList() {
const myContainer = $('#my-playlists');
const sharedContainer = $('#shared-playlists');
if (!myContainer || !sharedContainer) return;
// My playlists
if (myPlaylists.length === 0) {
myContainer.innerHTML = '<div class="empty-playlists">No playlists yet</div>';
} else {
myContainer.innerHTML = myPlaylists.map(p => `
<div class="playlist-item${p.id === selectedPlaylistId ? ' selected' : ''}" data-id="${p.id}">
<span class="playlist-name">${escapeHtml(p.name)}</span>
${p.isPublic ? '<span class="playlist-public-icon" title="Public">🌐</span>' : ''}
<span class="playlist-count">${p.trackIds.length}</span>
</div>
`).join('');
}
// Shared playlists
if (sharedPlaylists.length === 0) {
sharedContainer.innerHTML = '<div class="empty-playlists">No shared playlists</div>';
} else {
sharedContainer.innerHTML = sharedPlaylists.map(p => `
<div class="playlist-item${p.id === selectedPlaylistId ? ' selected' : ''}" data-id="${p.id}">
<span class="playlist-name">${escapeHtml(p.name)}</span>
<span class="playlist-owner">by ${escapeHtml(p.ownerName || 'Unknown')}</span>
<span class="playlist-count">${p.trackIds.length}</span>
</div>
`).join('');
}
// Attach click handlers
myContainer.querySelectorAll('.playlist-item').forEach(el => {
el.onclick = () => selectPlaylist(el.dataset.id);
el.oncontextmenu = (e) => showPlaylistContextMenu(e, el.dataset.id, true);
});
sharedContainer.querySelectorAll('.playlist-item').forEach(el => {
el.onclick = () => selectPlaylist(el.dataset.id);
el.oncontextmenu = (e) => showPlaylistContextMenu(e, el.dataset.id, false);
});
}
async function selectPlaylist(id) {
selectedPlaylistId = id;
// Update selection in list
document.querySelectorAll('.playlist-item').forEach(el => {
el.classList.toggle('selected', el.dataset.id === id);
});
// Load playlist details
try {
const res = await fetch(`/api/playlists/${id}`);
if (!res.ok) throw new Error('Failed to load playlist');
selectedPlaylist = await res.json();
renderPlaylistContents();
} catch (err) {
console.error('Failed to load playlist:', err);
showToast('Failed to load playlist', 'error');
}
}
// Playlist tracks container instance
let playlistContainer = null;
function renderPlaylistContents() {
const header = $('#selected-playlist-name');
const actions = $('#playlist-actions');
const containerEl = $('#playlist-tracks');
if (!selectedPlaylist) {
header.textContent = 'Select a playlist';
actions.classList.add('hidden');
containerEl.innerHTML = '';
return;
}
header.textContent = selectedPlaylist.name;
actions.classList.remove('hidden');
const isMine = myPlaylists.some(p => p.id === selectedPlaylistId);
// Create or update container (even for empty playlists, to enable drag-drop)
playlistContainer = M.trackContainer.createContainer({
type: 'playlist',
element: containerEl,
getTracks: () => {
return selectedPlaylist.trackIds.map((id, i) => {
const track = M.library.find(t => t.id === id);
return { track: track || { id, title: 'Unknown track', duration: 0 }, originalIndex: i };
});
},
isPlaylistOwner: isMine,
playlistId: selectedPlaylistId
});
playlistContainer.render();
}
function reloadCurrentPlaylist() {
if (selectedPlaylistId) {
selectPlaylist(selectedPlaylistId);
}
}
function showPlaylistContextMenu(e, playlistId, isMine) {
e.preventDefault();
M.contextMenu.hide();
const playlist = isMine
? myPlaylists.find(p => p.id === playlistId)
: sharedPlaylists.find(p => p.id === playlistId);
if (!playlist) return;
const items = [];
// Add to queue options
items.push({
label: '▶ Add to Queue',
action: () => addPlaylistToQueue(playlistId)
});
items.push({
label: '⏭ Play Next',
action: () => addPlaylistToQueue(playlistId, true)
});
items.push({ separator: true });
if (isMine) {
// Rename
items.push({
label: '✏️ Rename',
action: () => startRenamePlaylist(playlistId)
});
// Share/unshare
if (playlist.isPublic) {
items.push({
label: '🔒 Make Private',
action: () => togglePlaylistPublic(playlistId, false)
});
} else {
items.push({
label: '🌐 Make Public',
action: () => togglePlaylistPublic(playlistId, true)
});
}
items.push({ separator: true });
// Delete
items.push({
label: '🗑️ Delete',
action: () => deletePlaylist(playlistId),
className: 'danger'
});
} else {
// Copy to my playlists
items.push({
label: '📋 Copy to My Playlists',
action: () => copyPlaylist(playlistId)
});
}
M.contextMenu.show(e, items);
}
async function addPlaylistToQueue(playlistId, playNext = false) {
const playlist = [...myPlaylists, ...sharedPlaylists].find(p => p.id === playlistId);
if (!playlist || playlist.trackIds.length === 0) {
showToast('Playlist is empty', 'error');
return;
}
try {
const body = playNext
? { add: playlist.trackIds, insertAt: M.currentIndex + 1 }
: { add: playlist.trackIds };
const res = await fetch(`/api/channels/${M.currentChannelId}/queue`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) throw new Error('Failed to add to queue');
showToast(`Added ${playlist.trackIds.length} tracks to queue`);
} catch (err) {
console.error('Failed to add playlist to queue:', err);
showToast('Failed to add to queue', 'error');
}
}
async function addTracksToQueue(trackIds, playNext = false) {
try {
const body = playNext
? { add: trackIds, insertAt: M.currentIndex + 1 }
: { add: trackIds };
const res = await fetch(`/api/channels/${M.currentChannelId}/queue`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) throw new Error('Failed to add to queue');
showToast(`Added ${trackIds.length} track${trackIds.length > 1 ? 's' : ''} to queue`);
} catch (err) {
console.error('Failed to add to queue:', err);
showToast('Failed to add to queue', 'error');
}
}
function playTrackFromPlaylist(trackId) {
// Desynced playback of single track
if (M.playDirectTrack) {
M.playDirectTrack(trackId);
}
}
async function createPlaylist(name) {
try {
const res = await fetch('/api/playlists', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});
if (!res.ok) throw new Error('Failed to create playlist');
const playlist = await res.json();
showToast(`Created playlist "${name}"`);
await loadPlaylists();
selectPlaylist(playlist.id);
return playlist;
} catch (err) {
console.error('Failed to create playlist:', err);
showToast('Failed to create playlist', 'error');
return null;
}
}
async function deletePlaylist(playlistId) {
const playlist = myPlaylists.find(p => p.id === playlistId);
if (!playlist) return;
try {
const res = await fetch(`/api/playlists/${playlistId}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete playlist');
showToast(`Deleted playlist "${playlist.name}"`);
if (selectedPlaylistId === playlistId) {
selectedPlaylistId = null;
selectedPlaylist = null;
renderPlaylistContents();
}
await loadPlaylists();
} catch (err) {
console.error('Failed to delete playlist:', err);
showToast('Failed to delete playlist', 'error');
}
}
function startRenamePlaylist(playlistId) {
const el = document.querySelector(`.playlist-item[data-id="${playlistId}"] .playlist-name`);
if (!el) return;
const playlist = myPlaylists.find(p => p.id === playlistId);
if (!playlist) return;
const input = document.createElement('input');
input.type = 'text';
input.className = 'playlist-rename-input';
input.value = playlist.name;
const originalText = el.textContent;
el.textContent = '';
el.appendChild(input);
input.focus();
input.select();
const finish = async (save) => {
const newName = input.value.trim();
el.textContent = save && newName ? newName : originalText;
if (save && newName && newName !== originalText) {
try {
const res = await fetch(`/api/playlists/${playlistId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName })
});
if (!res.ok) throw new Error('Failed to rename');
showToast(`Renamed to "${newName}"`);
await loadPlaylists();
if (selectedPlaylistId === playlistId && selectedPlaylist) {
selectedPlaylist.name = newName;
renderPlaylistContents();
}
} catch (err) {
el.textContent = originalText;
showToast('Failed to rename playlist', 'error');
}
}
};
input.onblur = () => finish(true);
input.onkeydown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
input.blur();
} else if (e.key === 'Escape') {
finish(false);
}
};
}
async function togglePlaylistPublic(playlistId, isPublic) {
try {
const res = await fetch(`/api/playlists/${playlistId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isPublic })
});
if (!res.ok) throw new Error('Failed to update');
showToast(isPublic ? 'Playlist is now public' : 'Playlist is now private');
await loadPlaylists();
} catch (err) {
showToast('Failed to update playlist', 'error');
}
}
async function generateShareLink(playlistId) {
try {
const res = await fetch(`/api/playlists/${playlistId}/share`, { method: 'POST' });
if (!res.ok) throw new Error('Failed to generate link');
const data = await res.json();
const url = `${window.location.origin}/playlist/${data.shareToken}`;
await navigator.clipboard.writeText(url);
showToast('Share link copied to clipboard');
await loadPlaylists();
} catch (err) {
showToast('Failed to generate share link', 'error');
}
}
function copyShareLink(token) {
const url = `${window.location.origin}/playlist/${token}`;
navigator.clipboard.writeText(url).then(() => {
showToast('Share link copied to clipboard');
});
}
async function copyPlaylist(playlistId) {
const playlist = sharedPlaylists.find(p => p.id === playlistId);
if (!playlist) return;
try {
if (playlist.shareToken) {
const res = await fetch(`/api/playlists/shared/${playlist.shareToken}`, { method: 'POST' });
if (!res.ok) throw new Error('Failed to copy');
} else {
// Create new playlist and copy tracks
const newPlaylist = await createPlaylist(`${playlist.name} (Copy)`);
if (newPlaylist) {
await fetch(`/api/playlists/${newPlaylist.id}/tracks`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ set: playlist.trackIds })
});
}
}
showToast(`Copied "${playlist.name}" to your playlists`);
await loadPlaylists();
} catch (err) {
showToast('Failed to copy playlist', 'error');
}
}
async function removeTrackFromPlaylist(index) {
if (!selectedPlaylistId) return;
try {
const res = await fetch(`/api/playlists/${selectedPlaylistId}/tracks`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ remove: [index] })
});
if (!res.ok) throw new Error('Failed to remove track');
showToast('Track removed from playlist');
await selectPlaylist(selectedPlaylistId);
await loadPlaylists();
} catch (err) {
showToast('Failed to remove track', 'error');
}
}
// Add tracks to playlist (used from library/queue context menu)
async function addTracksToPlaylist(playlistId, trackIds) {
try {
const res = await fetch(`/api/playlists/${playlistId}/tracks`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ add: trackIds })
});
if (!res.ok) throw new Error('Failed to add tracks');
const playlist = myPlaylists.find(p => p.id === playlistId);
showToast(`Added ${trackIds.length} track${trackIds.length > 1 ? 's' : ''} to "${playlist?.name || 'playlist'}"`);
if (selectedPlaylistId === playlistId) {
await selectPlaylist(playlistId);
}
await loadPlaylists();
} catch (err) {
showToast('Failed to add tracks to playlist', 'error');
}
}
// Show "Add to Playlist" submenu
function showAddToPlaylistMenu(trackIds) {
if (myPlaylists.length === 0) {
showToast('Create a playlist first', 'info');
return null;
}
return myPlaylists.map(p => ({
label: p.name,
action: () => addTracksToPlaylist(p.id, trackIds)
}));
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]));
}
function initPlaylists() {
// New playlist button
const btnNew = $('#btn-new-playlist');
if (btnNew) {
btnNew.onclick = () => {
// Inline input for new playlist name
const container = $('#my-playlists');
const input = document.createElement('input');
input.type = 'text';
input.className = 'new-playlist-input';
input.placeholder = 'Playlist name...';
container.insertBefore(input, container.firstChild);
input.focus();
const finish = async (create) => {
const name = input.value.trim();
input.remove();
if (create && name) {
await createPlaylist(name);
}
};
input.onblur = () => finish(true);
input.onkeydown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
input.blur();
} else if (e.key === 'Escape') {
finish(false);
}
};
};
}
// Add to queue buttons
const btnAddQueue = $('#btn-playlist-add-queue');
const btnPlayNext = $('#btn-playlist-play-next');
if (btnAddQueue) {
btnAddQueue.onclick = () => {
if (selectedPlaylistId) addPlaylistToQueue(selectedPlaylistId);
};
}
if (btnPlayNext) {
btnPlayNext.onclick = () => {
if (selectedPlaylistId) addPlaylistToQueue(selectedPlaylistId, true);
};
}
// Load playlists when tab is shown
document.querySelectorAll('.panel-tab[data-tab="playlists"]').forEach(tab => {
tab.addEventListener('click', () => {
loadPlaylists();
});
});
}
// Expose for other modules
M.playlists = {
load: loadPlaylists,
init: initPlaylists,
getMyPlaylists: () => myPlaylists,
showAddToPlaylistMenu,
addTracksToPlaylist,
renderPlaylistContents,
reloadCurrentPlaylist
};
})();

View File

@ -1,25 +1,21 @@
// MusicRoom - Queue module
// Queue rendering and library display
// Queue and library display using trackContainer
(function() {
const M = window.MusicRoom;
// Selection state for bulk operations
M.selectedQueueIndices = new Set();
M.selectedLibraryIds = new Set();
// Last selected index for shift-select range
let lastSelectedQueueIndex = null;
let lastSelectedLibraryIndex = null;
// Context menu state
let activeContextMenu = null;
// Download state - only one at a time
let isDownloading = false;
let exportQueue = [];
let isExporting = false;
// Container instances
let queueContainer = null;
let libraryContainer = null;
// Library search state
M.librarySearchQuery = "";
// Download a track to user's device (uses cache if available)
async function downloadTrack(trackId, filename) {
if (isDownloading) {
@ -81,38 +77,31 @@
return;
}
// Build list of cached tracks with filenames
const cachedIds = [...M.cachedTracks];
if (cachedIds.length === 0) {
M.showToast("No cached tracks to export", "warning");
return;
}
// Find filenames from library or queue
const trackMap = new Map();
M.library.forEach(t => { if (t.filename) trackMap.set(t.id, t.filename); });
M.queue.forEach(t => { if (t.filename && !trackMap.has(t.id)) trackMap.set(t.id, t.filename); });
// Only export tracks with known filenames
exportQueue = cachedIds
.filter(id => trackMap.has(id))
.map(id => ({ id, filename: trackMap.get(id) }));
const skipped = cachedIds.length - exportQueue.length;
if (exportQueue.length === 0) {
M.showToast("No exportable tracks (filenames unknown)", "warning");
M.showToast("No exportable tracks found", "warning");
return;
}
isExporting = true;
const msg = skipped > 0
? `Exporting ${exportQueue.length} tracks (${skipped} skipped - not in library)`
: `Exporting ${exportQueue.length} cached tracks...`;
M.showToast(msg);
M.showToast(`Exporting ${exportQueue.length} tracks...`);
let exported = 0;
for (const { id, filename } of exportQueue) {
if (!isExporting) break; // Allow cancellation
if (!isExporting) break;
try {
const cached = await TrackStorage.get(id);
@ -126,8 +115,6 @@
document.body.removeChild(a);
URL.revokeObjectURL(url);
exported++;
// Small delay between downloads to not overwhelm browser
await new Promise(r => setTimeout(r, 500));
}
} catch (e) {
@ -147,249 +134,50 @@
}
};
// Close context menu when clicking elsewhere
document.addEventListener("click", () => {
if (activeContextMenu) {
activeContextMenu.remove();
activeContextMenu = null;
}
});
// Show context menu
function showContextMenu(e, items) {
e.preventDefault();
if (activeContextMenu) activeContextMenu.remove();
const menu = document.createElement("div");
menu.className = "context-menu";
items.forEach(item => {
const el = document.createElement("div");
el.className = "context-menu-item" + (item.danger ? " danger" : "");
el.textContent = item.label;
el.onclick = (ev) => {
ev.stopPropagation();
menu.remove();
activeContextMenu = null;
item.action();
};
menu.appendChild(el);
});
document.body.appendChild(menu);
// Position menu, keep within viewport
let x = e.clientX;
let y = e.clientY;
const rect = menu.getBoundingClientRect();
if (x + rect.width > window.innerWidth) x = window.innerWidth - rect.width - 5;
if (y + rect.height > window.innerHeight) y = window.innerHeight - rect.height - 5;
menu.style.left = x + "px";
menu.style.top = y + "px";
activeContextMenu = menu;
}
// Drag state for queue reordering
let draggedIndices = [];
let draggedLibraryIds = [];
let dropTargetIndex = null;
let dragSource = null; // 'queue' or 'library'
// Insert library tracks into queue at position
async function insertTracksAtPosition(trackIds, position) {
if (!M.currentChannelId || trackIds.length === 0) return;
// Build new queue with tracks inserted at position
const newQueue = [...M.queue];
const newTrackIds = [...newQueue.map(t => t.id)];
newTrackIds.splice(position, 0, ...trackIds);
const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ set: newTrackIds })
});
if (res.status === 403) M.flashPermissionDenied();
else if (res.ok) {
M.showToast(`Added ${trackIds.length} track${trackIds.length > 1 ? 's' : ''}`);
M.clearSelections();
}
}
// Reorder queue on server
async function reorderQueue(fromIndices, toIndex) {
if (!M.currentChannelId || fromIndices.length === 0) return;
// Build new queue order
const newQueue = [...M.queue];
// Sort indices descending to remove from end first
const sortedIndices = [...fromIndices].sort((a, b) => b - a);
const movedTracks = [];
// Remove items (in reverse order to preserve indices)
for (const idx of sortedIndices) {
movedTracks.unshift(newQueue.splice(idx, 1)[0]);
}
// Adjust target index for removed items before it
let adjustedTarget = toIndex;
for (const idx of fromIndices) {
if (idx < toIndex) adjustedTarget--;
}
// Insert at new position
newQueue.splice(adjustedTarget, 0, ...movedTracks);
// Send to server
const trackIds = newQueue.map(t => t.id);
const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ set: trackIds })
});
if (res.status === 403) M.flashPermissionDenied();
else if (res.ok) {
M.clearSelections();
}
}
// Toggle selection mode (with optional shift for range select)
M.toggleQueueSelection = function(index, shiftKey = false) {
if (shiftKey && lastSelectedQueueIndex !== null) {
// Range select: select all between last and current
const start = Math.min(lastSelectedQueueIndex, index);
const end = Math.max(lastSelectedQueueIndex, index);
for (let i = start; i <= end; i++) {
M.selectedQueueIndices.add(i);
}
} else {
if (M.selectedQueueIndices.has(index)) {
M.selectedQueueIndices.delete(index);
} else {
M.selectedQueueIndices.add(index);
}
lastSelectedQueueIndex = index;
}
M.renderQueue();
};
M.toggleLibrarySelection = function(index, shiftKey = false) {
if (shiftKey && lastSelectedLibraryIndex !== null) {
// Range select: select all between last and current
const start = Math.min(lastSelectedLibraryIndex, index);
const end = Math.max(lastSelectedLibraryIndex, index);
for (let i = start; i <= end; i++) {
M.selectedLibraryIds.add(M.library[i].id);
}
} else {
const trackId = M.library[index].id;
if (M.selectedLibraryIds.has(trackId)) {
M.selectedLibraryIds.delete(trackId);
} else {
M.selectedLibraryIds.add(trackId);
}
lastSelectedLibraryIndex = index;
}
M.renderLibrary();
};
M.clearSelections = function() {
M.selectedQueueIndices.clear();
M.selectedLibraryIds.clear();
lastSelectedQueueIndex = null;
lastSelectedLibraryIndex = null;
M.renderQueue();
M.renderLibrary();
};
// 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)
// Migration: remove old filename-based cache entries
const oldEntries = cached.filter(id => !id.startsWith("sha256:"));
if (oldEntries.length > 0) {
console.log("[Cache] Migrating: removing", oldEntries.length, "old filename-based entries");
console.log("[Cache] Migrating: removing", oldEntries.length, "old 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");
console.log("[Cache] Updated:", M.cachedTracks.size, "tracks cached");
};
// Debug: log cache status for current track
// Debug functions
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
segments: `${trackCache.size}/${M.SEGMENTS}`,
inCachedTracks: M.cachedTracks.has(M.currentTrackId),
hasBlobUrl: M.trackBlobs.has(M.currentTrackId)
});
};
// 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 ===");
console.log("M.queue tracks:");
M.queue.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)}...`);
console.log(` [${i}] ${t.title?.slice(0, 30)} | cached: ${M.cachedTracks.has(id)}`);
});
};
// Debug: detailed info for a specific track
M.debugTrack = function(index) {
const track = M.queue[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();
@ -398,324 +186,80 @@
M.bulkDownloadStarted.clear();
M.renderQueue();
M.renderLibrary();
console.log("[Cache] All caches cleared. Refresh the page.");
console.log("[Cache] Cleared. Refresh the page.");
};
// Render the current queue
M.renderQueue = function() {
const container = M.$("#queue");
if (!container) return;
container.innerHTML = "";
// Initialize containers
function initContainers() {
const queueEl = M.$("#queue");
const libraryEl = M.$("#library");
const canEdit = M.canControl();
// Setup container-level drag handlers for dropping from library
if (canEdit) {
container.ondragover = (e) => {
if (dragSource === 'library') {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
// If no tracks or hovering at bottom, show we can drop
if (M.queue.length === 0) {
container.classList.add("drop-target");
}
}
};
container.ondragleave = (e) => {
// Only remove if leaving the container entirely
if (!container.contains(e.relatedTarget)) {
container.classList.remove("drop-target");
}
};
container.ondrop = (e) => {
container.classList.remove("drop-target");
// Handle drop on empty queue or at the end
if (dragSource === 'library' && draggedLibraryIds.length > 0) {
e.preventDefault();
const targetIndex = dropTargetIndex !== null ? dropTargetIndex : M.queue.length;
insertTracksAtPosition(draggedLibraryIds, targetIndex);
draggedLibraryIds = [];
dragSource = null;
dropTargetIndex = null;
}
};
if (queueEl && !queueContainer) {
queueContainer = M.trackContainer.createContainer({
type: 'queue',
element: queueEl,
getTracks: () => M.queue,
canReorder: true,
onRender: () => M.updateNowPlayingBar()
});
}
if (M.queue.length === 0) {
container.innerHTML = '<div class="empty drop-zone">Queue empty - drag tracks here</div>';
M.updateNowPlayingBar();
if (libraryEl && !libraryContainer) {
libraryContainer = M.trackContainer.createContainer({
type: 'library',
element: libraryEl,
getTracks: () => M.library,
getFilteredTracks: () => {
const query = M.librarySearchQuery.toLowerCase();
if (!query) {
return M.library.map((track, i) => ({ track, originalIndex: i }));
}
return M.library
.map((track, i) => ({ track, originalIndex: i }))
.filter(({ track }) => {
const title = track.title?.trim() || track.filename || '';
return title.toLowerCase().includes(query);
});
}
});
}
}
// Render functions
M.renderQueue = function() {
initContainers();
if (queueContainer) {
queueContainer.render();
}
updateQueueDuration();
};
function updateQueueDuration() {
const el = M.$("#queue-duration");
if (!el) return;
const totalSecs = M.queue.reduce((sum, t) => sum + (t.duration || 0), 0);
if (totalSecs === 0) {
el.textContent = "";
return;
}
const hours = Math.floor(totalSecs / 3600);
const mins = Math.floor((totalSecs % 3600) / 60);
const secs = Math.floor(totalSecs % 60);
let text = "";
if (hours > 0) text = `${hours}h ${mins}m`;
else if (mins > 0) text = `${mins}m ${secs}s`;
else text = `${secs}s`;
el.textContent = `(${M.queue.length} tracks · ${text})`;
}
// Debug: log first few track cache statuses
if (M.queue.length > 0 && M.cachedTracks.size > 0) {
const sample = M.queue.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.renderLibrary = function() {
initContainers();
if (libraryContainer) {
libraryContainer.render();
}
M.queue.forEach((track, i) => {
const div = document.createElement("div");
const trackId = track.id || track.filename;
const isCached = M.cachedTracks.has(trackId);
const isSelected = M.selectedQueueIndices.has(i);
div.className = "track" + (i === M.currentIndex ? " active" : "") + (isCached ? " cached" : " not-cached") + (isSelected ? " selected" : "");
div.dataset.index = i;
const title = track.title?.trim() || (track.id || track.filename || "Unknown").replace(/\.[^.]+$/, "");
div.title = title; // Tooltip for full name
const checkmark = isSelected ? `<span class="track-checkmark">✓</span>` : '';
const trackNum = `<span class="track-number">${i + 1}.</span>`;
div.innerHTML = `${checkmark}<span class="cache-indicator"></span>${trackNum}<span class="track-title">${title}</span><span class="track-actions"><span class="duration">${M.fmt(track.duration)}</span></span>`;
// Drag and drop for reordering (if user can edit)
if (canEdit) {
div.draggable = true;
div.ondragstart = (e) => {
dragSource = 'queue';
draggedLibraryIds = [];
// If dragging a selected item, drag all selected; otherwise just this one
if (M.selectedQueueIndices.has(i)) {
draggedIndices = [...M.selectedQueueIndices];
} else {
draggedIndices = [i];
}
div.classList.add("dragging");
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", "queue:" + draggedIndices.join(","));
};
div.ondragend = () => {
div.classList.remove("dragging");
draggedIndices = [];
draggedLibraryIds = [];
dragSource = null;
// Clear all drop indicators
container.querySelectorAll(".drop-above, .drop-below").forEach(el => {
el.classList.remove("drop-above", "drop-below");
});
};
div.ondragover = (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
// Determine if dropping above or below
const rect = div.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
const isAbove = e.clientY < midY;
// Clear other indicators
container.querySelectorAll(".drop-above, .drop-below").forEach(el => {
el.classList.remove("drop-above", "drop-below");
});
// Don't show indicator on dragged queue items (for reorder)
if (dragSource === 'queue' && draggedIndices.includes(i)) return;
div.classList.add(isAbove ? "drop-above" : "drop-below");
dropTargetIndex = isAbove ? i : i + 1;
};
div.ondragleave = () => {
div.classList.remove("drop-above", "drop-below");
};
div.ondrop = (e) => {
e.preventDefault();
div.classList.remove("drop-above", "drop-below");
if (dragSource === 'library' && draggedLibraryIds.length > 0 && dropTargetIndex !== null) {
// Insert library tracks at drop position
insertTracksAtPosition(draggedLibraryIds, dropTargetIndex);
} else if (dragSource === 'queue' && draggedIndices.length > 0 && dropTargetIndex !== null) {
// Reorder queue
const minDragged = Math.min(...draggedIndices);
const maxDragged = Math.max(...draggedIndices);
if (dropTargetIndex < minDragged || dropTargetIndex > maxDragged + 1) {
reorderQueue(draggedIndices, dropTargetIndex);
}
}
draggedIndices = [];
draggedLibraryIds = [];
dragSource = null;
dropTargetIndex = null;
};
}
// Click toggles selection
div.onclick = (e) => {
if (e.target.closest('.track-actions')) return;
M.toggleQueueSelection(i, e.shiftKey);
};
// Right-click context menu
div.oncontextmenu = (e) => {
const menuItems = [];
const hasSelection = M.selectedQueueIndices.size > 0;
const selectedCount = hasSelection ? M.selectedQueueIndices.size : 1;
const indicesToRemove = hasSelection ? [...M.selectedQueueIndices] : [i];
// Play track option (only for single track, not bulk)
if (!hasSelection) {
menuItems.push({
label: "▶ Play track",
action: 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();
} else {
M.currentIndex = i;
M.currentTrackId = trackId;
M.serverTrackDuration = track.duration;
M.setTrackTitle(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();
}
}
});
}
// Remove track(s) option (if user can edit)
if (canEdit) {
// Get track IDs for the selected indices
const idsToAdd = hasSelection
? [...M.selectedQueueIndices].map(idx => M.queue[idx]?.id).filter(Boolean)
: [trackId];
// Add again option - duplicate tracks at end of queue
const addAgainLabel = selectedCount > 1 ? ` Add ${selectedCount} again` : " Add again";
menuItems.push({
label: addAgainLabel,
action: async () => {
if (!M.currentChannelId) return;
const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ add: idsToAdd })
});
if (res.status === 403) M.flashPermissionDenied();
else if (res.ok) {
M.showToast(selectedCount > 1 ? `Added ${selectedCount} tracks again` : "Track added again");
M.clearSelections();
}
}
});
// Play next option - insert after current track
const playNextLabel = selectedCount > 1 ? `⏭ Play ${selectedCount} next` : "⏭ Play next";
menuItems.push({
label: playNextLabel,
action: async () => {
if (!M.currentChannelId) return;
const insertAt = (M.currentIndex ?? 0) + 1;
const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ add: idsToAdd, insertAt })
});
if (res.status === 403) M.flashPermissionDenied();
else if (res.ok) {
M.showToast(selectedCount > 1 ? `${selectedCount} tracks playing next` : "Track playing next");
M.clearSelections();
}
}
});
const label = selectedCount > 1 ? `✕ Remove ${selectedCount} tracks` : "✕ Remove track";
menuItems.push({
label,
danger: true,
action: async () => {
if (!M.currentChannelId) return;
const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ remove: indicesToRemove })
});
if (res.status === 403) M.flashPermissionDenied();
else if (res.ok) {
M.showToast(selectedCount > 1 ? `Removed ${selectedCount} tracks` : "Track removed");
M.clearSelections();
}
}
});
}
// Preload track(s) option - only show if not in stream-only mode
if (!M.streamOnly) {
const idsToPreload = hasSelection
? [...M.selectedQueueIndices].map(idx => M.queue[idx]?.id).filter(Boolean)
: [trackId];
const preloadLabel = hasSelection && idsToPreload.length > 1 ? `Preload ${idsToPreload.length} tracks` : "Preload track";
menuItems.push({
label: preloadLabel,
action: () => {
const uncachedIds = idsToPreload.filter(id => !M.cachedTracks.has(id));
if (uncachedIds.length === 0) {
M.showToast("All tracks already cached");
return;
}
M.showToast(`Preloading ${uncachedIds.length} track${uncachedIds.length > 1 ? 's' : ''}...`);
uncachedIds.forEach(id => M.downloadAndCacheTrack(id));
}
});
}
// Download track option (single track only)
if (!hasSelection) {
menuItems.push({
label: "Download",
action: () => downloadTrack(trackId, track.filename)
});
// Copy link option
menuItems.push({
label: "🔗 Generate listening link",
action: () => {
const url = `${location.origin}/listen/${encodeURIComponent(trackId)}`;
navigator.clipboard.writeText(url).then(() => {
M.showToast("Link copied to clipboard");
}).catch(() => {
M.showToast("Failed to copy link", "error");
});
}
});
}
// Clear selection option (if items selected)
if (hasSelection) {
menuItems.push({
label: "Clear selection",
action: () => M.clearSelections()
});
}
showContextMenu(e, menuItems);
};
container.appendChild(div);
});
M.updateNowPlayingBar();
};
// Update the now-playing bar above the queue
// Now-playing bar
M.updateNowPlayingBar = function() {
const bar = M.$("#now-playing-bar");
if (!bar) return;
@ -732,7 +276,6 @@
bar.classList.remove("hidden");
};
// Scroll queue to current track
M.scrollToCurrentTrack = function() {
const container = M.$("#queue");
if (!container) return;
@ -743,226 +286,11 @@
}
};
// Setup now-playing bar click handler
document.addEventListener("DOMContentLoaded", () => {
const bar = M.$("#now-playing-bar");
if (bar) {
bar.onclick = () => M.scrollToCurrentTrack();
}
});
// Library search state
M.librarySearchQuery = "";
// 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;
}
const canEdit = M.canControl();
const query = M.librarySearchQuery.toLowerCase();
// Filter library by search query
const filteredLibrary = query
? M.library.map((track, i) => ({ track, i })).filter(({ track }) => {
const title = track.title?.trim() || track.filename;
return title.toLowerCase().includes(query);
})
: M.library.map((track, i) => ({ track, i }));
if (filteredLibrary.length === 0) {
container.innerHTML = '<div class="empty">No matches</div>';
return;
}
filteredLibrary.forEach(({ track, i }) => {
const div = document.createElement("div");
const isCached = M.cachedTracks.has(track.id);
const isSelected = M.selectedLibraryIds.has(track.id);
div.className = "track" + (isCached ? " cached" : " not-cached") + (isSelected ? " selected" : "");
const title = track.title?.trim() || track.filename.replace(/\.[^.]+$/, "");
div.title = title; // Tooltip for full name
const checkmark = isSelected ? `<span class="track-checkmark">✓</span>` : '';
div.innerHTML = `${checkmark}<span class="cache-indicator"></span><span class="track-title">${title}</span><span class="track-actions"><span class="duration">${M.fmt(track.duration)}</span></span>`;
// Drag from library to queue (if user can edit)
if (canEdit) {
div.draggable = true;
div.ondragstart = (e) => {
dragSource = 'library';
draggedIndices = [];
// If dragging a selected item, drag all selected; otherwise just this one
if (M.selectedLibraryIds.has(track.id)) {
draggedLibraryIds = [...M.selectedLibraryIds];
} else {
draggedLibraryIds = [track.id];
}
div.classList.add("dragging");
e.dataTransfer.effectAllowed = "copy";
e.dataTransfer.setData("text/plain", "library:" + draggedLibraryIds.join(","));
};
div.ondragend = () => {
div.classList.remove("dragging");
draggedIndices = [];
draggedLibraryIds = [];
dragSource = null;
// Clear drop indicators in queue
const queueContainer = M.$("#queue");
if (queueContainer) {
queueContainer.querySelectorAll(".drop-above, .drop-below").forEach(el => {
el.classList.remove("drop-above", "drop-below");
});
}
};
}
// Click toggles selection
div.onclick = (e) => {
if (e.target.closest('.track-actions')) return;
M.toggleLibrarySelection(i, e.shiftKey);
};
// Right-click context menu
div.oncontextmenu = (e) => {
const menuItems = [];
const hasSelection = M.selectedLibraryIds.size > 0;
const selectedCount = hasSelection ? M.selectedLibraryIds.size : 1;
const idsToAdd = hasSelection ? [...M.selectedLibraryIds] : [track.id];
// Play track option (local mode only, single track)
if (!M.synced && !hasSelection) {
menuItems.push({
label: "▶ Play track",
action: async () => {
M.currentTrackId = track.id;
M.serverTrackDuration = track.duration;
M.setTrackTitle(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();
}
});
}
// Add to queue option (if user can edit)
if (canEdit) {
const label = selectedCount > 1 ? ` Add ${selectedCount} to queue` : " Add to queue";
menuItems.push({
label,
action: async () => {
if (!M.currentChannelId) {
M.showToast("No channel selected");
return;
}
const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ add: idsToAdd })
});
if (res.status === 403) M.flashPermissionDenied();
else if (res.ok) {
M.showToast(selectedCount > 1 ? `Added ${selectedCount} tracks` : "Track added to queue");
M.clearSelections();
}
}
});
// Play next option - insert after current track
const playNextLabel = selectedCount > 1 ? `⏭ Play ${selectedCount} next` : "⏭ Play next";
menuItems.push({
label: playNextLabel,
action: async () => {
if (!M.currentChannelId) {
M.showToast("No channel selected");
return;
}
const insertAt = (M.currentIndex ?? 0) + 1;
const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ add: idsToAdd, insertAt })
});
if (res.status === 403) M.flashPermissionDenied();
else if (res.ok) {
M.showToast(selectedCount > 1 ? `${selectedCount} tracks playing next` : "Track playing next");
M.clearSelections();
}
}
});
}
// Preload track(s) option - only show if not in stream-only mode
if (!M.streamOnly) {
const preloadLabel = selectedCount > 1 ? `Preload ${selectedCount} tracks` : "Preload track";
menuItems.push({
label: preloadLabel,
action: () => {
const uncachedIds = idsToAdd.filter(id => !M.cachedTracks.has(id));
if (uncachedIds.length === 0) {
M.showToast("All tracks already cached");
return;
}
M.showToast(`Preloading ${uncachedIds.length} track${uncachedIds.length > 1 ? 's' : ''}...`);
uncachedIds.forEach(id => M.downloadAndCacheTrack(id));
}
});
}
// Download track option (single track only)
if (!hasSelection) {
menuItems.push({
label: "Download",
action: () => downloadTrack(track.id, track.filename)
});
// Copy link option
menuItems.push({
label: "🔗 Generate listening link",
action: () => {
const url = `${location.origin}/listen/${encodeURIComponent(track.id)}`;
navigator.clipboard.writeText(url).then(() => {
M.showToast("Link copied to clipboard");
}).catch(() => {
M.showToast("Failed to copy link", "error");
});
}
});
}
// Export all cached option (if there are cached tracks)
if (M.cachedTracks.size > 0) {
menuItems.push({
label: `Preload and export ${M.cachedTracks.size} cached`,
action: () => M.exportAllCached()
});
}
// Clear selection option (if items selected)
if (hasSelection) {
menuItems.push({
label: "Clear selection",
action: () => M.clearSelections()
});
}
if (menuItems.length > 0) {
showContextMenu(e, menuItems);
}
};
container.appendChild(div);
});
// Backwards compatibility
M.clearSelections = function() {
M.clearAllSelections();
M.renderQueue();
M.renderLibrary();
};
// Load library from server
@ -976,8 +304,13 @@
}
};
// Setup library search
// Setup event listeners
document.addEventListener("DOMContentLoaded", () => {
const bar = M.$("#now-playing-bar");
if (bar) {
bar.onclick = () => M.scrollToCurrentTrack();
}
const searchInput = M.$("#library-search");
if (searchInput) {
searchInput.addEventListener("input", (e) => {

View File

@ -70,6 +70,25 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
.task-item .task-progress { font-size: 0.7rem; color: #888; flex-shrink: 0; }
.task-item .task-bar { position: absolute; left: 0; bottom: 0; height: 2px; background: #ea4; transition: width 0.2s; }
.task-item.complete .task-bar { background: #4e8; }
.slow-queue-section { margin-top: 0.5rem; border-top: 1px solid #333; padding-top: 0.5rem; }
.slow-queue-section.hidden { display: none; }
.slow-queue-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.3rem; padding: 0 0.2rem; gap: 0.5rem; }
.slow-queue-title { font-size: 0.75rem; color: #888; font-weight: 500; }
.slow-queue-timer { font-size: 0.7rem; color: #6af; flex: 1; }
.slow-queue-cancel-all { background: none; border: 1px solid #633; color: #a66; font-size: 0.65rem; padding: 0.15rem 0.4rem; border-radius: 3px; cursor: pointer; transition: all 0.15s; }
.slow-queue-cancel-all:hover { background: #422; border-color: #844; color: #e88; }
.slow-queue-list { display: flex; flex-direction: column; gap: 0.15rem; max-height: 200px; overflow-y: auto; }
.slow-queue-playlist-header { font-size: 0.7rem; color: #888; padding: 0.3rem 0.2rem 0.15rem; margin-top: 0.2rem; border-top: 1px solid #2a2a2a; }
.slow-queue-playlist-header:first-child { border-top: none; margin-top: 0; }
.slow-queue-item { display: flex; align-items: center; gap: 0.4rem; padding: 0.25rem 0.4rem; background: #1a1a2a; border-radius: 3px; font-size: 0.75rem; color: #6af; }
.slow-queue-item.next { background: #1a2a2a; color: #4cf; }
.slow-queue-item-icon { flex-shrink: 0; font-size: 0.7rem; }
.slow-queue-item-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.slow-queue-cancel { background: none; border: none; color: #666; cursor: pointer; padding: 0 0.2rem; font-size: 0.7rem; opacity: 0; transition: opacity 0.15s; }
.slow-queue-item:hover .slow-queue-cancel { opacity: 1; }
.slow-queue-cancel:hover { color: #e44; }
.slow-queue-show-toggle { background: none; border: none; color: #68a; font-size: 0.7rem; padding: 0.3rem; cursor: pointer; width: 100%; text-align: center; }
.slow-queue-show-toggle:hover { color: #8af; text-decoration: underline; }
.scan-progress { font-size: 0.7rem; color: #ea4; padding: 0.2rem 0.4rem; background: #2a2a1a; border-radius: 3px; margin-bottom: 0.3rem; display: flex; align-items: center; gap: 0.4rem; flex-shrink: 0; }
.scan-progress.hidden { display: none; }
.scan-progress.complete { color: #4e8; background: #1a2a1a; }
@ -110,6 +129,7 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
.upload-dropzone.hidden { display: none; }
.dropzone-content { color: #4e8; font-size: 1.2rem; font-weight: 600; }
#queue-title { margin: 0 0 0.3rem 0; }
#queue-duration { font-size: 0.75rem; color: #888; font-weight: normal; }
.now-playing-bar { font-size: 0.75rem; color: #4e8; padding: 0.3rem 0.5rem; background: #1a2a1a; border: 1px solid #2a4a3a; border-radius: 4px; margin-bottom: 0.3rem; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.now-playing-bar:hover { background: #2a3a2a; }
.now-playing-bar.hidden { display: none; }
@ -125,9 +145,9 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
.search-input { flex: 1; background: #222; color: #eee; border: 1px solid #333; padding: 0.2rem 0.4rem; border-radius: 4px; font-size: 0.75rem; }
.search-input::placeholder { color: #666; }
#library, #queue { flex: 1; overflow-y: auto; overflow-x: hidden; min-width: 0; }
#library .track, #queue .track { padding: 0.3rem 0.5rem; border-radius: 4px; cursor: pointer; font-size: 0.85rem; display: flex; justify-content: space-between; align-items: center; position: relative; user-select: none; min-width: 0; }
#library .track[title], #queue .track[title] { cursor: pointer; }
#library .track:hover, #queue .track:hover { background: #222; }
#library .track, #queue .track, #playlist-tracks .track { padding: 0.3rem 0.5rem; border-radius: 4px; cursor: pointer; font-size: 0.85rem; display: flex; justify-content: space-between; align-items: center; position: relative; user-select: none; min-width: 0; }
#library .track[title], #queue .track[title], #playlist-tracks .track[title] { cursor: pointer; }
#library .track:hover, #queue .track:hover, #playlist-tracks .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; }
@ -136,6 +156,10 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
.track-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.track-actions { display: flex; align-items: center; gap: 0.4rem; flex-shrink: 0; }
.track-actions .duration { color: #666; font-size: 0.75rem; }
.track-actions .track-play-btn { width: 22px; height: 22px; display: flex; align-items: center; justify-content: center; background: #333; border: none; border-radius: 3px; font-size: 0.7rem; color: #aaa; cursor: pointer; transition: background 0.2s, color 0.2s; }
.track-actions .track-play-btn:hover { background: #48f; color: #fff; }
.track-actions .track-preview-btn { width: 22px; height: 22px; display: flex; align-items: center; justify-content: center; background: #333; border: none; border-radius: 3px; font-size: 0.7rem; color: #aaa; cursor: pointer; transition: background 0.2s, color 0.2s; }
.track-actions .track-preview-btn:hover { background: #4a4; color: #fff; }
.track-actions .track-add, .track-actions .track-remove { width: 18px; height: 18px; display: flex; align-items: center; justify-content: center; background: #333; border: none; border-radius: 3px; font-size: 0.85rem; color: #ccc; cursor: pointer; opacity: 0; transition: opacity 0.2s; }
.track:hover .track-add, .track:hover .track-remove { opacity: 0.6; }
.track-actions .track-add:hover, .track-actions .track-remove:hover { opacity: 1; background: #444; }
@ -146,9 +170,13 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
.track-checkmark { color: #4e8; font-weight: bold; margin-right: 0.4rem; font-size: 0.85rem; }
.track.selected { background: #2a3a4a; }
.track.dragging { opacity: 0.5; }
/* Allow drop events to pass through to parent track element */
.track > * { pointer-events: none; }
.track > .track-actions { pointer-events: auto; }
.track > .track-actions > * { pointer-events: auto; }
.track.drop-above::before { content: ""; position: absolute; top: -1px; left: 0; right: 0; height: 2px; background: #4e8; border-radius: 2px; }
.track.drop-below::after { content: ""; position: absolute; bottom: -1px; left: 0; right: 0; height: 2px; background: #4e8; border-radius: 2px; }
#queue.drop-target, #queue .drop-zone { border: 2px dashed #4e8; border-radius: 4px; }
#queue.drop-target, #queue .drop-zone, #playlist-tracks.drop-target { border: 2px dashed #4e8; border-radius: 4px; }
#queue .drop-zone { padding: 1.5rem; text-align: center; color: #4e8; }
/* Context menu */
@ -261,6 +289,44 @@ button:hover { background: #333; }
.history-item.history-error { color: #e44; background: #2a1a1a; }
.history-time { color: #666; margin-right: 0.4rem; }
/* Playlists UI */
.playlists-container { display: flex; flex: 1; min-height: 0; gap: 0.5rem; }
#playlists-list-panel { width: 180px; flex-shrink: 0; display: flex; flex-direction: column; overflow: hidden; }
#playlist-contents-panel { flex: 1; display: flex; flex-direction: column; min-width: 0; overflow: hidden; }
.playlists-section { margin-bottom: 0.5rem; }
.playlists-section h4 { color: #666; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.3rem; padding: 0 0.3rem; }
.playlist-item { padding: 0.3rem 0.5rem; border-radius: 4px; cursor: pointer; font-size: 0.8rem; display: flex; align-items: center; gap: 0.3rem; }
.playlist-item:hover { background: #2a2a2a; }
.playlist-item.selected { background: #2a4a3a; }
.playlist-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.playlist-owner { color: #666; font-size: 0.7rem; }
.playlist-count { color: #666; font-size: 0.7rem; background: #333; padding: 0.1rem 0.3rem; border-radius: 3px; }
.empty-playlists { color: #555; font-size: 0.75rem; padding: 0.5rem; font-style: italic; }
.new-playlist-btn { width: 100%; padding: 0.4rem 0.5rem; background: #252525; border: 1px dashed #444; border-radius: 4px; color: #888; font-size: 0.8rem; cursor: pointer; margin-top: auto; }
.new-playlist-btn:hover { background: #2a2a2a; border-color: #4e8; color: #4e8; }
.new-playlist-input, .playlist-rename-input { width: 100%; padding: 0.3rem 0.5rem; background: #222; border: 1px solid #4e8; border-radius: 4px; color: #eee; font-size: 0.8rem; margin-bottom: 0.3rem; outline: none; }
#playlist-contents-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.3rem; padding: 0.3rem; background: #252525; border-radius: 4px; }
#selected-playlist-name { font-size: 0.9rem; font-weight: 600; color: #eee; }
#playlist-actions { display: flex; gap: 0.3rem; }
#playlist-actions.hidden { display: none; }
#playlist-actions button { padding: 0.2rem 0.5rem; font-size: 0.75rem; background: #333; border: 1px solid #444; border-radius: 3px; color: #aaa; cursor: pointer; }
#playlist-actions button:hover { background: #444; color: #eee; }
#playlist-tracks { flex: 1; overflow-y: auto; }
.playlist-track { display: flex; align-items: center; gap: 0.4rem; padding: 0.3rem 0.5rem; border-radius: 4px; font-size: 0.8rem; cursor: pointer; }
.playlist-track:hover { background: #2a2a2a; }
.playlist-track .track-number { color: #555; font-size: 0.7rem; min-width: 1.5rem; }
.playlist-track .track-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.playlist-track .track-duration { color: #666; font-size: 0.75rem; }
.empty-playlist-tracks { color: #555; font-size: 0.8rem; padding: 1rem; text-align: center; font-style: italic; }
.context-menu-separator { height: 1px; background: #333; margin: 0.2rem 0; }
.context-menu-item.has-submenu { position: relative; }
.context-submenu { position: absolute; display: none; top: -4px; left: calc(100% - 8px); padding-left: 8px; background: transparent; min-width: 120px; z-index: 1001; }
.context-submenu-inner { background: #222; border: 1px solid #444; border-radius: 4px; padding: 0.2rem 0; }
.context-menu-item.has-submenu:hover > .context-submenu { display: block; }
.context-submenu .context-menu-item { padding: 0.4rem 0.75rem; }
.context-menu-item.disabled { color: #666; cursor: default; }
.context-menu-item.disabled:hover { background: transparent; }
/* Mobile tab bar - hidden on desktop */
#mobile-tabs { display: none; }

85
public/trackComponent.js Normal file
View File

@ -0,0 +1,85 @@
// MusicRoom - Track Component
// Pure rendering for track rows - no event handlers attached
(function() {
const M = window.MusicRoom;
/**
* Render a track row element (pure rendering, no handlers)
* @param {Object} track - Track object with id, title, filename, duration
* @param {Object} config - Configuration options
* @param {string} config.view - 'queue' | 'library' | 'playlist'
* @param {number} config.index - Index in the list
* @param {number} [config.displayIndex] - Display number (1-based)
* @param {boolean} config.isSelected - Whether track is selected
* @param {boolean} config.isCached - Whether track is cached locally
* @param {boolean} config.isActive - Whether this is the currently playing track
* @param {boolean} config.draggable - Whether element is draggable
* @returns {HTMLElement}
*/
function render(track, config) {
const {
view,
index,
displayIndex,
isSelected,
isCached,
isActive,
draggable
} = config;
const div = document.createElement("div");
const trackId = track.id || track.filename;
// Build class list
const classes = ["track"];
if (isActive) classes.push("active");
if (isCached) classes.push("cached");
else classes.push("not-cached");
if (isSelected) classes.push("selected");
div.className = classes.join(" ");
// Store data attributes
div.dataset.index = index;
div.dataset.trackId = trackId;
div.dataset.view = view;
// Build title
const title = track.title?.trim() || (track.filename || trackId || "Unknown").replace(/\.[^.]+$/, "");
div.title = title;
// Build HTML
const checkmark = isSelected ? '<span class="track-checkmark">✓</span>' : '';
const trackNum = displayIndex != null ? `<span class="track-number">${displayIndex}.</span>` : '';
div.innerHTML = `
${checkmark}
<span class="cache-indicator"></span>
${trackNum}
<span class="track-title">${escapeHtml(title)}</span>
<span class="track-actions">
<span class="duration">${M.fmt(track.duration)}</span>
</span>
`;
if (draggable) {
div.draggable = true;
}
return div;
}
// HTML escape helper
function escapeHtml(str) {
if (!str) return '';
return str.replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
})[c]);
}
// Export
M.trackComponent = {
render,
escapeHtml
};
})();

908
public/trackContainer.js Normal file
View File

@ -0,0 +1,908 @@
// MusicRoom - Track Container
// Manages track lists with selection, drag-and-drop, and context menus
(function() {
const M = window.MusicRoom;
// Track if a drag is in progress (to prevent re-renders from canceling it)
let isDragging = false;
// Selection state per container type
const selection = {
queue: new Set(), // indices
library: new Set(), // track IDs
playlist: new Set() // indices (for duplicate support)
};
// Last selected for shift-select
const lastSelected = {
queue: null,
library: null,
playlist: null
};
// Drag state (shared across containers)
let dragSource = null;
let draggedIndices = [];
let draggedTrackIds = [];
let dropTargetIndex = null;
// Active context menu
let activeContextMenu = null;
/**
* Create a track container manager
* @param {Object} config
* @param {string} config.type - 'queue' | 'library' | 'playlist'
* @param {HTMLElement} config.element - Container DOM element
* @param {Function} config.getTracks - Returns array of tracks to render
* @param {Function} [config.getFilteredTracks] - Returns filtered tracks (for library search)
* @param {boolean} [config.canEditQueue] - Whether user can modify queue
* @param {boolean} [config.canReorder] - Whether tracks can be reordered (queue only)
* @param {boolean} [config.isPlaylistOwner] - Whether user owns the playlist (can remove/reorder)
* @param {string} [config.playlistId] - Playlist ID (for playlist type)
* @param {Function} [config.onRender] - Callback after render
*/
function createContainer(config) {
const {
type,
element,
getTracks,
getFilteredTracks,
canReorder = false,
isPlaylistOwner = false,
playlistId = null,
onRender
} = config;
let currentTracks = [];
// Get canEditQueue dynamically (permissions may change)
const getCanEditQueue = () => config.canEditQueue ?? M.canControl();
// Track if this container needs a render after drag ends
let pendingRender = false;
function render() {
// Defer render if a drag is in progress (would cancel the drag)
if (isDragging) {
pendingRender = true;
return;
}
pendingRender = false;
const canEditQueue = getCanEditQueue();
element.innerHTML = "";
// Get tracks (filtered for library, direct for queue/playlist)
currentTracks = getFilteredTracks ? getFilteredTracks() : getTracks();
// Always wire up container drop handlers first (even for empty containers)
if (canEditQueue && type === 'queue') {
wireQueueContainerDrop(element);
}
if (isPlaylistOwner && type === 'playlist' && playlistId) {
wirePlaylistContainerDrop(element);
}
if (currentTracks.length === 0) {
const emptyMsg = type === 'queue' ? 'Queue empty - drag tracks here'
: type === 'library' ? 'No tracks'
: 'No tracks - drag here to add';
element.innerHTML = `<div class="empty">${emptyMsg}</div>`;
if (onRender) onRender();
return;
}
currentTracks.forEach((item, filteredIndex) => {
// item can be { track, originalIndex } or just track
const track = item.track || item;
const index = type === 'queue' ? filteredIndex : (item.originalIndex ?? filteredIndex);
const trackId = track.id || track.filename;
// Queue and playlist use indices, library uses trackIds
const isSelected = type === 'library'
? selection.library.has(trackId)
: selection[type].has(index);
const isCached = M.cachedTracks.has(trackId);
const isActive = type === 'queue' && index === M.currentIndex;
// Library/playlist always draggable (read access), queue needs edit permission
const isDraggable = type === 'library' || type === 'playlist' || (type === 'queue' && canEditQueue);
const div = M.trackComponent.render(track, {
view: type,
index: filteredIndex,
displayIndex: type === 'queue' || type === 'playlist' ? filteredIndex + 1 : null,
isSelected,
isCached,
isActive,
draggable: isDraggable
});
// Wire up event handlers
wireTrackEvents(div, track, filteredIndex, index, canEditQueue);
element.appendChild(div);
});
if (onRender) onRender();
}
function wirePlaylistContainerDrop(container) {
container.ondragover = (e) => {
if (dragSource === 'queue' || dragSource === 'library' || dragSource === 'playlist') {
e.preventDefault();
e.dataTransfer.dropEffect = dragSource === 'playlist' ? "move" : "copy";
container.classList.add("drop-target");
}
};
container.ondragleave = (e) => {
if (!container.contains(e.relatedTarget)) {
container.classList.remove("drop-target");
}
};
container.ondrop = (e) => {
container.classList.remove("drop-target");
// Clear any drop indicators on tracks
container.querySelectorAll(".drop-above, .drop-below").forEach(el => {
el.classList.remove("drop-above", "drop-below");
});
if (draggedTrackIds.length > 0) {
e.preventDefault();
// If dropTargetIndex was set by a track, use that position
// Otherwise append to end
const targetPos = dropTargetIndex !== null ? dropTargetIndex : currentTracks.length;
if (dragSource === 'playlist') {
reorderPlaylist(draggedIndices, targetPos);
} else if (dragSource === 'queue' || dragSource === 'library') {
if (currentTracks.length === 0) {
addTracksToPlaylist(draggedTrackIds);
} else {
addTracksToPlaylistAt(draggedTrackIds, targetPos);
}
}
draggedTrackIds = [];
draggedIndices = [];
dragSource = null;
dropTargetIndex = null;
}
};
}
async function addTracksToPlaylist(trackIds) {
if (!playlistId) return;
const res = await fetch(`/api/playlists/${playlistId}/tracks`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ add: trackIds })
});
if (res.ok) {
M.showToast(`Added ${trackIds.length} track${trackIds.length > 1 ? 's' : ''} to playlist`);
if (M.playlists) M.playlists.reloadCurrentPlaylist();
} else {
M.showToast("Failed to add to playlist", "error");
}
}
async function addTracksToPlaylistAt(trackIds, position) {
if (!playlistId) return;
// Get current tracks and insert at position
const current = currentTracks.map(t => (t.track || t).id);
const newList = [...current.slice(0, position), ...trackIds, ...current.slice(position)];
const res = await fetch(`/api/playlists/${playlistId}/tracks`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ set: newList })
});
if (res.ok) {
M.showToast(`Added ${trackIds.length} track${trackIds.length > 1 ? 's' : ''}`);
if (M.playlists) M.playlists.reloadCurrentPlaylist();
} else {
M.showToast("Failed to add to playlist", "error");
}
}
async function reorderPlaylist(indices, targetIndex) {
if (!playlistId) return;
// Get current track IDs
const current = currentTracks.map(t => (t.track || t).id);
// Sort indices descending for safe removal
const sortedIndices = [...indices].sort((a, b) => b - a);
// Get track IDs being moved
const movedTrackIds = indices.map(i => current[i]);
// Calculate insertion position (adjusted for removed items before target)
let insertAt = targetIndex;
for (const idx of indices) {
if (idx < targetIndex) {
insertAt--;
}
}
// Remove tracks at indices (from end to preserve indices)
const remaining = current.filter((_, i) => !indices.includes(i));
insertAt = Math.max(0, Math.min(insertAt, remaining.length));
// Insert at new position
const newList = [...remaining.slice(0, insertAt), ...movedTrackIds, ...remaining.slice(insertAt)];
const res = await fetch(`/api/playlists/${playlistId}/tracks`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ set: newList })
});
if (res.ok) {
selection.playlist.clear();
lastSelected.playlist = null;
if (M.playlists) M.playlists.reloadCurrentPlaylist();
} else {
M.showToast("Failed to reorder playlist", "error");
}
}
function wireTrackEvents(div, track, filteredIndex, originalIndex, canEditQueue) {
const trackId = track.id || track.filename;
const index = type === 'queue' ? originalIndex : filteredIndex;
// Click/double-click handling - delay render to allow double-click detection
let clickTimeout = null;
div.onclick = (e) => {
if (e.target.closest('.track-actions')) return;
toggleSelection(index, trackId, e.shiftKey, e.ctrlKey || e.metaKey);
clearTimeout(clickTimeout);
clickTimeout = setTimeout(() => render(), 200);
};
// Double-click - queue: jump to track, library/playlist: add to queue
div.ondblclick = (e) => {
if (e.target.closest('.track-actions')) return;
clearTimeout(clickTimeout);
if (type === 'queue') {
M.jumpToTrack(originalIndex);
} else {
addToQueue([trackId]);
}
};
// Context menu
div.oncontextmenu = (e) => {
e.preventDefault();
showContextMenu(e, track, originalIndex, canEditQueue);
};
// Drag start/end handlers - library/playlist always (read access), queue needs edit permission
const canDrag = type === 'library' || type === 'playlist' || (type === 'queue' && canEditQueue);
if (canDrag) {
div.ondragstart = (e) => handleDragStart(e, track, originalIndex, div);
div.ondragend = (e) => handleDragEnd(e, div);
}
// Drop handlers - queue and playlist accept drops
if (canEditQueue && type === 'queue') {
div.ondragover = (e) => handleDragOver(e, div, originalIndex);
div.ondragleave = (e) => handleDragLeave(e, div);
div.ondrop = (e) => handleDrop(e, div, originalIndex);
}
if (type === 'playlist' && playlistId && isPlaylistOwner) {
div.ondragover = (e) => handleDragOver(e, div, filteredIndex);
div.ondragleave = (e) => handleDragLeave(e, div);
div.ondrop = (e) => handleDrop(e, div, filteredIndex);
}
}
function toggleSelection(index, trackId, shiftKey, ctrlKey) {
// Queue and playlist use indices, library uses trackIds
const key = type === 'library' ? trackId : index;
const sel = selection[type];
const currentIdx = type === 'library' ? getFilteredIndex(trackId) : index;
if (shiftKey && lastSelected[type] !== null) {
// Shift+click: Range select (add to existing selection)
const start = Math.min(lastSelected[type], currentIdx);
const end = Math.max(lastSelected[type], currentIdx);
for (let i = start; i <= end; i++) {
if (type === 'library') {
const t = currentTracks[i];
if (t) {
const id = (t.track || t).id;
if (id) sel.add(id);
}
} else {
// Queue and playlist use indices
sel.add(i);
}
}
} else if (ctrlKey) {
// Ctrl+click: Toggle single item
if (sel.has(key)) {
sel.delete(key);
} else {
sel.add(key);
}
lastSelected[type] = currentIdx;
} else {
// Plain click: Select only this item (clear others)
sel.clear();
sel.add(key);
lastSelected[type] = currentIdx;
}
}
function getFilteredIndex(trackId) {
return currentTracks.findIndex(t => ((t.track || t).id) === trackId);
}
function handleDragStart(e, track, index, div) {
console.log(`[Drag] handleDragStart: type=${type} index=${index} track=${track.title || track.filename}`);
isDragging = true;
const trackId = track.id || track.filename;
dragSource = type;
if (type === 'queue') {
draggedIndices = selection.queue.has(index) ? [...selection.queue] : [index];
draggedTrackIds = draggedIndices.map(i => M.queue[i]?.id).filter(Boolean);
} else if (type === 'playlist') {
// Playlist uses indices for selection (supports duplicates)
draggedIndices = selection.playlist.has(index) ? [...selection.playlist] : [index];
draggedTrackIds = draggedIndices.map(i => {
const t = currentTracks[i];
return t ? (t.track || t).id : null;
}).filter(Boolean);
} else {
// Library uses trackIds
draggedTrackIds = selection.library.has(trackId) ? [...selection.library] : [trackId];
draggedIndices = [];
}
div.classList.add("dragging");
// Use "copyMove" to allow both copy and move operations
e.dataTransfer.effectAllowed = "copyMove";
e.dataTransfer.setData("text/plain", `${type}:${draggedTrackIds.join(",")}`);
}
function handleDragEnd(e, div) {
isDragging = false;
div.classList.remove("dragging");
draggedIndices = [];
draggedTrackIds = [];
dragSource = null;
dropTargetIndex = null;
// Clear all drop indicators
element.querySelectorAll(".drop-above, .drop-below").forEach(el => {
el.classList.remove("drop-above", "drop-below");
});
// Execute deferred render if any
if (pendingRender) {
setTimeout(() => render(), 50);
}
}
function handleDragOver(e, div, index) {
e.preventDefault();
// Set drop effect based on source
if (type === 'queue') {
e.dataTransfer.dropEffect = dragSource === 'queue' ? "move" : "copy";
} else if (type === 'playlist') {
e.dataTransfer.dropEffect = dragSource === 'playlist' ? "move" : "copy";
}
const rect = div.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
const isAbove = e.clientY < midY;
// Clear other indicators
element.querySelectorAll(".drop-above, .drop-below").forEach(el => {
el.classList.remove("drop-above", "drop-below");
});
// Don't show indicator on dragged items
if (dragSource === type && draggedIndices.includes(index)) return;
div.classList.add(isAbove ? "drop-above" : "drop-below");
dropTargetIndex = isAbove ? index : index + 1;
}
function handleDragLeave(e, div) {
div.classList.remove("drop-above", "drop-below");
}
function handleDrop(e, div, index) {
console.log(`[Drag] handleDrop: type=${type} index=${index} dropTargetIndex=${dropTargetIndex} dragSource=${dragSource} draggedIndices=${draggedIndices}`);
e.preventDefault();
e.stopPropagation();
div.classList.remove("drop-above", "drop-below");
element.classList.remove("drop-target");
if (dropTargetIndex === null) {
console.log(`[Drag] handleDrop: dropTargetIndex is null, aborting`);
return;
}
if (type === 'queue') {
if (dragSource === 'queue' && draggedIndices.length > 0) {
// Reorder within queue
const minDragged = Math.min(...draggedIndices);
const maxDragged = Math.max(...draggedIndices);
console.log(`[Drag] Reorder check: dropTargetIndex=${dropTargetIndex} minDragged=${minDragged} maxDragged=${maxDragged}`);
if (dropTargetIndex < minDragged || dropTargetIndex > maxDragged + 1) {
console.log(`[Drag] Calling reorderQueue(${draggedIndices}, ${dropTargetIndex})`);
reorderQueue(draggedIndices, dropTargetIndex);
} else {
console.log(`[Drag] Skipping reorder - dropping on self`);
}
} else if ((dragSource === 'library' || dragSource === 'playlist') && draggedTrackIds.length > 0) {
// Insert tracks from library or playlist
insertTracksAtPosition(draggedTrackIds, dropTargetIndex);
}
} else if (type === 'playlist') {
if (dragSource === 'playlist' && draggedIndices.length > 0) {
// Reorder within playlist
reorderPlaylist(draggedIndices, dropTargetIndex);
} else if ((dragSource === 'queue' || dragSource === 'library') && draggedTrackIds.length > 0) {
// Insert at position
addTracksToPlaylistAt(draggedTrackIds, dropTargetIndex);
}
}
draggedIndices = [];
draggedTrackIds = [];
dragSource = null;
dropTargetIndex = null;
}
function wireQueueContainerDrop(container) {
container.ondragover = (e) => {
if (dragSource === 'library' || dragSource === 'playlist') {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
if (M.queue.length === 0) {
container.classList.add("drop-target");
}
}
};
container.ondragleave = (e) => {
if (!container.contains(e.relatedTarget)) {
container.classList.remove("drop-target");
}
};
container.ondrop = (e) => {
container.classList.remove("drop-target");
if ((dragSource === 'library' || dragSource === 'playlist') && draggedTrackIds.length > 0) {
e.preventDefault();
const targetIndex = dropTargetIndex !== null ? dropTargetIndex : M.queue.length;
insertTracksAtPosition(draggedTrackIds, targetIndex);
draggedTrackIds = [];
dragSource = null;
dropTargetIndex = null;
}
};
}
async function reorderQueue(indices, targetIndex) {
if (!M.currentChannelId) return;
const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ move: indices, to: targetIndex })
});
if (res.status === 403) M.flashPermissionDenied();
else if (res.ok) {
selection.queue.clear();
lastSelected.queue = null;
} else {
M.showToast("Failed to reorder queue", "error");
}
}
async function insertTracksAtPosition(trackIds, position) {
if (!M.currentChannelId) return;
const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ add: trackIds, insertAt: position })
});
if (res.status === 403) M.flashPermissionDenied();
else if (res.ok) {
M.showToast(`Added ${trackIds.length} track${trackIds.length > 1 ? 's' : ''}`);
clearSelection();
} else {
M.showToast("Failed to add tracks", "error");
}
}
async function playTrack(track, index) {
const trackId = track.id || track.filename;
const title = track.title?.trim() || (track.filename || trackId || "Unknown").replace(/\.[^.]+$/, "");
if (type === 'queue') {
// Jump to track in queue
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 })
});
if (res.status === 403) M.flashPermissionDenied();
} else {
// Local playback
M.currentIndex = index;
M.currentTrackId = trackId;
M.serverTrackDuration = track.duration;
M.setTrackTitle(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();
render();
}
}
}
async function previewTrack(track) {
const trackId = track.id || track.filename;
const title = track.title?.trim() || (track.filename || trackId || "Unknown").replace(/\.[^.]+$/, "");
M.currentTrackId = trackId;
M.serverTrackDuration = track.duration;
M.setTrackTitle(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();
// Desync and disable auto-resync
if (M.synced || M.wantSync) {
M.synced = false;
M.wantSync = false;
M.showToast("Previewing track (desynced)");
M.updateUI();
}
}
function showContextMenu(e, track, index, canEditQueue) {
const trackId = track.id || track.filename;
const title = track.title?.trim() || (track.filename || trackId || "Unknown").replace(/\.[^.]+$/, "");
const sel = selection[type];
const hasSelection = sel.size > 0;
const selectedCount = hasSelection ? sel.size : 1;
// Get IDs/indices for bulk operations
let idsForAction, indicesToRemove;
if (type === 'queue') {
indicesToRemove = hasSelection ? [...sel] : [index];
idsForAction = indicesToRemove.map(i => M.queue[i]?.id).filter(Boolean);
} else if (type === 'playlist') {
// Playlist uses indices for selection/removal (supports duplicates)
indicesToRemove = hasSelection ? [...sel] : [index];
idsForAction = indicesToRemove.map(i => currentTracks[i]?.track?.id || currentTracks[i]?.id).filter(Boolean);
} else {
// Library uses trackIds
idsForAction = hasSelection ? [...sel] : [trackId];
}
const menuItems = [];
// Play (queue only, single track or single selection)
if (type === 'queue' && selectedCount === 1) {
menuItems.push({
label: "▶ Play",
action: () => playTrack(track, index)
});
}
// Preview (all views, single track or single selection)
if (selectedCount === 1) {
menuItems.push({
label: "⏵ Preview",
action: () => previewTrack(track)
});
}
// Queue actions
if (type === 'queue' && canEditQueue) {
menuItems.push({
label: selectedCount > 1 ? ` Add ${selectedCount} again` : " Add again",
action: () => addToQueue(idsForAction)
});
menuItems.push({
label: selectedCount > 1 ? `⏭ Play ${selectedCount} next` : "⏭ Play next",
action: () => addToQueue(idsForAction, true)
});
menuItems.push({
label: selectedCount > 1 ? `✕ Remove ${selectedCount}` : "✕ Remove",
danger: true,
action: () => removeFromQueue(indicesToRemove)
});
}
// Library actions - can add to queue if user has queue edit permission
if (type === 'library' && canEditQueue) {
menuItems.push({
label: selectedCount > 1 ? ` Add ${selectedCount} to queue` : " Add to queue",
action: () => addToQueue(idsForAction)
});
menuItems.push({
label: selectedCount > 1 ? `⏭ Play ${selectedCount} next` : "⏭ Play next",
action: () => addToQueue(idsForAction, true)
});
}
// Playlist actions
if (type === 'playlist') {
// Can add to queue if user has queue edit permission
if (canEditQueue) {
menuItems.push({
label: selectedCount > 1 ? ` Add ${selectedCount} to queue` : " Add to queue",
action: () => addToQueue(idsForAction)
});
menuItems.push({
label: selectedCount > 1 ? `⏭ Play ${selectedCount} next` : "⏭ Play next",
action: () => addToQueue(idsForAction, true)
});
}
// Can remove if user owns the playlist
if (isPlaylistOwner && playlistId) {
menuItems.push({
label: selectedCount > 1 ? `🗑️ Remove ${selectedCount}` : "🗑️ Remove",
danger: true,
action: () => removeFromPlaylist(indicesToRemove)
});
}
}
// Preload (library/queue, non-stream mode)
if ((type === 'library' || type === 'queue') && !M.streamOnly) {
menuItems.push({
label: selectedCount > 1 ? `Preload ${selectedCount}` : "Preload",
action: () => {
const uncached = idsForAction.filter(id => !M.cachedTracks.has(id));
if (uncached.length === 0) {
M.showToast("Already cached");
return;
}
M.showToast(`Preloading ${uncached.length}...`);
uncached.forEach(id => M.downloadAndCacheTrack(id));
}
});
}
// Add to Playlist
if (M.playlists && !M.currentUser?.is_guest) {
const submenu = M.playlists.showAddToPlaylistMenu(idsForAction);
if (submenu && submenu.length > 0) {
menuItems.push({
label: idsForAction.length > 1 ? `📁 Add ${idsForAction.length} to Playlist...` : "📁 Add to Playlist...",
submenu
});
}
}
// Copy link (single)
if (!hasSelection) {
menuItems.push({
label: "🔗 Copy link",
action: () => {
navigator.clipboard.writeText(`${location.origin}/listen/${encodeURIComponent(trackId)}`);
M.showToast("Link copied");
}
});
}
// Clear selection
if (hasSelection) {
menuItems.push({
label: "Clear selection",
action: () => { clearSelection(); render(); }
});
}
M.contextMenu.show(e, menuItems);
}
async function addToQueue(trackIds, playNext = false) {
if (!M.currentChannelId) {
M.showToast("No channel selected");
return;
}
const body = playNext
? { add: trackIds, insertAt: (M.currentIndex ?? 0) + 1 }
: { add: trackIds };
const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
if (res.status === 403) M.flashPermissionDenied();
else if (res.ok) {
M.showToast(playNext ? "Playing next" : "Added to queue");
clearSelection();
render();
} else {
M.showToast("Failed to add to queue", "error");
}
}
async function removeFromQueue(indices) {
if (!M.currentChannelId) return;
const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ remove: indices })
});
if (res.status === 403) M.flashPermissionDenied();
else if (res.ok) {
M.showToast("Removed");
clearSelection();
render();
} else {
M.showToast("Failed to remove from queue", "error");
}
}
async function removeFromPlaylist(indices) {
if (!playlistId) return;
const res = await fetch(`/api/playlists/${playlistId}/tracks`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ remove: indices })
});
if (res.ok) {
M.showToast("Removed from playlist");
clearSelection();
if (M.playlists) M.playlists.reloadCurrentPlaylist();
} else {
M.showToast("Failed to remove from playlist", "error");
}
}
function clearSelection() {
selection[type].clear();
lastSelected[type] = null;
}
function getSelection() {
return [...selection[type]];
}
return {
render,
clearSelection,
getSelection,
get currentTracks() { return currentTracks; }
};
}
// Context menu rendering (shared)
function showContextMenuUI(e, items) {
e.preventDefault();
hideContextMenu();
const menu = document.createElement("div");
menu.className = "context-menu";
items.forEach(item => {
if (item.separator) {
const sep = document.createElement("div");
sep.className = "context-menu-separator";
menu.appendChild(sep);
return;
}
const el = document.createElement("div");
el.className = "context-menu-item" + (item.danger ? " danger" : "") + (item.disabled ? " disabled" : "");
el.textContent = item.label;
if (item.submenu) {
el.classList.add("has-submenu");
el.innerHTML += ' ▸';
const sub = document.createElement("div");
sub.className = "context-submenu";
const subInner = document.createElement("div");
subInner.className = "context-submenu-inner";
item.submenu.forEach(subItem => {
const subEl = document.createElement("div");
subEl.className = "context-menu-item";
subEl.textContent = subItem.label;
subEl.onclick = (e) => {
e.stopPropagation();
hideContextMenu();
subItem.action();
};
subInner.appendChild(subEl);
});
sub.appendChild(subInner);
el.appendChild(sub);
} else if (!item.disabled) {
el.onclick = () => {
hideContextMenu();
item.action();
};
}
menu.appendChild(el);
});
menu.style.left = e.clientX + "px";
menu.style.top = e.clientY + "px";
document.body.appendChild(menu);
// Adjust if off-screen
const rect = menu.getBoundingClientRect();
if (rect.right > window.innerWidth) {
menu.style.left = (window.innerWidth - rect.width - 5) + "px";
}
if (rect.bottom > window.innerHeight) {
menu.style.top = (window.innerHeight - rect.height - 5) + "px";
}
activeContextMenu = menu;
// Close on click outside
setTimeout(() => {
document.addEventListener("click", hideContextMenu, { once: true });
}, 0);
}
function hideContextMenu() {
if (activeContextMenu) {
activeContextMenu.remove();
activeContextMenu = null;
}
}
// Export
M.trackContainer = { createContainer };
M.contextMenu = {
show: showContextMenuUI,
hide: hideContextMenu
};
// Clear all selections helper
M.clearAllSelections = function() {
Object.keys(selection).forEach(k => {
selection[k].clear();
lastSelected[k] = null;
});
};
})();

View File

@ -12,6 +12,10 @@
const tasksList = M.$("#tasks-list");
const tasksEmpty = M.$("#tasks-empty");
// Slow queue section elements (created dynamically)
let slowQueueSection = null;
let slowQueuePollInterval = null;
if (!addBtn || !fileInput || !dropzone) return;
function openPanel() {
@ -103,20 +107,23 @@
if (data.type === "playlist") {
// Ask user to confirm playlist download
const confirmed = confirm(`Download playlist "${data.title}" with ${data.count} items?\n\nItems will be downloaded slowly (one every ~3 minutes) to avoid overloading the server.`);
const confirmed = confirm(`Download playlist "${data.title}" with ${data.count} items?\n\nItems will be downloaded slowly (one every ~3 minutes) to avoid overloading the server.\n\nA playlist will be created automatically.`);
if (confirmed) {
// Confirm playlist download
// Confirm playlist download with title for auto-playlist creation
const confirmRes = await fetch("/api/fetch/confirm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items: data.items })
body: JSON.stringify({ items: data.items, playlistTitle: data.title })
});
if (confirmRes.ok) {
const confirmData = await confirmRes.json();
M.showToast(confirmData.message);
// Tasks will be created by WebSocket progress messages
M.showToast(`${confirmData.message} → "${confirmData.playlistName}"`);
// Refresh playlists to show the new one
if (M.playlists?.load) M.playlists.load();
// Refresh slow queue display
pollSlowQueue();
} else {
const err = await confirmRes.json().catch(() => ({}));
M.showToast(err.error || "Failed to queue playlist", "error");
@ -200,10 +207,187 @@
const fetchTasks = new Map(); // Map<id, taskHandle>
function updateTasksEmpty() {
const hasTasks = tasksList.children.length > 0;
tasksEmpty.classList.toggle("hidden", hasTasks);
const hasActiveTasks = tasksList.children.length > 0;
const hasSlowQueue = slowQueueSection && !slowQueueSection.classList.contains("hidden");
tasksEmpty.classList.toggle("hidden", hasActiveTasks || hasSlowQueue);
}
// Slow queue display
function createSlowQueueSection() {
if (slowQueueSection) return slowQueueSection;
slowQueueSection = document.createElement("div");
slowQueueSection.className = "slow-queue-section hidden";
slowQueueSection.innerHTML = `
<div class="slow-queue-header">
<span class="slow-queue-title">Playlist Queue</span>
<span class="slow-queue-timer"></span>
<button class="slow-queue-cancel-all" title="Cancel all">Cancel All</button>
</div>
<div class="slow-queue-list"></div>
`;
// Wire up cancel all button
slowQueueSection.querySelector(".slow-queue-cancel-all").onclick = async () => {
try {
const res = await fetch("/api/fetch", { method: "DELETE" });
if (res.ok) {
const data = await res.json();
M.showToast(data.message);
pollSlowQueue();
} else {
M.showToast("Failed to cancel", "error");
}
} catch (e) {
M.showToast("Failed to cancel", "error");
}
};
// Insert before tasks-empty
tasksEmpty.parentNode.insertBefore(slowQueueSection, tasksEmpty);
return slowQueueSection;
}
function formatTime(seconds) {
if (seconds <= 0) return "now";
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
if (mins > 0) return `${mins}m ${secs}s`;
return `${secs}s`;
}
const QUEUE_PREVIEW_COUNT = 5;
let showAllQueue = false;
function updateSlowQueueDisplay(slowQueue, slowQueueNextIn) {
const section = createSlowQueueSection();
const queuedItems = slowQueue.filter(i => i.status === "queued");
if (queuedItems.length === 0) {
section.classList.add("hidden");
showAllQueue = false;
updateTasksEmpty();
return;
}
section.classList.remove("hidden");
// Update header with count and timer
const timerEl = section.querySelector(".slow-queue-timer");
timerEl.textContent = `${queuedItems.length} queued · next in ${formatTime(slowQueueNextIn)}`;
// Determine how many items to show
const itemsToShow = showAllQueue ? queuedItems : queuedItems.slice(0, QUEUE_PREVIEW_COUNT);
const hiddenCount = queuedItems.length - itemsToShow.length;
// Group items by playlist
const byPlaylist = new Map();
for (const item of itemsToShow) {
const key = item.playlistId || "__none__";
if (!byPlaylist.has(key)) {
byPlaylist.set(key, { name: item.playlistName, items: [] });
}
byPlaylist.get(key).items.push(item);
}
// Update list
const listEl = section.querySelector(".slow-queue-list");
let html = "";
for (const [playlistId, group] of byPlaylist) {
if (group.name) {
html += `<div class="slow-queue-playlist-header">📁 ${group.name}</div>`;
}
html += group.items.map((item, i) => {
const isNext = queuedItems.indexOf(item) === 0;
return `
<div class="slow-queue-item${isNext ? ' next' : ''}" data-id="${item.id}">
<span class="slow-queue-item-icon">${isNext ? '⏳' : '·'}</span>
<span class="slow-queue-item-title">${item.title}</span>
<button class="slow-queue-cancel" title="Cancel"></button>
</div>
`;
}).join("");
}
// Add show more/less button if needed
if (queuedItems.length > QUEUE_PREVIEW_COUNT) {
if (showAllQueue) {
html += `<button class="slow-queue-show-toggle">Show less</button>`;
} else {
html += `<button class="slow-queue-show-toggle">Show ${hiddenCount} more...</button>`;
}
}
listEl.innerHTML = html;
// Add cancel handlers
listEl.querySelectorAll(".slow-queue-cancel").forEach(btn => {
btn.onclick = async (e) => {
e.stopPropagation();
const itemEl = btn.closest(".slow-queue-item");
const itemId = itemEl.dataset.id;
try {
const res = await fetch(`/api/fetch/${itemId}`, { method: "DELETE" });
if (res.ok) {
itemEl.remove();
pollSlowQueue();
} else {
M.showToast("Cannot cancel item", "error");
}
} catch (e) {
M.showToast("Failed to cancel", "error");
}
};
});
// Add show more/less handler
const toggleBtn = listEl.querySelector(".slow-queue-show-toggle");
if (toggleBtn) {
toggleBtn.onclick = () => {
showAllQueue = !showAllQueue;
updateSlowQueueDisplay(slowQueue, slowQueueNextIn);
};
}
updateTasksEmpty();
}
async function pollSlowQueue() {
if (!M.currentUser || M.currentUser.isGuest) return;
try {
const res = await fetch("/api/fetch");
if (res.ok) {
const data = await res.json();
updateSlowQueueDisplay(data.slowQueue || [], data.slowQueueNextIn || 0);
}
} catch (e) {
// Ignore poll errors
}
}
function startSlowQueuePoll() {
if (slowQueuePollInterval) return;
pollSlowQueue();
slowQueuePollInterval = setInterval(pollSlowQueue, 5000);
}
function stopSlowQueuePoll() {
if (slowQueuePollInterval) {
clearInterval(slowQueuePollInterval);
slowQueuePollInterval = null;
}
if (slowQueueSection) {
slowQueueSection.classList.add("hidden");
updateTasksEmpty();
}
}
// Expose start/stop for auth module to call
M.startSlowQueuePoll = startSlowQueuePoll;
M.stopSlowQueuePoll = stopSlowQueuePoll;
// Handle WebSocket fetch progress messages
M.handleFetchProgress = function(data) {
let task = fetchTasks.get(data.id);
@ -220,9 +404,13 @@
} else if (data.status === "complete") {
task.setComplete();
fetchTasks.delete(data.id);
// Refresh slow queue on completion
pollSlowQueue();
} else if (data.status === "error") {
task.setError(data.error || "Failed");
fetchTasks.delete(data.id);
// Refresh slow queue on error
pollSlowQueue();
}
};

View File

@ -221,7 +221,7 @@ export async function handleModifyQueue(req: Request, server: any, channelId: st
try {
const body = await req.json();
const { add, remove, set, insertAt } = body;
const { add, remove, set, insertAt, move, to } = body;
if (Array.isArray(set)) {
const tracks = buildTracksFromIds(set, state.library);
@ -229,6 +229,12 @@ export async function handleModifyQueue(req: Request, server: any, channelId: st
return Response.json({ success: true, queueLength: channel.queue.length });
}
// Move/reorder tracks within queue
if (Array.isArray(move) && typeof to === "number") {
channel.moveTracks(move, to);
return Response.json({ success: true, queueLength: channel.queue.length });
}
if (Array.isArray(remove) && remove.length > 0) {
const indices = remove.filter((i: unknown) => typeof i === "number");
channel.removeTracksByIndex(indices);

View File

@ -4,9 +4,12 @@ import {
checkUrl,
addToFastQueue,
addToSlowQueue,
getUserQueues,
getQueues,
cancelSlowQueueItem,
cancelAllSlowQueueItems,
} from "../ytdlp";
import { getOrCreateUser } from "./helpers";
import { createPlaylist, generateUniquePlaylistName } from "../db";
const ytdlpConfig = config.ytdlp || DEFAULT_CONFIG.ytdlp!;
@ -72,12 +75,19 @@ export async function handleFetchConfirm(req: Request, server: any): Promise<Res
}
try {
const { items } = await req.json();
const { items, playlistTitle } = await req.json();
if (!Array.isArray(items) || items.length === 0) {
return Response.json({ error: "Items required" }, { status: 400 });
}
const queueItems = addToSlowQueue(items, user.id);
// Auto-create playlist with unique name
const baseName = playlistTitle || "Imported Playlist";
const uniqueName = generateUniquePlaylistName(baseName, user.id);
const playlist = createPlaylist(uniqueName, user.id, `Imported from URL (${items.length} tracks)`);
console.log(`[Fetch] ${user.username} created playlist: ${uniqueName} (id=${playlist.id})`);
const queueItems = addToSlowQueue(items, user.id, { id: playlist.id, name: uniqueName });
const estimatedMinutes = Math.ceil(queueItems.length * ytdlpConfig.slowQueueInterval / 60);
const hours = Math.floor(estimatedMinutes / 60);
const mins = estimatedMinutes % 60;
@ -89,6 +99,8 @@ export async function handleFetchConfirm(req: Request, server: any): Promise<Res
message: `Added ${queueItems.length} items to queue`,
queueType: "slow",
estimatedTime,
playlistId: playlist.id,
playlistName: uniqueName,
items: queueItems.map(i => ({ id: i.id, title: i.title }))
}, { headers });
} catch (e) {
@ -97,13 +109,39 @@ export async function handleFetchConfirm(req: Request, server: any): Promise<Res
}
}
// GET /api/fetch - get fetch queue status
// GET /api/fetch - get fetch queue status (all users see all tasks)
export function handleGetFetchQueue(req: Request, server: any): Response {
const { user, headers } = getOrCreateUser(req, server);
if (!user) {
return Response.json({ error: "Authentication required" }, { status: 401 });
}
const queues = getUserQueues(user.id);
const queues = getQueues(); // Return all queues, not filtered by user
return Response.json(queues, { headers });
}
// DELETE /api/fetch/:id - cancel a slow queue item
export function handleCancelFetchItem(req: Request, server: any, itemId: string): Response {
const { user, headers } = getOrCreateUser(req, server);
if (!user) {
return Response.json({ error: "Authentication required" }, { status: 401 });
}
const success = cancelSlowQueueItem(itemId, user.id);
if (success) {
return Response.json({ message: "Item cancelled" }, { headers });
} else {
return Response.json({ error: "Cannot cancel item (not found, not owned, or already downloading)" }, { status: 400, headers });
}
}
// DELETE /api/fetch - cancel all slow queue items for user
export function handleCancelAllFetchItems(req: Request, server: any): Response {
const { user, headers } = getOrCreateUser(req, server);
if (!user) {
return Response.json({ error: "Authentication required" }, { status: 401 });
}
const cancelled = cancelAllSlowQueueItems(user.id);
return Response.json({ message: `Cancelled ${cancelled} items`, cancelled }, { headers });
}

View File

@ -40,8 +40,24 @@ import {
handleFetch,
handleFetchConfirm,
handleGetFetchQueue,
handleCancelFetchItem,
handleCancelAllFetchItems,
} from "./fetch";
// Playlist routes
import {
handleListPlaylists,
handleCreatePlaylist,
handleGetPlaylist,
handleUpdatePlaylist,
handleDeletePlaylist,
handleModifyPlaylistTracks,
handleSharePlaylist,
handleUnsharePlaylist,
handleGetSharedPlaylist,
handleCopySharedPlaylist,
} from "./playlists";
// Static file serving
import { handleStatic } from "./static";
@ -135,6 +151,53 @@ export function createRouter() {
if (path === "/api/fetch" && req.method === "GET") {
return handleGetFetchQueue(req, server);
}
if (path === "/api/fetch" && req.method === "DELETE") {
return handleCancelAllFetchItems(req, server);
}
const fetchCancelMatch = path.match(/^\/api\/fetch\/([^/]+)$/);
if (fetchCancelMatch && req.method === "DELETE") {
return handleCancelFetchItem(req, server, fetchCancelMatch[1]);
}
// Playlist routes
if (path === "/api/playlists" && req.method === "GET") {
return handleListPlaylists(req, server);
}
if (path === "/api/playlists" && req.method === "POST") {
return handleCreatePlaylist(req, server);
}
const sharedPlaylistMatch = path.match(/^\/api\/playlists\/shared\/([^/]+)$/);
if (sharedPlaylistMatch && req.method === "GET") {
return handleGetSharedPlaylist(req, server, sharedPlaylistMatch[1]);
}
if (sharedPlaylistMatch && req.method === "POST") {
return handleCopySharedPlaylist(req, server, sharedPlaylistMatch[1]);
}
const playlistMatch = path.match(/^\/api\/playlists\/([^/]+)$/);
if (playlistMatch && req.method === "GET") {
return handleGetPlaylist(req, server, playlistMatch[1]);
}
if (playlistMatch && req.method === "PATCH") {
return handleUpdatePlaylist(req, server, playlistMatch[1]);
}
if (playlistMatch && req.method === "DELETE") {
return handleDeletePlaylist(req, server, playlistMatch[1]);
}
const playlistTracksMatch = path.match(/^\/api\/playlists\/([^/]+)\/tracks$/);
if (playlistTracksMatch && req.method === "PATCH") {
return handleModifyPlaylistTracks(req, server, playlistTracksMatch[1]);
}
const playlistShareMatch = path.match(/^\/api\/playlists\/([^/]+)\/share$/);
if (playlistShareMatch && req.method === "POST") {
return handleSharePlaylist(req, server, playlistShareMatch[1]);
}
if (playlistShareMatch && req.method === "DELETE") {
return handleUnsharePlaylist(req, server, playlistShareMatch[1]);
}
// Auth routes
if (path === "/api/auth/signup" && req.method === "POST") {

270
routes/playlists.ts Normal file
View File

@ -0,0 +1,270 @@
import {
createPlaylist,
getPlaylist,
getPlaylistsByUser,
getPublicPlaylists,
getPlaylistByShareToken,
updatePlaylist,
deletePlaylist,
setPlaylistTracks,
addTracksToPlaylist,
removeTrackFromPlaylist,
removeTracksFromPlaylist,
movePlaylistTracks,
insertTracksToPlaylistAt,
generatePlaylistShareToken,
removePlaylistShareToken,
findUserById,
} from "../db";
import { getOrCreateUser, userHasPermission } from "./helpers";
// GET /api/playlists - List user's + shared playlists
export function handleListPlaylists(req: Request, server: any): Response {
const { user } = getOrCreateUser(req, server);
if (!user) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const myPlaylists = getPlaylistsByUser(user.id);
const sharedPlaylists = getPublicPlaylists(user.id);
return Response.json({
mine: myPlaylists,
shared: sharedPlaylists,
});
}
// POST /api/playlists - Create new playlist
export async function handleCreatePlaylist(req: Request, server: any): Promise<Response> {
const { user } = getOrCreateUser(req, server);
if (!user) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
if (user.is_guest) {
return Response.json({ error: "Guests cannot create playlists" }, { status: 403 });
}
let body: { name: string; description?: string };
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
if (!body.name?.trim()) {
return Response.json({ error: "Name required" }, { status: 400 });
}
const playlist = createPlaylist(body.name.trim(), user.id, body.description?.trim() || "");
return Response.json(playlist, { status: 201 });
}
// GET /api/playlists/:id - Get playlist details
export function handleGetPlaylist(req: Request, server: any, playlistId: string): Response {
const { user } = getOrCreateUser(req, server);
const playlist = getPlaylist(playlistId);
if (!playlist) {
return Response.json({ error: "Playlist not found" }, { status: 404 });
}
// Check access: owner, public, or has share token
const url = new URL(req.url);
const shareToken = url.searchParams.get("token");
if (
playlist.ownerId !== user?.id &&
!playlist.isPublic &&
playlist.shareToken !== shareToken
) {
return Response.json({ error: "Access denied" }, { status: 403 });
}
// Include owner username
const owner = findUserById(playlist.ownerId);
return Response.json({
...playlist,
ownerName: owner?.username || "Unknown",
});
}
// PATCH /api/playlists/:id - Update playlist
export async function handleUpdatePlaylist(req: Request, server: any, playlistId: string): Promise<Response> {
const { user } = getOrCreateUser(req, server);
if (!user) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const playlist = getPlaylist(playlistId);
if (!playlist) {
return Response.json({ error: "Playlist not found" }, { status: 404 });
}
if (playlist.ownerId !== user.id && !user.is_admin) {
return Response.json({ error: "Not your playlist" }, { status: 403 });
}
let body: { name?: string; description?: string; isPublic?: boolean };
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
updatePlaylist(playlistId, {
name: body.name?.trim(),
description: body.description?.trim(),
isPublic: body.isPublic,
});
return Response.json({ ok: true });
}
// DELETE /api/playlists/:id - Delete playlist
export function handleDeletePlaylist(req: Request, server: any, playlistId: string): Response {
const { user } = getOrCreateUser(req, server);
if (!user) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const playlist = getPlaylist(playlistId);
if (!playlist) {
return Response.json({ error: "Playlist not found" }, { status: 404 });
}
if (playlist.ownerId !== user.id && !user.is_admin) {
return Response.json({ error: "Not your playlist" }, { status: 403 });
}
deletePlaylist(playlistId);
return Response.json({ ok: true });
}
// PATCH /api/playlists/:id/tracks - Modify tracks
export async function handleModifyPlaylistTracks(req: Request, server: any, playlistId: string): Promise<Response> {
const { user } = getOrCreateUser(req, server);
if (!user) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const playlist = getPlaylist(playlistId);
if (!playlist) {
return Response.json({ error: "Playlist not found" }, { status: 404 });
}
if (playlist.ownerId !== user.id && !user.is_admin) {
return Response.json({ error: "Not your playlist" }, { status: 403 });
}
let body: { add?: string[]; remove?: number[]; set?: string[]; move?: number[]; to?: number; insertAt?: number };
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
// If 'set' is provided, replace entire track list
if (body.set !== undefined) {
setPlaylistTracks(playlistId, body.set);
return Response.json({ ok: true });
}
// Move/reorder tracks
if (body.move?.length && typeof body.to === "number") {
movePlaylistTracks(playlistId, body.move, body.to);
return Response.json({ ok: true });
}
// Remove tracks by position (bulk operation)
if (body.remove?.length) {
removeTracksFromPlaylist(playlistId, body.remove);
}
// Add tracks (at specific position or at end)
if (body.add?.length) {
if (typeof body.insertAt === "number") {
insertTracksToPlaylistAt(playlistId, body.add, body.insertAt);
} else {
addTracksToPlaylist(playlistId, body.add);
}
}
return Response.json({ ok: true });
}
// POST /api/playlists/:id/share - Generate share token
export function handleSharePlaylist(req: Request, server: any, playlistId: string): Response {
const { user } = getOrCreateUser(req, server);
if (!user) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const playlist = getPlaylist(playlistId);
if (!playlist) {
return Response.json({ error: "Playlist not found" }, { status: 404 });
}
if (playlist.ownerId !== user.id && !user.is_admin) {
return Response.json({ error: "Not your playlist" }, { status: 403 });
}
const token = generatePlaylistShareToken(playlistId);
return Response.json({ shareToken: token });
}
// DELETE /api/playlists/:id/share - Remove sharing
export function handleUnsharePlaylist(req: Request, server: any, playlistId: string): Response {
const { user } = getOrCreateUser(req, server);
if (!user) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const playlist = getPlaylist(playlistId);
if (!playlist) {
return Response.json({ error: "Playlist not found" }, { status: 404 });
}
if (playlist.ownerId !== user.id && !user.is_admin) {
return Response.json({ error: "Not your playlist" }, { status: 403 });
}
removePlaylistShareToken(playlistId);
return Response.json({ ok: true });
}
// GET /api/playlists/shared/:token - Get shared playlist by token
export function handleGetSharedPlaylist(req: Request, server: any, token: string): Response {
const playlist = getPlaylistByShareToken(token);
if (!playlist) {
return Response.json({ error: "Playlist not found" }, { status: 404 });
}
const owner = findUserById(playlist.ownerId);
return Response.json({
...playlist,
ownerName: owner?.username || "Unknown",
});
}
// POST /api/playlists/shared/:token/copy - Copy shared playlist to own
export function handleCopySharedPlaylist(req: Request, server: any, token: string): Response {
const { user } = getOrCreateUser(req, server);
if (!user) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
if (user.is_guest) {
return Response.json({ error: "Guests cannot copy playlists" }, { status: 403 });
}
const original = getPlaylistByShareToken(token);
if (!original) {
return Response.json({ error: "Playlist not found" }, { status: 404 });
}
const copy = createPlaylist(`${original.name} (Copy)`, user.id, original.description);
setPlaylistTracks(copy.id, original.trackIds);
return Response.json(copy, { status: 201 });
}

View File

@ -16,6 +16,12 @@ export async function handleStatic(path: string): Promise<Response | null> {
});
}
if (path === "/favicon.ico") {
return new Response(file(join(PUBLIC_DIR, "favicon.ico")), {
headers: { "Content-Type": "image/x-icon" },
});
}
if (path.endsWith(".js")) {
const jsFile = file(join(PUBLIC_DIR, path.slice(1)));
if (await jsFile.exists()) {

199
ytdlp.ts
View File

@ -3,19 +3,32 @@
import { spawn } from "child_process";
import { join } from "path";
import {
saveSlowQueueItem,
updateSlowQueueItem,
loadSlowQueue,
deleteSlowQueueItem,
clearCompletedSlowQueue,
addTracksToPlaylist,
type SlowQueueRow
} from "./db";
export interface QueueItem {
id: string;
url: string;
title: string;
userId: number;
status: "queued" | "downloading" | "complete" | "error";
status: "queued" | "downloading" | "complete" | "error" | "cancelled";
progress: number;
queueType: "fast" | "slow";
error?: string;
filename?: string;
createdAt: number;
completedAt?: number;
playlistId?: string;
playlistName?: string;
position?: number;
trackId?: string; // Set after successful download
}
export interface YtdlpStatus {
@ -65,6 +78,7 @@ let lastSlowDownload = 0;
// Callbacks
let onProgress: ProgressCallback | null = null;
let onTrackReady: ((item: QueueItem) => void) | null = null;
// Generate unique ID
function generateId(): string {
@ -115,6 +129,17 @@ export async function initYtdlp(config: {
ffmpegAvailable = false;
}
// Load persisted slow queue from database
if (featureEnabled) {
const savedQueue = loadSlowQueue();
for (const row of savedQueue) {
slowQueue.push(rowToQueueItem(row));
}
if (savedQueue.length > 0) {
console.log(`[ytdlp] Restored ${savedQueue.length} items from slow queue`);
}
}
// Start slow queue processor
if (featureEnabled) {
startSlowQueueProcessor();
@ -123,6 +148,25 @@ export async function initYtdlp(config: {
return getStatus();
}
// Convert database row to QueueItem
function rowToQueueItem(row: SlowQueueRow): QueueItem {
return {
id: row.id,
url: row.url,
title: row.title,
userId: row.user_id,
status: row.status as QueueItem["status"],
progress: row.progress,
queueType: "slow",
error: row.error ?? undefined,
createdAt: row.created_at * 1000,
completedAt: row.completed_at ? row.completed_at * 1000 : undefined,
playlistId: row.playlist_id ?? undefined,
playlistName: row.playlist_name ?? undefined,
position: row.position ?? undefined
};
}
// Run a command and return stdout
function runCommand(cmd: string, args: string[]): Promise<string> {
const fullCmd = `${cmd} ${args.join(" ")}`;
@ -162,6 +206,11 @@ export function setProgressCallback(callback: ProgressCallback): void {
onProgress = callback;
}
// Set track ready callback (called when download completes and needs playlist association)
export function setTrackReadyCallback(callback: (item: QueueItem) => void): void {
onTrackReady = callback;
}
// Get all queue items
export function getQueues(): { fastQueue: QueueItem[]; slowQueue: QueueItem[]; slowQueueNextIn: number } {
const now = Date.now();
@ -258,8 +307,13 @@ export function addToFastQueue(url: string, title: string, userId: number): Queu
}
// Add items to slow queue (for playlists)
export function addToSlowQueue(items: { url: string; title: string }[], userId: number): QueueItem[] {
const queueItems: QueueItem[] = items.map(item => ({
export function addToSlowQueue(
items: { url: string; title: string }[],
userId: number,
playlist?: { id: string; name: string }
): QueueItem[] {
const now = Date.now();
const queueItems: QueueItem[] = items.map((item, index) => ({
id: generateId(),
url: item.url,
title: item.title,
@ -267,8 +321,28 @@ export function addToSlowQueue(items: { url: string; title: string }[], userId:
status: "queued" as const,
progress: 0,
queueType: "slow" as const,
createdAt: Date.now()
createdAt: now,
playlistId: playlist?.id,
playlistName: playlist?.name,
position: playlist ? index : undefined
}));
// Persist to database
for (const item of queueItems) {
saveSlowQueueItem({
id: item.id,
url: item.url,
title: item.title,
userId: item.userId,
status: item.status,
progress: item.progress,
playlistId: item.playlistId,
playlistName: item.playlistName,
position: item.position,
createdAt: Math.floor(item.createdAt / 1000)
});
}
slowQueue.push(...queueItems);
return queueItems;
}
@ -366,6 +440,21 @@ async function downloadItem(item: QueueItem): Promise<void> {
item.status = "complete";
item.progress = 100;
item.completedAt = Date.now();
// Update database
if (item.queueType === "slow") {
updateSlowQueueItem(item.id, {
status: "complete",
progress: 100,
completedAt: Math.floor(item.completedAt / 1000)
});
// Register for playlist addition immediately - library will match when it scans
if (item.playlistId && onTrackReady) {
onTrackReady(item);
}
}
notifyProgress(item);
// Remove from queue after delay
@ -374,6 +463,17 @@ async function downloadItem(item: QueueItem): Promise<void> {
} catch (e: any) {
item.status = "error";
item.error = e.message || "Download failed";
item.completedAt = Date.now();
// Update database
if (item.queueType === "slow") {
updateSlowQueueItem(item.id, {
status: "error",
error: item.error,
completedAt: Math.floor(item.completedAt / 1000)
});
}
notifyProgress(item);
// Remove from queue after delay
@ -389,9 +489,65 @@ function removeFromQueue(item: QueueItem): void {
} else {
const idx = slowQueue.findIndex(i => i.id === item.id);
if (idx !== -1) slowQueue.splice(idx, 1);
// Remove from database
deleteSlowQueueItem(item.id);
}
}
// Cancel a slow queue item
export function cancelSlowQueueItem(id: string, userId: number): boolean {
const item = slowQueue.find(i => i.id === id && i.userId === userId);
if (!item || item.status === "downloading") {
return false; // Can't cancel if not found, not owned, or already downloading
}
item.status = "cancelled";
item.completedAt = Date.now();
// Update database
updateSlowQueueItem(id, {
status: "cancelled",
completedAt: Math.floor(item.completedAt / 1000)
});
notifyProgress(item);
// Remove from queue after brief delay
setTimeout(() => removeFromQueue(item), 1000);
return true;
}
// Cancel all queued items in slow queue for a user
export function cancelAllSlowQueueItems(userId: number): number {
const items = slowQueue.filter(i => i.userId === userId && i.status === "queued");
let cancelled = 0;
for (const item of items) {
item.status = "cancelled";
item.completedAt = Date.now();
updateSlowQueueItem(item.id, {
status: "cancelled",
completedAt: Math.floor(item.completedAt / 1000)
});
notifyProgress(item);
cancelled++;
}
// Remove all cancelled items after brief delay
setTimeout(() => {
for (let i = slowQueue.length - 1; i >= 0; i--) {
if (slowQueue[i].status === "cancelled") {
slowQueue.splice(i, 1);
}
}
}, 1000);
return cancelled;
}
// Notify progress callback
function notifyProgress(item: QueueItem): void {
if (onProgress) {
@ -399,13 +555,43 @@ function notifyProgress(item: QueueItem): void {
}
}
// Mark a slow queue item as skipped (already in library)
export function skipSlowQueueItem(id: string, trackId: string): QueueItem | null {
const item = slowQueue.find(i => i.id === id && i.status === "queued");
if (!item) return null;
item.status = "complete";
item.progress = 100;
item.completedAt = Date.now();
item.trackId = trackId;
// Update database
updateSlowQueueItem(id, {
status: "complete",
progress: 100,
completedAt: Math.floor(item.completedAt / 1000)
});
notifyProgress(item);
// Remove from queue after brief delay
setTimeout(() => removeFromQueue(item), 1000);
return item;
}
// Get queued items from slow queue (for prescan)
export function getQueuedSlowItems(): QueueItem[] {
return slowQueue.filter(i => i.status === "queued");
}
// Cleanup old completed/failed items
export function cleanupOldItems(maxAge: number = 3600000): void {
const now = Date.now();
const cleanup = (queue: QueueItem[]) => {
for (let i = queue.length - 1; i >= 0; i--) {
const item = queue[i];
if ((item.status === "complete" || item.status === "error") &&
if ((item.status === "complete" || item.status === "error" || item.status === "cancelled") &&
now - item.createdAt > maxAge) {
queue.splice(i, 1);
}
@ -413,4 +599,7 @@ export function cleanupOldItems(maxAge: number = 3600000): void {
};
cleanup(fastQueue);
cleanup(slowQueue);
// Also cleanup database
clearCompletedSlowQueue(Math.floor(maxAge / 1000));
}