diff --git a/server/routes/playlists.js b/server/routes/playlists.js new file mode 100644 index 0000000..a720f14 --- /dev/null +++ b/server/routes/playlists.js @@ -0,0 +1,237 @@ +const express = require('express'); +const router = express.Router(); +const { v4: uuidv4 } = require('uuid'); +const { db } = require('../db/database'); + +// Verify playlist belongs to the authenticated user +function requirePlaylistOwnership(req, res, next) { + const playlist = db.prepare('SELECT * FROM playlists WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id); + if (!playlist) return res.status(404).json({ error: 'playlist not found' }); + req.playlist = playlist; + next(); +} + +// List playlists +router.get('/', (req, res) => { + const playlists = db.prepare(` + SELECT p.*, COUNT(pi.id) as item_count + FROM playlists p + LEFT JOIN playlist_items pi ON p.id = pi.playlist_id + WHERE p.user_id = ? + GROUP BY p.id + ORDER BY p.name ASC + `).all(req.user.id); + res.json(playlists); +}); + +// Create playlist +router.post('/', (req, res) => { + const { name, description } = req.body; + if (!name || !name.trim()) return res.status(400).json({ error: 'name required' }); + const id = uuidv4(); + db.prepare('INSERT INTO playlists (id, user_id, name, description) VALUES (?, ?, ?, ?)') + .run(id, req.user.id, name.trim(), (description || '').trim()); + res.status(201).json(db.prepare(` + SELECT p.*, 0 as item_count FROM playlists p WHERE p.id = ? + `).get(id)); +}); + +// Get single playlist with items +router.get('/:id', requirePlaylistOwnership, (req, res) => { + const items = db.prepare(` + SELECT pi.*, + COALESCE(c.filename, w.name) as filename, + c.mime_type, c.filepath, c.thumbnail_path, + c.duration_sec as content_duration, c.file_size, c.remote_url, + w.name as widget_name, w.widget_type, w.config as widget_config + FROM playlist_items pi + LEFT JOIN content c ON pi.content_id = c.id + LEFT JOIN widgets w ON pi.widget_id = w.id + WHERE pi.playlist_id = ? + ORDER BY pi.sort_order ASC + `).all(req.params.id); + res.json({ ...req.playlist, items, item_count: items.length }); +}); + +// Update playlist +router.put('/:id', requirePlaylistOwnership, (req, res) => { + const { name, description } = req.body; + const updates = []; + const values = []; + if (name !== undefined) { + if (!name.trim()) return res.status(400).json({ error: 'name cannot be empty' }); + updates.push('name = ?'); + values.push(name.trim()); + } + if (description !== undefined) { + updates.push('description = ?'); + values.push(description.trim()); + } + if (updates.length > 0) { + updates.push("updated_at = strftime('%s','now')"); + values.push(req.params.id); + db.prepare(`UPDATE playlists SET ${updates.join(', ')} WHERE id = ?`).run(...values); + } + res.json(db.prepare('SELECT * FROM playlists WHERE id = ?').get(req.params.id)); +}); + +// Delete playlist +router.delete('/:id', requirePlaylistOwnership, (req, res) => { + db.prepare('DELETE FROM playlists WHERE id = ?').run(req.params.id); + res.json({ success: true }); +}); + +// --- Playlist Items --- + +// List items +router.get('/:id/items', requirePlaylistOwnership, (req, res) => { + const items = db.prepare(` + SELECT pi.*, + COALESCE(c.filename, w.name) as filename, + c.mime_type, c.filepath, c.thumbnail_path, + c.duration_sec as content_duration, c.file_size, c.remote_url, + w.name as widget_name, w.widget_type, w.config as widget_config + FROM playlist_items pi + LEFT JOIN content c ON pi.content_id = c.id + LEFT JOIN widgets w ON pi.widget_id = w.id + WHERE pi.playlist_id = ? + ORDER BY pi.sort_order ASC + `).all(req.params.id); + res.json(items); +}); + +// Add item +router.post('/:id/items', requirePlaylistOwnership, (req, res) => { + const { content_id, widget_id, duration_sec = 10, sort_order } = req.body; + + if (!content_id && !widget_id) return res.status(400).json({ error: 'content_id or widget_id required' }); + if (duration_sec !== undefined && (typeof duration_sec !== 'number' || duration_sec < 1)) { + return res.status(400).json({ error: 'duration_sec must be a positive integer' }); + } + + // Validate content ownership + if (content_id) { + const content = db.prepare('SELECT id, user_id FROM content WHERE id = ?').get(content_id); + if (!content) return res.status(404).json({ error: 'Content not found' }); + if (!['admin', 'superadmin'].includes(req.user.role) && content.user_id && content.user_id !== req.user.id) { + return res.status(403).json({ error: 'Content not owned by you' }); + } + } + if (widget_id) { + const widget = db.prepare('SELECT id FROM widgets WHERE id = ?').get(widget_id); + if (!widget) return res.status(404).json({ error: 'Widget not found' }); + } + + // Auto-increment sort_order if not specified + let order = sort_order; + if (order === undefined || order === null) { + const max = db.prepare('SELECT MAX(sort_order) as max_order FROM playlist_items WHERE playlist_id = ?') + .get(req.params.id); + order = (max.max_order || 0) + 1; + } + + const result = db.prepare(` + INSERT INTO playlist_items (playlist_id, content_id, widget_id, sort_order, duration_sec) + VALUES (?, ?, ?, ?, ?) + `).run(req.params.id, content_id || null, widget_id || null, order, duration_sec); + + // Touch playlist updated_at + db.prepare("UPDATE playlists SET updated_at = strftime('%s','now') WHERE id = ?").run(req.params.id); + + const item = db.prepare(` + SELECT pi.*, + COALESCE(c.filename, w.name) as filename, + c.mime_type, c.filepath, c.thumbnail_path, + c.duration_sec as content_duration, c.file_size, c.remote_url, + w.name as widget_name, w.widget_type, w.config as widget_config + FROM playlist_items pi + LEFT JOIN content c ON pi.content_id = c.id + LEFT JOIN widgets w ON pi.widget_id = w.id + WHERE pi.id = ? + `).get(result.lastInsertRowid); + + res.status(201).json(item); +}); + +// Update item +router.put('/:id/items/:itemId', requirePlaylistOwnership, (req, res) => { + const item = db.prepare('SELECT * FROM playlist_items WHERE id = ? AND playlist_id = ?') + .get(req.params.itemId, req.params.id); + if (!item) return res.status(404).json({ error: 'item not found' }); + + const { sort_order, duration_sec } = req.body; + const updates = []; + const values = []; + + if (sort_order !== undefined) { updates.push('sort_order = ?'); values.push(sort_order); } + if (duration_sec !== undefined) { + if (typeof duration_sec !== 'number' || duration_sec < 1) { + return res.status(400).json({ error: 'duration_sec must be a positive integer' }); + } + updates.push('duration_sec = ?'); + values.push(duration_sec); + } + + if (updates.length > 0) { + updates.push("updated_at = strftime('%s','now')"); + values.push(req.params.itemId); + db.prepare(`UPDATE playlist_items SET ${updates.join(', ')} WHERE id = ?`).run(...values); + db.prepare("UPDATE playlists SET updated_at = strftime('%s','now') WHERE id = ?").run(req.params.id); + } + + const updated = db.prepare(` + SELECT pi.*, + COALESCE(c.filename, w.name) as filename, + c.mime_type, c.filepath, c.thumbnail_path, + c.duration_sec as content_duration, c.file_size, c.remote_url, + w.name as widget_name, w.widget_type, w.config as widget_config + FROM playlist_items pi + LEFT JOIN content c ON pi.content_id = c.id + LEFT JOIN widgets w ON pi.widget_id = w.id + WHERE pi.id = ? + `).get(req.params.itemId); + res.json(updated); +}); + +// Delete item +router.delete('/:id/items/:itemId', requirePlaylistOwnership, (req, res) => { + const item = db.prepare('SELECT * FROM playlist_items WHERE id = ? AND playlist_id = ?') + .get(req.params.itemId, req.params.id); + if (!item) return res.status(404).json({ error: 'item not found' }); + + db.prepare('DELETE FROM playlist_items WHERE id = ?').run(req.params.itemId); + db.prepare("UPDATE playlists SET updated_at = strftime('%s','now') WHERE id = ?").run(req.params.id); + res.json({ success: true }); +}); + +// Reorder items +router.post('/:id/items/reorder', requirePlaylistOwnership, (req, res) => { + const { order } = req.body; + if (!Array.isArray(order)) return res.status(400).json({ error: 'order must be an array of item IDs' }); + + const updateStmt = db.prepare('UPDATE playlist_items SET sort_order = ? WHERE id = ? AND playlist_id = ?'); + const transaction = db.transaction(() => { + order.forEach((itemId, index) => { + updateStmt.run(index, itemId, req.params.id); + }); + }); + transaction(); + + db.prepare("UPDATE playlists SET updated_at = strftime('%s','now') WHERE id = ?").run(req.params.id); + + const items = db.prepare(` + SELECT pi.*, + COALESCE(c.filename, w.name) as filename, + c.mime_type, c.filepath, c.thumbnail_path, + c.duration_sec as content_duration, c.file_size, c.remote_url, + w.name as widget_name, w.widget_type, w.config as widget_config + FROM playlist_items pi + LEFT JOIN content c ON pi.content_id = c.id + LEFT JOIN widgets w ON pi.widget_id = w.id + WHERE pi.playlist_id = ? + ORDER BY pi.sort_order ASC + `).all(req.params.id); + res.json(items); +}); + +module.exports = router;