mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
Schema: add status and published_snapshot columns to playlists table. Migration snapshots all existing playlists as published (idempotent via schema_migrations). Devices always receive the published_snapshot, not live playlist_items. Edits from device-detail/groups auto-publish immediately (display updates instantly). Edits from playlist detail page go to draft (requires explicit publish). POST /playlists/:id/publish snapshots and pushes to all devices. POST /playlists/:id/discard reverts playlist_items from published snapshot. Content deletion scrubs references from all published snapshots. Frontend: draft badge in playlist list, prominent yellow banner with publish/discard buttons on playlist detail and device detail pages. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
224 lines
9.5 KiB
JavaScript
224 lines
9.5 KiB
JavaScript
const express = require('express');
|
|
const router = express.Router();
|
|
const { v4: uuidv4 } = require('uuid');
|
|
const { db } = require('../db/database');
|
|
|
|
// Auto-publish: snapshot current items and push to devices.
|
|
// Device-detail edits always go live immediately.
|
|
// If deviceId is provided, pushes to that device only; otherwise pushes to all devices using this playlist.
|
|
function autoPublish(playlistId, req, deviceId) {
|
|
const items = db.prepare(`
|
|
SELECT pi.content_id, pi.widget_id, pi.sort_order, pi.duration_sec,
|
|
COALESCE(c.filename, w.name) as filename, c.mime_type, c.filepath, c.file_size,
|
|
c.duration_sec as content_duration, 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(playlistId);
|
|
db.prepare("UPDATE playlists SET status = 'published', published_snapshot = ?, updated_at = strftime('%s','now') WHERE id = ?")
|
|
.run(JSON.stringify(items), playlistId);
|
|
try {
|
|
const io = req?.app?.get('io');
|
|
if (!io) return;
|
|
const { buildPlaylistPayload } = require('../ws/deviceSocket');
|
|
if (!buildPlaylistPayload) return;
|
|
if (deviceId) {
|
|
io.of('/device').to(deviceId).emit('device:playlist-update', buildPlaylistPayload(deviceId));
|
|
} else {
|
|
const devices = db.prepare('SELECT id FROM devices WHERE playlist_id = ?').all(playlistId);
|
|
for (const d of devices) {
|
|
io.of('/device').to(d.id).emit('device:playlist-update', buildPlaylistPayload(d.id));
|
|
}
|
|
}
|
|
} catch (e) { /* silent */ }
|
|
}
|
|
|
|
// Check device ownership for device-scoped routes
|
|
function checkDeviceAccess(req, res) {
|
|
const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(req.params.deviceId);
|
|
if (!device) { res.status(404).json({ error: 'Device not found' }); return false; }
|
|
if (!['admin','superadmin'].includes(req.user.role) && device.user_id && device.user_id !== req.user.id) {
|
|
res.status(403).json({ error: 'Access denied' }); return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// 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 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 } = req.body;
|
|
|
|
if (!content_id && !widget_id) return res.status(400).json({ error: 'content_id or widget_id required' });
|
|
|
|
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' });
|
|
}
|
|
|
|
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 playlist_items WHERE playlist_id = ?')
|
|
.get(playlistId);
|
|
order = (max.max_order || 0) + 1;
|
|
}
|
|
|
|
try {
|
|
const result = db.prepare(`
|
|
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);
|
|
|
|
autoPublish(playlistId, req, req.params.deviceId);
|
|
|
|
const item = db.prepare(`${ITEM_SELECT} WHERE pi.id = ?`).get(result.lastInsertRowid);
|
|
res.status(201).json(item);
|
|
} catch (err) {
|
|
if (err.message.includes('UNIQUE')) {
|
|
return res.status(409).json({ error: 'Content already in playlist' });
|
|
}
|
|
throw err;
|
|
}
|
|
});
|
|
|
|
// Update playlist item
|
|
router.put('/:id', (req, res) => {
|
|
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, 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 (updates.length > 0) {
|
|
updates.push("updated_at = strftime('%s','now')");
|
|
values.push(req.params.id);
|
|
db.prepare(`UPDATE playlist_items SET ${updates.join(', ')} WHERE id = ?`).run(...values);
|
|
autoPublish(item.playlist_id, req, null);
|
|
}
|
|
|
|
const updated = db.prepare(`${ITEM_SELECT} WHERE pi.id = ?`).get(req.params.id);
|
|
res.json(updated);
|
|
});
|
|
|
|
// Delete playlist item
|
|
router.delete('/:id', (req, res) => {
|
|
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 playlist_items WHERE id = ?').run(req.params.id);
|
|
autoPublish(item.playlist_id, req, null);
|
|
|
|
res.json({ success: true, content_id: item.content_id });
|
|
});
|
|
|
|
// Reorder items for a device's playlist
|
|
router.post('/device/:deviceId/reorder', (req, res) => {
|
|
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 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((itemId, index) => {
|
|
updateStmt.run(index, itemId, device.playlist_id);
|
|
});
|
|
});
|
|
transaction();
|
|
|
|
autoPublish(device.playlist_id, req, req.params.deviceId);
|
|
|
|
const items = db.prepare(`${ITEM_SELECT} WHERE pi.playlist_id = ? ORDER BY pi.sort_order ASC`)
|
|
.all(device.playlist_id);
|
|
res.json(items);
|
|
});
|
|
|
|
// Copy playlist from one device to another
|
|
router.post('/device/:deviceId/copy-to/:targetDeviceId', (req, res) => {
|
|
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 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' });
|
|
|
|
const targetPlaylistId = ensureDevicePlaylist(req.params.targetDeviceId, target.user_id || req.user.id);
|
|
|
|
if (req.body.replace) {
|
|
db.prepare('DELETE FROM playlist_items WHERE playlist_id = ?').run(targetPlaylistId);
|
|
}
|
|
|
|
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(() => {
|
|
sourceItems.forEach((a, i) => {
|
|
stmt.run(targetPlaylistId, a.content_id, a.widget_id, maxOrder + i + 1, a.duration_sec);
|
|
});
|
|
});
|
|
transaction();
|
|
|
|
autoPublish(targetPlaylistId, req, req.params.targetDeviceId);
|
|
res.json({ success: true, copied: sourceItems.length });
|
|
});
|
|
|
|
module.exports = router;
|