From 55c8e354b4f3bc9159db521f4187f9b22ba7a705 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Sat, 11 Apr 2026 22:05:19 -0500 Subject: [PATCH] Phase 2: assignments.js operates on device playlists instead of assignments table All CRUD routes now read/write playlist_items via device.playlist_id. Auto-creates a playlist for a device on first content add if none exists. PUT/DELETE push updates to ALL devices sharing the same playlist. Copy-to-device creates a playlist on the target if needed. Co-Authored-By: Claude Opus 4.6 --- server/routes/assignments.js | 182 ++++++++++++++++++++--------------- 1 file changed, 102 insertions(+), 80 deletions(-) diff --git a/server/routes/assignments.js b/server/routes/assignments.js index 518d5d7..f7c1e4c 100644 --- a/server/routes/assignments.js +++ b/server/routes/assignments.js @@ -1,5 +1,6 @@ const express = require('express'); const router = express.Router(); +const { v4: uuidv4 } = require('uuid'); const { db } = require('../db/database'); // Push playlist update to a connected device via WebSocket @@ -26,32 +27,50 @@ function checkDeviceAccess(req, res) { return true; } -// Get assignments for a device +// Ensure device has a playlist; auto-create one if missing +function ensureDevicePlaylist(deviceId, userId) { + const device = db.prepare('SELECT playlist_id FROM devices WHERE id = ?').get(deviceId); + if (device?.playlist_id) return device.playlist_id; + + const deviceRow = db.prepare('SELECT name FROM devices WHERE id = ?').get(deviceId); + const playlistId = uuidv4(); + db.prepare('INSERT INTO playlists (id, user_id, name, is_auto_generated) VALUES (?, ?, ?, 1)') + .run(playlistId, userId, `${deviceRow?.name || 'Display'} playlist`); + db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?').run(playlistId, deviceId); + return playlistId; +} + +// Standard item query with joined content/widget info +const ITEM_SELECT = ` + SELECT pi.id, pi.playlist_id, pi.content_id, pi.widget_id, pi.sort_order, pi.duration_sec, + pi.created_at, pi.updated_at, + 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 +`; + +// Get assignments (playlist items) for a device router.get('/device/:deviceId', (req, res) => { if (!checkDeviceAccess(req, res)) return; - const assignments = db.prepare(` - SELECT a.*, - 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 assignments a - LEFT JOIN content c ON a.content_id = c.id - LEFT JOIN widgets w ON a.widget_id = w.id - WHERE a.device_id = ? - ORDER BY a.sort_order ASC - `).all(req.params.deviceId); - res.json(assignments); + const device = db.prepare('SELECT playlist_id FROM devices WHERE id = ?').get(req.params.deviceId); + if (!device?.playlist_id) return res.json([]); + + const items = db.prepare(`${ITEM_SELECT} WHERE pi.playlist_id = ? ORDER BY pi.sort_order ASC`) + .all(device.playlist_id); + res.json(items); }); // Add content or widget to device playlist router.post('/device/:deviceId', (req, res) => { if (!checkDeviceAccess(req, res)) return; - const { content_id, widget_id, zone_id, duration_sec = 10, sort_order, schedule_start, schedule_end, schedule_days } = req.body; + const { content_id, widget_id, zone_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' }); - // Validate the referenced item exists AND belongs to the user 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' }); @@ -64,131 +83,134 @@ router.post('/device/:deviceId', (req, res) => { if (!widget) return res.status(404).json({ error: 'Widget not found' }); } - // Get max sort order if not specified + const playlistId = ensureDevicePlaylist(req.params.deviceId, req.user.id); + let order = sort_order; if (order === undefined || order === null) { - const max = db.prepare('SELECT MAX(sort_order) as max_order FROM assignments WHERE device_id = ?') - .get(req.params.deviceId); + const max = db.prepare('SELECT MAX(sort_order) as max_order FROM playlist_items WHERE playlist_id = ?') + .get(playlistId); order = (max.max_order || 0) + 1; } try { const result = db.prepare(` - INSERT INTO assignments (device_id, content_id, widget_id, zone_id, sort_order, duration_sec, schedule_start, schedule_end, schedule_days) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run(req.params.deviceId, content_id || null, widget_id || null, zone_id || null, order, duration_sec, schedule_start || null, schedule_end || null, schedule_days || null); + INSERT INTO playlist_items (playlist_id, content_id, widget_id, sort_order, duration_sec) + VALUES (?, ?, ?, ?, ?) + `).run(playlistId, content_id || null, widget_id || null, order, duration_sec); - const assignment = db.prepare(` - SELECT a.*, c.filename 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 assignments a - LEFT JOIN content c ON a.content_id = c.id - LEFT JOIN widgets w ON a.widget_id = w.id - WHERE a.id = ? - `).get(result.lastInsertRowid); + db.prepare("UPDATE playlists SET updated_at = strftime('%s','now') WHERE id = ?").run(playlistId); + const item = db.prepare(`${ITEM_SELECT} WHERE pi.id = ?`).get(result.lastInsertRowid); pushPlaylistToDevice(req, req.params.deviceId); - res.status(201).json(assignment); + res.status(201).json(item); } catch (err) { if (err.message.includes('UNIQUE')) { - return res.status(409).json({ error: 'Content already assigned to this device' }); + return res.status(409).json({ error: 'Content already in playlist' }); } throw err; } }); -// Update assignment +// Update playlist item router.put('/:id', (req, res) => { - const assignment = db.prepare('SELECT * FROM assignments WHERE id = ?').get(req.params.id); - if (!assignment) return res.status(404).json({ error: 'Assignment not found' }); + const item = db.prepare('SELECT pi.*, p.user_id FROM playlist_items pi JOIN playlists p ON pi.playlist_id = p.id WHERE pi.id = ?').get(req.params.id); + if (!item) return res.status(404).json({ error: 'Item not found' }); - const { sort_order, duration_sec, schedule_start, schedule_end, schedule_days, enabled, zone_id } = req.body; + const { sort_order, duration_sec, zone_id } = req.body; const updates = []; const values = []; if (sort_order !== undefined) { updates.push('sort_order = ?'); values.push(sort_order); } if (duration_sec !== undefined) { updates.push('duration_sec = ?'); values.push(duration_sec); } - if (schedule_start !== undefined) { updates.push('schedule_start = ?'); values.push(schedule_start); } - if (schedule_end !== undefined) { updates.push('schedule_end = ?'); values.push(schedule_end); } - if (schedule_days !== undefined) { updates.push('schedule_days = ?'); values.push(schedule_days); } - if (enabled !== undefined) { updates.push('enabled = ?'); values.push(enabled); } - if (zone_id !== undefined) { updates.push('zone_id = ?'); values.push(zone_id || null); } - if (req.body.muted !== undefined) { updates.push('muted = ?'); values.push(req.body.muted ? 1 : 0); } if (updates.length > 0) { + updates.push("updated_at = strftime('%s','now')"); values.push(req.params.id); - db.prepare(`UPDATE assignments SET ${updates.join(', ')} WHERE id = ?`).run(...values); + 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(item.playlist_id); } - const updated = db.prepare(` - SELECT a.*, 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 assignments a LEFT JOIN content c ON a.content_id = c.id LEFT JOIN widgets w ON a.widget_id = w.id - WHERE a.id = ? - `).get(req.params.id); - pushPlaylistToDevice(req, assignment.device_id); + const updated = db.prepare(`${ITEM_SELECT} WHERE pi.id = ?`).get(req.params.id); + + // Push to any device using this playlist + const devices = db.prepare('SELECT id FROM devices WHERE playlist_id = ?').all(item.playlist_id); + for (const d of devices) pushPlaylistToDevice(req, d.id); + res.json(updated); }); -// Delete assignment +// Delete playlist item router.delete('/:id', (req, res) => { - const assignment = db.prepare('SELECT * FROM assignments WHERE id = ?').get(req.params.id); - if (!assignment) return res.status(404).json({ error: 'Assignment not found' }); + const item = db.prepare('SELECT pi.*, p.user_id FROM playlist_items pi JOIN playlists p ON pi.playlist_id = p.id WHERE pi.id = ?').get(req.params.id); + if (!item) return res.status(404).json({ error: 'Item not found' }); - db.prepare('DELETE FROM assignments WHERE id = ?').run(req.params.id); - pushPlaylistToDevice(req, assignment.device_id); - res.json({ success: true, device_id: assignment.device_id, content_id: assignment.content_id }); + db.prepare('DELETE FROM playlist_items WHERE id = ?').run(req.params.id); + db.prepare("UPDATE playlists SET updated_at = strftime('%s','now') WHERE id = ?").run(item.playlist_id); + + // Push to any device using this playlist + const devices = db.prepare('SELECT id FROM devices WHERE playlist_id = ?').all(item.playlist_id); + for (const d of devices) pushPlaylistToDevice(req, d.id); + + res.json({ success: true, content_id: item.content_id }); }); -// Reorder assignments for a device +// Reorder items for a device's playlist router.post('/device/:deviceId/reorder', (req, res) => { - const { order } = req.body; // Array of assignment IDs in desired order - if (!Array.isArray(order)) return res.status(400).json({ error: 'order must be an array of assignment IDs' }); + if (!checkDeviceAccess(req, res)) return; + 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 assignments SET sort_order = ? WHERE id = ? AND device_id = ?'); + const device = db.prepare('SELECT playlist_id FROM devices WHERE id = ?').get(req.params.deviceId); + if (!device?.playlist_id) return res.json([]); + + const updateStmt = db.prepare('UPDATE playlist_items SET sort_order = ? WHERE id = ? AND playlist_id = ?'); const transaction = db.transaction(() => { - order.forEach((assignmentId, index) => { - updateStmt.run(index, assignmentId, req.params.deviceId); + order.forEach((itemId, index) => { + updateStmt.run(index, itemId, device.playlist_id); }); }); transaction(); - const assignments = db.prepare(` - SELECT a.*, 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 assignments a LEFT JOIN content c ON a.content_id = c.id LEFT JOIN widgets w ON a.widget_id = w.id - WHERE a.device_id = ? - ORDER BY a.sort_order ASC - `).all(req.params.deviceId); + db.prepare("UPDATE playlists SET updated_at = strftime('%s','now') WHERE id = ?").run(device.playlist_id); + + const items = db.prepare(`${ITEM_SELECT} WHERE pi.playlist_id = ? ORDER BY pi.sort_order ASC`) + .all(device.playlist_id); pushPlaylistToDevice(req, req.params.deviceId); - res.json(assignments); + res.json(items); }); // Copy playlist from one device to another router.post('/device/:deviceId/copy-to/:targetDeviceId', (req, res) => { - const source = db.prepare('SELECT * FROM assignments WHERE device_id = ? AND enabled = 1 ORDER BY sort_order').all(req.params.deviceId); - if (!source.length) return res.status(404).json({ error: 'Source device has no assignments' }); + const sourceDevice = db.prepare('SELECT playlist_id FROM devices WHERE id = ?').get(req.params.deviceId); + if (!sourceDevice?.playlist_id) return res.status(404).json({ error: 'Source device has no playlist' }); - const target = db.prepare('SELECT id FROM devices WHERE id = ?').get(req.params.targetDeviceId); + const sourceItems = db.prepare('SELECT * FROM playlist_items WHERE playlist_id = ? ORDER BY sort_order') + .all(sourceDevice.playlist_id); + if (!sourceItems.length) return res.status(404).json({ error: 'Source playlist is empty' }); + + const target = db.prepare('SELECT id, user_id FROM devices WHERE id = ?').get(req.params.targetDeviceId); if (!target) return res.status(404).json({ error: 'Target device not found' }); - // Clear existing assignments on target if requested + const targetPlaylistId = ensureDevicePlaylist(req.params.targetDeviceId, target.user_id || req.user.id); + if (req.body.replace) { - db.prepare('DELETE FROM assignments WHERE device_id = ?').run(req.params.targetDeviceId); + db.prepare('DELETE FROM playlist_items WHERE playlist_id = ?').run(targetPlaylistId); } - const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM assignments WHERE device_id = ?').get(req.params.targetDeviceId).m || 0; - const stmt = db.prepare('INSERT OR IGNORE INTO assignments (device_id, content_id, widget_id, zone_id, sort_order, duration_sec, enabled) VALUES (?, ?, ?, ?, ?, ?, 1)'); + const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM playlist_items WHERE playlist_id = ?') + .get(targetPlaylistId).m || 0; + const stmt = db.prepare('INSERT INTO playlist_items (playlist_id, content_id, widget_id, sort_order, duration_sec) VALUES (?, ?, ?, ?, ?)'); const transaction = db.transaction(() => { - source.forEach((a, i) => { - stmt.run(req.params.targetDeviceId, a.content_id, a.widget_id, a.zone_id, maxOrder + i + 1, a.duration_sec); + sourceItems.forEach((a, i) => { + stmt.run(targetPlaylistId, a.content_id, a.widget_id, maxOrder + i + 1, a.duration_sec); }); }); transaction(); + db.prepare("UPDATE playlists SET updated_at = strftime('%s','now') WHERE id = ?").run(targetPlaylistId); pushPlaylistToDevice(req, req.params.targetDeviceId); - res.json({ success: true, copied: source.length }); + res.json({ success: true, copied: sourceItems.length }); }); module.exports = router;