dev/playlists #13

Merged
peterino merged 19 commits from dev/playlists into integration 2026-02-09 18:58:41 +00:00
11 changed files with 1403 additions and 8 deletions
Showing only changes of commit 19d98e0cc9 - Show all commits

View File

@ -98,13 +98,25 @@ M.trackBlobs // Map<trackId, blobUrl> - blob URLs for cached tracks
``` ```
GET / → Serves public/index.html GET / → Serves public/index.html
GET /listen/:trackId → Serves index.html (direct track link)
GET /api/channels → List all channels with listener counts GET /api/channels → List all channels with listener counts
POST /api/channels → Create a new channel POST /api/channels → Create a new channel
GET /api/channels/:id → Get channel state GET /api/channels/:id → Get channel state
PATCH /api/channels/:id → Rename channel
DELETE /api/channels/:id → Delete a channel (not default) DELETE /api/channels/:id → Delete a channel (not default)
WS /api/channels/:id/ws → WebSocket: pushes state on connect and changes 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/tracks/:id → Serve audio by content hash (supports Range)
GET /api/library → List all tracks with id, filename, title, duration 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 ## 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). - **channels.ts** — Channel CRUD and control (list, create, delete, jump, seek, queue, mode).
- **tracks.ts** — Library listing, file upload, audio serving with range support. - **tracks.ts** — Library listing, file upload, audio serving with range support.
- **fetch.ts** — yt-dlp fetch endpoints (check URL, confirm playlist, queue status). - **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). - **static.ts** — Static file serving (index.html, styles.css, JS files).
### Client (public/) ### Client (public/)
@ -137,7 +150,8 @@ GET /api/library → List all tracks with id, filename, title, dura
- **audioCache.js** — Track caching, segment downloads, prefetching - **audioCache.js** — Track caching, segment downloads, prefetching
- **channelSync.js** — WebSocket connection, server sync, channel switching - **channelSync.js** — WebSocket connection, server sync, channel switching
- **ui.js** — Progress bar, buffer display, UI updates - **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 - **controls.js** — Play, pause, seek, volume
- **auth.js** — Login, signup, logout - **auth.js** — Login, signup, logout
- **init.js** — App initialization - **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 track (local mode, single track)
- ⏭ Play next (insert after current) - ⏭ Play next (insert after current)
- Add to queue (append to end) - Add to queue (append to end)
- 📁 Add to Playlist... (submenu)
- 🔗 Generate listening link
**Queue tracks:** **Queue tracks:**
- ▶ Play track (jump to track) - ▶ Play track (jump to track)
- ⏭ Play next (re-add after current) - ⏭ Play next (re-add after current)
- Add again (duplicate at end) - Add again (duplicate at end)
- 📁 Add to Playlist... (submenu)
- 🔗 Generate listening link
- ✕ Remove from queue - ✕ 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 ### Mobile/Touch Support
- Larger touch targets (min 44px) - Larger touch targets (min 44px)
- No hover-dependent features (always show action buttons) - No hover-dependent features (always show action buttons)

277
db.ts
View File

@ -417,3 +417,280 @@ export function loadChannelQueue(channelId: string): string[] {
export function removeTrackFromQueues(trackId: string): void { export function removeTrackFromQueues(trackId: string): void {
db.query("DELETE FROM channel_queue WHERE track_id = ?").run(trackId); 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 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);
}

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 id="library-panel">
<div class="panel-tabs"> <div class="panel-tabs">
<button class="panel-tab active" data-tab="library">Library</button> <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> <button class="panel-tab" data-tab="tasks">Tasks</button>
</div> </div>
<div class="panel-views"> <div class="panel-views">
@ -100,6 +101,31 @@
<div class="dropzone-content">Drop audio files here</div> <div class="dropzone-content">Drop audio files here</div>
</div> </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 all to queue">Add to Queue</button>
<button id="btn-playlist-play-next" title="Play next">Play Next</button>
</div>
</div>
<div id="playlist-tracks"></div>
</div>
</div>
</div>
<div id="tasks-view" class="panel-view"> <div id="tasks-view" class="panel-view">
<div id="tasks-list"></div> <div id="tasks-list"></div>
<div id="tasks-empty" class="tasks-empty">No active tasks</div> <div id="tasks-empty" class="tasks-empty">No active tasks</div>
@ -158,6 +184,7 @@
<script src="/channelSync.js"></script> <script src="/channelSync.js"></script>
<script src="/ui.js"></script> <script src="/ui.js"></script>
<script src="/queue.js"></script> <script src="/queue.js"></script>
<script src="/playlists.js"></script>
<script src="/controls.js"></script> <script src="/controls.js"></script>
<script src="/auth.js"></script> <script src="/auth.js"></script>
<script src="/upload.js"></script> <script src="/upload.js"></script>

View File

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

570
public/playlists.js Normal file
View File

@ -0,0 +1,570 @@
// 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');
}
}
function renderPlaylistContents() {
const header = $('#selected-playlist-name');
const actions = $('#playlist-actions');
const container = $('#playlist-tracks');
if (!selectedPlaylist) {
header.textContent = 'Select a playlist';
actions.classList.add('hidden');
container.innerHTML = '';
return;
}
header.textContent = selectedPlaylist.name;
actions.classList.remove('hidden');
if (selectedPlaylist.trackIds.length === 0) {
container.innerHTML = '<div class="empty-playlist-tracks">No tracks in this playlist</div>';
return;
}
// Get track info from library
const tracks = selectedPlaylist.trackIds.map(id => {
const track = M.library.find(t => t.id === id);
return track || { id, title: 'Unknown track', duration: 0 };
});
container.innerHTML = tracks.map((track, i) => `
<div class="playlist-track" data-id="${track.id}" data-index="${i}">
<span class="track-number">${i + 1}</span>
<span class="track-title">${escapeHtml(track.title || track.filename || 'Unknown')}</span>
<span class="track-duration">${formatTime(track.duration)}</span>
</div>
`).join('');
// Attach click handlers
container.querySelectorAll('.playlist-track').forEach(el => {
el.oncontextmenu = (e) => showPlaylistTrackContextMenu(e, el.dataset.id, parseInt(el.dataset.index));
});
}
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);
}
function showPlaylistTrackContextMenu(e, trackId, index) {
e.preventDefault();
M.contextMenu.hide();
const isMine = myPlaylists.some(p => p.id === selectedPlaylistId);
const items = [];
// Play
items.push({
label: '▶ Play',
action: () => playTrackFromPlaylist(trackId)
});
items.push({ separator: true });
// Add to queue
items.push({
label: ' Add to Queue',
action: () => addTracksToQueue([trackId])
});
items.push({
label: '⏭ Play Next',
action: () => addTracksToQueue([trackId], true)
});
if (isMine) {
items.push({ separator: true });
// Remove from playlist
items.push({
label: '🗑️ Remove from Playlist',
action: () => removeTrackFromPlaylist(index),
className: 'danger'
});
}
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
};
})();

View File

@ -164,15 +164,68 @@
menu.className = "context-menu"; menu.className = "context-menu";
items.forEach(item => { 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"); const el = document.createElement("div");
el.className = "context-menu-item" + (item.danger ? " danger" : ""); el.className = "context-menu-item" + (item.danger ? " danger" : "") + (item.disabled ? " disabled" : "");
el.textContent = item.label; el.textContent = item.label;
el.onclick = (ev) => {
ev.stopPropagation(); if (item.submenu) {
menu.remove(); // Has submenu - show on hover
activeContextMenu = null; el.classList.add("has-submenu");
item.action(); el.innerHTML += ' ▸';
};
const submenu = document.createElement("div");
submenu.className = "context-menu context-submenu";
item.submenu.forEach(sub => {
const subEl = document.createElement("div");
subEl.className = "context-menu-item";
subEl.textContent = sub.label;
subEl.onclick = (ev) => {
ev.stopPropagation();
menu.remove();
activeContextMenu = null;
sub.action();
};
submenu.appendChild(subEl);
});
el.appendChild(submenu);
// Position submenu on hover
el.onmouseenter = () => {
const rect = el.getBoundingClientRect();
submenu.style.display = "block";
submenu.style.left = rect.width + "px";
submenu.style.top = "0px";
// Check if submenu goes off screen
const subRect = submenu.getBoundingClientRect();
if (subRect.right > window.innerWidth) {
submenu.style.left = (-subRect.width) + "px";
}
if (subRect.bottom > window.innerHeight) {
submenu.style.top = (window.innerHeight - subRect.bottom - 5) + "px";
}
};
el.onmouseleave = () => {
submenu.style.display = "none";
};
} else {
el.onclick = (ev) => {
ev.stopPropagation();
if (item.disabled) return;
menu.remove();
activeContextMenu = null;
item.action();
};
}
menu.appendChild(el); menu.appendChild(el);
}); });
@ -190,6 +243,17 @@
activeContextMenu = menu; activeContextMenu = menu;
} }
// Expose context menu API for other modules (playlists.js)
M.contextMenu = {
show: showContextMenu,
hide: () => {
if (activeContextMenu) {
activeContextMenu.remove();
activeContextMenu = null;
}
}
};
// Drag state for queue reordering // Drag state for queue reordering
let draggedIndices = []; let draggedIndices = [];
let draggedLibraryIds = []; let draggedLibraryIds = [];
@ -698,6 +762,26 @@
}); });
} }
// Add to Playlist option
if (M.playlists && !M.currentUser?.is_guest) {
const trackIds = hasSelection
? [...M.selectedQueueIndices].map(idx => M.queue[idx]?.id).filter(Boolean)
: [trackId];
const submenu = M.playlists.showAddToPlaylistMenu(trackIds);
if (submenu && submenu.length > 0) {
menuItems.push({
label: hasSelection && trackIds.length > 1 ? `📁 Add ${trackIds.length} to Playlist...` : "📁 Add to Playlist...",
submenu: submenu
});
} else if (M.playlists.getMyPlaylists().length === 0) {
menuItems.push({
label: "📁 Add to Playlist...",
disabled: true,
action: () => M.showToast("Create a playlist first in the Playlists tab", "info")
});
}
}
// Clear selection option (if items selected) // Clear selection option (if items selected)
if (hasSelection) { if (hasSelection) {
menuItems.push({ menuItems.push({
@ -940,6 +1024,23 @@
}); });
} }
// Add to Playlist option
if (M.playlists && !M.currentUser?.is_guest) {
const submenu = M.playlists.showAddToPlaylistMenu(idsToAdd);
if (submenu && submenu.length > 0) {
menuItems.push({
label: hasSelection && idsToAdd.length > 1 ? `📁 Add ${idsToAdd.length} to Playlist...` : "📁 Add to Playlist...",
submenu: submenu
});
} else if (M.playlists.getMyPlaylists().length === 0) {
menuItems.push({
label: "📁 Add to Playlist...",
disabled: true,
action: () => M.showToast("Create a playlist first in the Playlists tab", "info")
});
}
}
// Export all cached option (if there are cached tracks) // Export all cached option (if there are cached tracks)
if (M.cachedTracks.size > 0) { if (M.cachedTracks.size > 0) {
menuItems.push({ menuItems.push({

View File

@ -261,6 +261,41 @@ button:hover { background: #333; }
.history-item.history-error { color: #e44; background: #2a1a1a; } .history-item.history-error { color: #e44; background: #2a1a1a; }
.history-time { color: #666; margin-right: 0.4rem; } .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: 0; left: 100%; margin-left: 2px; }
.context-menu-item.disabled { color: #666; cursor: default; }
.context-menu-item.disabled:hover { background: transparent; }
/* Mobile tab bar - hidden on desktop */ /* Mobile tab bar - hidden on desktop */
#mobile-tabs { display: none; } #mobile-tabs { display: none; }

View File

@ -42,6 +42,20 @@ import {
handleGetFetchQueue, handleGetFetchQueue,
} from "./fetch"; } from "./fetch";
// Playlist routes
import {
handleListPlaylists,
handleCreatePlaylist,
handleGetPlaylist,
handleUpdatePlaylist,
handleDeletePlaylist,
handleModifyPlaylistTracks,
handleSharePlaylist,
handleUnsharePlaylist,
handleGetSharedPlaylist,
handleCopySharedPlaylist,
} from "./playlists";
// Static file serving // Static file serving
import { handleStatic } from "./static"; import { handleStatic } from "./static";
@ -136,6 +150,46 @@ export function createRouter() {
return handleGetFetchQueue(req, server); return handleGetFetchQueue(req, server);
} }
// 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 // Auth routes
if (path === "/api/auth/signup" && req.method === "POST") { if (path === "/api/auth/signup" && req.method === "POST") {
return handleSignup(req, server); return handleSignup(req, server);

260
routes/playlists.ts Normal file
View File

@ -0,0 +1,260 @@
import {
createPlaylist,
getPlaylist,
getPlaylistsByUser,
getPublicPlaylists,
getPlaylistByShareToken,
updatePlaylist,
deletePlaylist,
setPlaylistTracks,
addTracksToPlaylist,
removeTrackFromPlaylist,
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[] };
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 });
}
// Remove tracks by position (do removes first, in reverse order)
if (body.remove?.length) {
const positions = [...body.remove].sort((a, b) => b - a);
for (const pos of positions) {
removeTrackFromPlaylist(playlistId, pos);
}
}
// Add tracks
if (body.add?.length) {
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")) { if (path.endsWith(".js")) {
const jsFile = file(join(PUBLIC_DIR, path.slice(1))); const jsFile = file(join(PUBLIC_DIR, path.slice(1)));
if (await jsFile.exists()) { if (await jsFile.exists()) {