Phase 3: playlist publish/draft state with auto-publish from device detail

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>
This commit is contained in:
ScreenTinker 2026-04-13 20:52:29 -05:00
parent 1cf6b93512
commit 436a3be7f6
11 changed files with 361 additions and 56 deletions

View file

@ -115,6 +115,8 @@ export const api = {
deletePlaylistItem: (id, itemId) => request(`/playlists/${id}/items/${itemId}`, { method: 'DELETE' }), deletePlaylistItem: (id, itemId) => request(`/playlists/${id}/items/${itemId}`, { method: 'DELETE' }),
reorderPlaylistItems: (id, order) => request(`/playlists/${id}/items/reorder`, { method: 'POST', body: JSON.stringify({ order }) }), reorderPlaylistItems: (id, order) => request(`/playlists/${id}/items/reorder`, { method: 'POST', body: JSON.stringify({ order }) }),
assignPlaylistToDevice: (playlistId, device_id) => request(`/playlists/${playlistId}/assign`, { method: 'POST', body: JSON.stringify({ device_id }) }), assignPlaylistToDevice: (playlistId, device_id) => request(`/playlists/${playlistId}/assign`, { method: 'POST', body: JSON.stringify({ device_id }) }),
publishPlaylist: (id) => request(`/playlists/${id}/publish`, { method: 'POST' }),
discardPlaylistDraft: (id) => request(`/playlists/${id}/discard`, { method: 'POST' }),
// Device Groups - Playlist // Device Groups - Playlist
groupAssignPlaylist: (groupId, playlist_id) => request(`/groups/${groupId}/assign-playlist`, { method: 'POST', body: JSON.stringify({ playlist_id }) }), groupAssignPlaylist: (groupId, playlist_id) => request(`/groups/${groupId}/assign-playlist`, { method: 'POST', body: JSON.stringify({ playlist_id }) }),

View file

@ -159,6 +159,21 @@ async function loadDevice(deviceId, activeTab = null) {
<!-- Playlist Tab --> <!-- Playlist Tab -->
<div class="tab-content" id="tab-playlist"> <div class="tab-content" id="tab-playlist">
${device.playlist_status === 'draft' ? `
<div id="deviceDraftBanner" style="background:#78350f;border:1px solid #92400e;border-radius:var(--radius);padding:14px 20px;margin-bottom:16px;display:flex;align-items:center;justify-content:space-between;gap:16px">
<div style="display:flex;align-items:center;gap:10px;color:#fbbf24">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<div>
<div style="font-weight:600;font-size:14px">Unpublished changes</div>
<div style="font-size:12px;color:#fcd34d;opacity:0.85">${device.playlist_has_published ? 'Devices are still showing the last published version.' : 'This playlist has never been published. Devices will show nothing until you publish.'}</div>
</div>
</div>
<div style="display:flex;gap:8px;flex-shrink:0">
${device.playlist_has_published ? '<button class="btn btn-secondary btn-sm" id="deviceDiscardDraftBtn" style="color:#fbbf24;border-color:#92400e">Discard</button>' : ''}
<button class="btn btn-sm" id="devicePublishBtn" style="background:#f59e0b;color:#000;font-weight:600;border:none">Publish</button>
</div>
</div>
` : ''}
<!-- Layout selector --> <!-- Layout selector -->
<div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:12px 16px;margin-bottom:16px;display:flex;align-items:center;gap:12px"> <div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:12px 16px;margin-bottom:16px;display:flex;align-items:center;gap:12px">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--text-secondary)" stroke-width="2"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--text-secondary)" stroke-width="2">
@ -568,6 +583,37 @@ async function setupActions(device) {
} }
}); });
// Publish / Discard from device detail
const devicePublishBtn = document.getElementById('devicePublishBtn');
if (devicePublishBtn && device.playlist_id) {
devicePublishBtn.addEventListener('click', async () => {
try {
devicePublishBtn.disabled = true;
devicePublishBtn.textContent = 'Publishing...';
await api.publishPlaylist(device.playlist_id);
showToast('Playlist published — devices updated');
loadDevice(device.id, 'playlist');
} catch (err) {
devicePublishBtn.disabled = false;
devicePublishBtn.textContent = 'Publish';
showToast(err.message, 'error');
}
});
}
const deviceDiscardBtn = document.getElementById('deviceDiscardDraftBtn');
if (deviceDiscardBtn && device.playlist_id) {
deviceDiscardBtn.addEventListener('click', async () => {
if (!confirm('Discard all unpublished changes and revert to the last published version?')) return;
try {
await api.discardPlaylistDraft(device.playlist_id);
showToast('Draft changes discarded');
loadDevice(device.id, 'playlist');
} catch (err) {
showToast(err.message, 'error');
}
});
}
// Populate playlist picker // Populate playlist picker
const playlistPicker = document.getElementById('playlistPicker'); const playlistPicker = document.getElementById('playlistPicker');
if (playlistPicker) { if (playlistPicker) {

View file

@ -99,6 +99,7 @@ async function loadPlaylists() {
<div style="display:flex;align-items:center;gap:8px"> <div style="display:flex;align-items:center;gap:8px">
<div style="font-size:16px;font-weight:600;color:var(--text-primary)">${esc(p.name)}</div> <div style="font-size:16px;font-weight:600;color:var(--text-primary)">${esc(p.name)}</div>
${p.is_auto_generated ? '<span style="font-size:10px;padding:2px 6px;border-radius:4px;background:var(--bg-input);color:var(--text-muted)">auto</span>' : ''} ${p.is_auto_generated ? '<span style="font-size:10px;padding:2px 6px;border-radius:4px;background:var(--bg-input);color:var(--text-muted)">auto</span>' : ''}
${p.status === 'draft' ? '<span style="font-size:10px;padding:2px 6px;border-radius:4px;background:#78350f;color:#fbbf24">draft</span>' : ''}
</div> </div>
<div style="font-size:12px;color:var(--text-muted);white-space:nowrap;margin-left:12px">${p.item_count} item${p.item_count !== 1 ? 's' : ''}</div> <div style="font-size:12px;color:var(--text-muted);white-space:nowrap;margin-left:12px">${p.item_count} item${p.item_count !== 1 ? 's' : ''}</div>
</div> </div>
@ -175,7 +176,26 @@ async function renderDetail(container, playlistId) {
} }
function renderDetailContent(container, playlist) { function renderDetailContent(container, playlist) {
const isDraft = playlist.status === 'draft';
const hasPublished = !!playlist.published_snapshot;
container.innerHTML = ` container.innerHTML = `
${isDraft ? `
<div id="draftBanner" style="background:#78350f;border:1px solid #92400e;border-radius:var(--radius-lg);padding:14px 20px;margin-bottom:16px;display:flex;align-items:center;justify-content:space-between;gap:16px">
<div style="display:flex;align-items:center;gap:10px;color:#fbbf24">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<div>
<div style="font-weight:600;font-size:14px">Unpublished changes</div>
<div style="font-size:12px;color:#fcd34d;opacity:0.85">${hasPublished ? 'Devices are still showing the last published version.' : 'This playlist has never been published. Devices will show nothing until you publish.'}</div>
</div>
</div>
<div style="display:flex;gap:8px;flex-shrink:0">
${hasPublished ? '<button class="btn btn-secondary btn-sm" id="discardDraftBtn" style="color:#fbbf24;border-color:#92400e">Discard Changes</button>' : ''}
<button class="btn btn-sm" id="publishBtn" style="background:#f59e0b;color:#000;font-weight:600;border:none">Publish</button>
</div>
</div>
` : ''}
<div class="page-header"> <div class="page-header">
<div style="display:flex;align-items:center;gap:12px"> <div style="display:flex;align-items:center;gap:12px">
<a href="#/playlists" style="color:var(--text-muted);text-decoration:none;font-size:20px" title="Back">&larr;</a> <a href="#/playlists" style="color:var(--text-muted);text-decoration:none;font-size:20px" title="Back">&larr;</a>
@ -197,6 +217,37 @@ function renderDetailContent(container, playlist) {
renderItems(playlist.items || []); renderItems(playlist.items || []);
// Publish / Discard handlers
const publishBtn = document.getElementById('publishBtn');
if (publishBtn) {
publishBtn.addEventListener('click', async () => {
try {
publishBtn.disabled = true;
publishBtn.textContent = 'Publishing...';
const updated = await api.publishPlaylist(playlist.id);
showToast('Playlist published — devices updated');
renderDetailContent(container, updated);
} catch (err) {
publishBtn.disabled = false;
publishBtn.textContent = 'Publish';
showToast(err.message, 'error');
}
});
}
const discardBtn = document.getElementById('discardDraftBtn');
if (discardBtn) {
discardBtn.addEventListener('click', async () => {
if (!confirm('Discard all unpublished changes and revert to the last published version?')) return;
try {
const updated = await api.discardPlaylistDraft(playlist.id);
showToast('Draft changes discarded');
renderDetailContent(container, updated);
} catch (err) {
showToast(err.message, 'error');
}
});
}
// Inline rename // Inline rename
document.getElementById('playlistTitle').addEventListener('click', () => inlineEdit(playlist, 'name')); document.getElementById('playlistTitle').addEventListener('click', () => inlineEdit(playlist, 'name'));
document.getElementById('playlistDesc').addEventListener('click', () => inlineEdit(playlist, 'description')); document.getElementById('playlistDesc').addEventListener('click', () => inlineEdit(playlist, 'description'));
@ -217,6 +268,17 @@ function renderDetailContent(container, playlist) {
}); });
} }
// After any item mutation, re-fetch and re-render the full detail to update the draft banner
async function refreshAfterMutation() {
if (!currentPlaylistId) return;
const mainContainer = document.getElementById('draftBanner')?.parentElement || document.querySelector('.page-header')?.parentElement;
if (!mainContainer) return;
try {
const playlist = await api.getPlaylist(currentPlaylistId);
renderDetailContent(mainContainer, playlist);
} catch (e) { /* silent */ }
}
function renderItems(items) { function renderItems(items) {
const itemsEl = document.getElementById('playlistItems'); const itemsEl = document.getElementById('playlistItems');
if (!itemsEl) return; if (!itemsEl) return;
@ -263,6 +325,7 @@ function renderItems(items) {
if (!val || val < 1) { e.target.value = 10; return; } if (!val || val < 1) { e.target.value = 10; return; }
try { try {
await api.updatePlaylistItem(currentPlaylistId, itemId, { duration_sec: val }); await api.updatePlaylistItem(currentPlaylistId, itemId, { duration_sec: val });
refreshAfterMutation();
} catch (err) { } catch (err) {
showToast(err.message, 'error'); showToast(err.message, 'error');
} }
@ -277,6 +340,7 @@ function renderItems(items) {
await api.deletePlaylistItem(currentPlaylistId, itemId); await api.deletePlaylistItem(currentPlaylistId, itemId);
const playlist = await api.getPlaylist(currentPlaylistId); const playlist = await api.getPlaylist(currentPlaylistId);
renderItems(playlist.items || []); renderItems(playlist.items || []);
refreshAfterMutation();
showToast('Item removed'); showToast('Item removed');
} catch (err) { } catch (err) {
showToast(err.message, 'error'); showToast(err.message, 'error');
@ -329,6 +393,7 @@ function setupDragReorder(container) {
try { try {
const items = await api.reorderPlaylistItems(currentPlaylistId, order); const items = await api.reorderPlaylistItems(currentPlaylistId, order);
renderItems(items); renderItems(items);
refreshAfterMutation();
} catch (err) { } catch (err) {
showToast(err.message, 'error'); showToast(err.message, 'error');
// Reload to fix state // Reload to fix state
@ -494,9 +559,8 @@ async function showAddItemModal(playlistId) {
btn.textContent = 'Added'; btn.textContent = 'Added';
btn.classList.remove('btn-primary'); btn.classList.remove('btn-primary');
btn.classList.add('btn-secondary'); btn.classList.add('btn-secondary');
// Refresh the detail view items // Refresh the detail view (items + draft banner)
const playlist = await api.getPlaylist(playlistId); refreshAfterMutation();
renderItems(playlist.items || []);
} catch (err) { } catch (err) {
btn.disabled = false; btn.disabled = false;
btn.textContent = 'Add'; btn.textContent = 'Add';

View file

@ -60,6 +60,9 @@ const migrations = [
"ALTER TABLE playlists ADD COLUMN is_auto_generated INTEGER NOT NULL DEFAULT 0", "ALTER TABLE playlists ADD COLUMN is_auto_generated INTEGER NOT NULL DEFAULT 0",
// Device authentication token // Device authentication token
"ALTER TABLE devices ADD COLUMN device_token TEXT", "ALTER TABLE devices ADD COLUMN device_token TEXT",
// Phase 3: playlist publish/draft state
"ALTER TABLE playlists ADD COLUMN status TEXT NOT NULL DEFAULT 'draft'",
"ALTER TABLE playlists ADD COLUMN published_snapshot TEXT",
]; ];
for (const sql of migrations) { for (const sql of migrations) {
try { db.exec(sql); } catch (e) { /* already exists */ } try { db.exec(sql); } catch (e) { /* already exists */ }
@ -198,6 +201,49 @@ async function migrateAssignmentsToPlaylists() {
migrateAssignmentsToPlaylists().catch(e => console.error('Migration error:', e)); migrateAssignmentsToPlaylists().catch(e => console.error('Migration error:', e));
// Phase 3 migration: snapshot existing playlist items into published_snapshot
const PHASE3_MIGRATION_ID = 'phase3_publish_snapshot';
function migratePublishSnapshots() {
const already = db.prepare('SELECT 1 FROM schema_migrations WHERE id = ?').get(PHASE3_MIGRATION_ID);
if (already) return;
const playlists = db.prepare('SELECT id FROM playlists').all();
if (playlists.length === 0) {
db.prepare('INSERT OR IGNORE INTO schema_migrations (id) VALUES (?)').run(PHASE3_MIGRATION_ID);
return;
}
console.log(`Phase 3 migration: snapshotting ${playlists.length} playlist(s) as published...`);
const getItems = 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
`);
const updatePlaylist = db.prepare("UPDATE playlists SET status = 'published', published_snapshot = ? WHERE id = ?");
const migrate = db.transaction(() => {
let snapshotted = 0;
for (const playlist of playlists) {
const items = getItems.all(playlist.id);
updatePlaylist.run(JSON.stringify(items), playlist.id);
snapshotted++;
}
db.prepare('INSERT OR IGNORE INTO schema_migrations (id) VALUES (?)').run(PHASE3_MIGRATION_ID);
console.log(`Phase 3 migration complete: ${snapshotted} playlist(s) snapshotted as published.`);
});
migrate();
}
migratePublishSnapshots();
// Prune old telemetry (keep last 24h worth at 15s intervals = ~5760, cap at 6000) // Prune old telemetry (keep last 24h worth at 15s intervals = ~5760, cap at 6000)
function pruneTelemetry(deviceId) { function pruneTelemetry(deviceId) {
db.prepare(` db.prepare(`

View file

@ -321,6 +321,8 @@ CREATE TABLE IF NOT EXISTS playlists (
name TEXT NOT NULL, name TEXT NOT NULL,
description TEXT DEFAULT '', description TEXT DEFAULT '',
is_auto_generated INTEGER NOT NULL DEFAULT 0, is_auto_generated INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'draft',
published_snapshot TEXT,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
); );

View file

@ -3,18 +3,37 @@ const router = express.Router();
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database'); const { db } = require('../db/database');
// Push playlist update to a connected device via WebSocket // Auto-publish: snapshot current items and push to devices.
function pushPlaylistToDevice(req, deviceId) { // 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 { try {
const io = req.app.get('io'); const io = req?.app?.get('io');
if (!io) return; if (!io) return;
const { buildPlaylistPayload } = require('../ws/deviceSocket'); const { buildPlaylistPayload } = require('../ws/deviceSocket');
if (!buildPlaylistPayload) return; if (!buildPlaylistPayload) return;
const deviceNs = io.of('/device'); if (deviceId) {
deviceNs.to(deviceId).emit('device:playlist-update', buildPlaylistPayload(deviceId)); io.of('/device').to(deviceId).emit('device:playlist-update', buildPlaylistPayload(deviceId));
} catch (e) { } else {
console.warn('Failed to push playlist update:', e.message); 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 // Check device ownership for device-scoped routes
@ -98,10 +117,9 @@ router.post('/device/:deviceId', (req, res) => {
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
`).run(playlistId, content_id || null, widget_id || null, order, duration_sec); `).run(playlistId, content_id || null, widget_id || null, order, duration_sec);
db.prepare("UPDATE playlists SET updated_at = strftime('%s','now') WHERE id = ?").run(playlistId); autoPublish(playlistId, req, req.params.deviceId);
const item = db.prepare(`${ITEM_SELECT} WHERE pi.id = ?`).get(result.lastInsertRowid); const item = db.prepare(`${ITEM_SELECT} WHERE pi.id = ?`).get(result.lastInsertRowid);
pushPlaylistToDevice(req, req.params.deviceId);
res.status(201).json(item); res.status(201).json(item);
} catch (err) { } catch (err) {
if (err.message.includes('UNIQUE')) { if (err.message.includes('UNIQUE')) {
@ -127,15 +145,10 @@ router.put('/:id', (req, res) => {
updates.push("updated_at = strftime('%s','now')"); updates.push("updated_at = strftime('%s','now')");
values.push(req.params.id); values.push(req.params.id);
db.prepare(`UPDATE playlist_items 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); autoPublish(item.playlist_id, req, null);
} }
const updated = db.prepare(`${ITEM_SELECT} WHERE pi.id = ?`).get(req.params.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); res.json(updated);
}); });
@ -145,11 +158,7 @@ router.delete('/:id', (req, res) => {
if (!item) return res.status(404).json({ error: 'Item not found' }); if (!item) return res.status(404).json({ error: 'Item not found' });
db.prepare('DELETE FROM playlist_items WHERE id = ?').run(req.params.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); autoPublish(item.playlist_id, req, null);
// 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 }); res.json({ success: true, content_id: item.content_id });
}); });
@ -171,11 +180,10 @@ router.post('/device/:deviceId/reorder', (req, res) => {
}); });
transaction(); transaction();
db.prepare("UPDATE playlists SET updated_at = strftime('%s','now') WHERE id = ?").run(device.playlist_id); autoPublish(device.playlist_id, req, req.params.deviceId);
const items = db.prepare(`${ITEM_SELECT} WHERE pi.playlist_id = ? ORDER BY pi.sort_order ASC`) const items = db.prepare(`${ITEM_SELECT} WHERE pi.playlist_id = ? ORDER BY pi.sort_order ASC`)
.all(device.playlist_id); .all(device.playlist_id);
pushPlaylistToDevice(req, req.params.deviceId);
res.json(items); res.json(items);
}); });
@ -208,8 +216,7 @@ router.post('/device/:deviceId/copy-to/:targetDeviceId', (req, res) => {
}); });
transaction(); transaction();
db.prepare("UPDATE playlists SET updated_at = strftime('%s','now') WHERE id = ?").run(targetPlaylistId); autoPublish(targetPlaylistId, req, req.params.targetDeviceId);
pushPlaylistToDevice(req, req.params.targetDeviceId);
res.json({ success: true, copied: sourceItems.length }); res.json({ success: true, copied: sourceItems.length });
}); });

View file

@ -308,14 +308,43 @@ router.delete('/:id', (req, res) => {
if (fs.existsSync(thumbPath)) fs.unlinkSync(thumbPath); if (fs.existsSync(thumbPath)) fs.unlinkSync(thumbPath);
} }
// Get devices that have this content assigned (to notify them) // Get devices that have this content in their playlist (via playlist_items)
const affectedDevices = db.prepare( const affectedDevices = db.prepare(`
'SELECT DISTINCT device_id FROM assignments WHERE content_id = ?' SELECT DISTINCT d.id as device_id FROM devices d
).all(req.params.id); JOIN playlists p ON d.playlist_id = p.id
JOIN playlist_items pi ON pi.playlist_id = p.id
WHERE pi.content_id = ?
`).all(req.params.id);
// Delete from DB (cascades to assignments) // Scrub published snapshots that reference this content
const snapshotPlaylists = db.prepare(
"SELECT id, published_snapshot FROM playlists WHERE published_snapshot LIKE ?"
).all(`%${req.params.id}%`);
for (const pl of snapshotPlaylists) {
try {
const items = JSON.parse(pl.published_snapshot);
const filtered = items.filter(item => item.content_id !== req.params.id);
if (filtered.length !== items.length) {
db.prepare('UPDATE playlists SET published_snapshot = ? WHERE id = ?')
.run(JSON.stringify(filtered), pl.id);
}
} catch (e) { /* corrupt snapshot, skip */ }
}
// Delete from DB (cascades to playlist_items via ON DELETE CASCADE)
db.prepare('DELETE FROM content WHERE id = ?').run(req.params.id); db.prepare('DELETE FROM content WHERE id = ?').run(req.params.id);
// Push updated snapshots to affected devices
try {
const io = req.app.get('io');
if (io) {
const { buildPlaylistPayload } = require('../ws/deviceSocket');
for (const d of affectedDevices) {
io.of('/device').to(d.device_id).emit('device:playlist-update', buildPlaylistPayload(d.device_id));
}
}
} catch (e) { /* silent */ }
res.json({ success: true, affectedDevices: affectedDevices.map(d => d.device_id) }); res.json({ success: true, affectedDevices: affectedDevices.map(d => d.device_id) });
}); });

View file

@ -93,7 +93,33 @@ function ensureDevicePlaylist(deviceId, userId) {
return playlistId; return playlistId;
} }
// Push playlist update to a device // Auto-publish: snapshot current items and push to device.
// Group assign-content is the bulk equivalent of device-detail — always goes live immediately.
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 && deviceId) {
io.of('/device').to(deviceId).emit('device:playlist-update', buildPlaylistPayload(deviceId));
}
} catch (e) { /* silent */ }
}
// Push playlist update to a device (used by assign-playlist which doesn't modify items)
function pushPlaylistToDevice(req, deviceId) { function pushPlaylistToDevice(req, deviceId) {
try { try {
const io = req.app.get('io'); const io = req.app.get('io');
@ -121,12 +147,11 @@ router.post('/:id/assign-content', requireGroupOwnership, (req, res) => {
const max = db.prepare('SELECT COALESCE(MAX(sort_order),0)+1 as next FROM playlist_items WHERE playlist_id = ?').get(playlistId); const max = db.prepare('SELECT COALESCE(MAX(sort_order),0)+1 as next FROM playlist_items WHERE playlist_id = ?').get(playlistId);
db.prepare('INSERT INTO playlist_items (playlist_id, content_id, sort_order, duration_sec) VALUES (?, ?, ?, ?)') db.prepare('INSERT INTO playlist_items (playlist_id, content_id, sort_order, duration_sec) VALUES (?, ?, ?, ?)')
.run(playlistId, content_id, max.next, duration_sec || 10); .run(playlistId, content_id, max.next, duration_sec || 10);
db.prepare("UPDATE playlists SET updated_at = strftime('%s','now') WHERE id = ?").run(playlistId); autoPublish(playlistId, req, m.device_id);
} }
}); });
transaction(); transaction();
for (const m of members) pushPlaylistToDevice(req, m.device_id);
res.json({ success: true, devices_updated: members.length }); res.json({ success: true, devices_updated: members.length });
}); });

View file

@ -64,8 +64,10 @@ router.get('/:id', (req, res) => {
'SELECT * FROM screenshots WHERE device_id = ? ORDER BY captured_at DESC LIMIT 1' 'SELECT * FROM screenshots WHERE device_id = ? ORDER BY captured_at DESC LIMIT 1'
).get(req.params.id); ).get(req.params.id);
// Get playlist items if device has an assigned playlist // Get playlist items and status if device has an assigned playlist
let assignments = []; let assignments = [];
let playlist_status = null;
let playlist_has_published = false;
if (device.playlist_id) { if (device.playlist_id) {
assignments = db.prepare(` assignments = db.prepare(`
SELECT pi.id, pi.content_id, pi.widget_id, pi.sort_order, pi.duration_sec, SELECT pi.id, pi.content_id, pi.widget_id, pi.sort_order, pi.duration_sec,
@ -79,6 +81,11 @@ router.get('/:id', (req, res) => {
WHERE pi.playlist_id = ? WHERE pi.playlist_id = ?
ORDER BY pi.sort_order ASC ORDER BY pi.sort_order ASC
`).all(device.playlist_id); `).all(device.playlist_id);
const pl = db.prepare('SELECT status, published_snapshot FROM playlists WHERE id = ?').get(device.playlist_id);
if (pl) {
playlist_status = pl.status;
playlist_has_published = pl.published_snapshot !== null;
}
} }
// Uptime timeline: get status change events for last 24 hours // Uptime timeline: get status change events for last 24 hours
@ -95,7 +102,7 @@ router.get('/:id', (req, res) => {
'SELECT reported_at FROM device_telemetry WHERE device_id = ? AND reported_at > ? ORDER BY reported_at ASC' 'SELECT reported_at FROM device_telemetry WHERE device_id = ? AND reported_at > ? ORDER BY reported_at ASC'
).all(req.params.id, dayAgo).map(r => r.reported_at); ).all(req.params.id, dayAgo).map(r => r.reported_at);
res.json({ ...device, telemetry, screenshot, assignments, uptimeData, statusLog }); res.json({ ...device, telemetry, screenshot, assignments, playlist_status, playlist_has_published, uptimeData, statusLog });
}); });
// Helper: check device ownership // Helper: check device ownership

View file

@ -41,7 +41,40 @@ function requirePlaylistOwnership(req, res, next) {
next(); next();
} }
// List playlists // Build the snapshot item list for a playlist (denormalized for device payload)
function buildSnapshotItems(playlistId) {
return 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);
}
// Mark playlist as draft (called after item mutations from the playlist detail UI)
function markDraft(playlistId) {
db.prepare("UPDATE playlists SET status = 'draft', updated_at = strftime('%s','now') WHERE id = ?").run(playlistId);
}
// Push playlist update to all devices using this playlist
function pushToDevices(playlistId, req) {
try {
const io = req.app.get('io');
if (!io) return;
const { buildPlaylistPayload } = require('../ws/deviceSocket');
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 */ }
}
// List playlists (status is already in p.*)
router.get('/', (req, res) => { router.get('/', (req, res) => {
const playlists = db.prepare(` const playlists = db.prepare(`
SELECT p.*, COUNT(DISTINCT pi.id) as item_count, COUNT(DISTINCT d.id) as display_count SELECT p.*, COUNT(DISTINCT pi.id) as item_count, COUNT(DISTINCT d.id) as display_count
@ -107,6 +140,57 @@ router.put('/:id', requirePlaylistOwnership, (req, res) => {
res.json(db.prepare('SELECT * FROM playlists WHERE id = ?').get(req.params.id)); res.json(db.prepare('SELECT * FROM playlists WHERE id = ?').get(req.params.id));
}); });
// Publish playlist — snapshot current items and push to devices
router.post('/:id/publish', requirePlaylistOwnership, (req, res) => {
const items = buildSnapshotItems(req.params.id);
db.prepare("UPDATE playlists SET status = 'published', published_snapshot = ?, updated_at = strftime('%s','now') WHERE id = ?")
.run(JSON.stringify(items), req.params.id);
pushToDevices(req.params.id, req);
res.json({ ...db.prepare('SELECT * FROM playlists WHERE id = ?').get(req.params.id), items });
});
// Discard draft — revert playlist_items to match published_snapshot
router.post('/:id/discard', requirePlaylistOwnership, (req, res) => {
const playlist = req.playlist;
if (!playlist.published_snapshot) {
return res.status(400).json({ error: 'No published version to revert to' });
}
if (playlist.status === 'published') {
return res.status(400).json({ error: 'Playlist has no unpublished changes' });
}
let publishedItems;
try { publishedItems = JSON.parse(playlist.published_snapshot); } catch (e) {
return res.status(500).json({ error: 'Corrupt published snapshot' });
}
const transaction = db.transaction(() => {
// Clear current draft items
db.prepare('DELETE FROM playlist_items WHERE playlist_id = ?').run(req.params.id);
// Re-insert from snapshot
const insert = db.prepare('INSERT INTO playlist_items (playlist_id, content_id, widget_id, sort_order, duration_sec) VALUES (?, ?, ?, ?, ?)');
for (const item of publishedItems) {
insert.run(req.params.id, item.content_id || null, item.widget_id || null, item.sort_order, item.duration_sec);
}
db.prepare("UPDATE playlists SET status = 'published', updated_at = strftime('%s','now') WHERE id = ?").run(req.params.id);
});
transaction();
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({ ...db.prepare('SELECT * FROM playlists WHERE id = ?').get(req.params.id), items });
});
// Delete playlist // Delete playlist
router.delete('/:id', requirePlaylistOwnership, (req, res) => { router.delete('/:id', requirePlaylistOwnership, (req, res) => {
db.prepare('DELETE FROM playlists WHERE id = ?').run(req.params.id); db.prepare('DELETE FROM playlists WHERE id = ?').run(req.params.id);
@ -175,8 +259,8 @@ router.post('/:id/items', requirePlaylistOwnership, async (req, res) => {
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
`).run(req.params.id, content_id || null, widget_id || null, order, duration_sec); `).run(req.params.id, content_id || null, widget_id || null, order, duration_sec);
// Touch playlist updated_at // Mark as draft (items changed since last publish)
db.prepare("UPDATE playlists SET updated_at = strftime('%s','now') WHERE id = ?").run(req.params.id); markDraft(req.params.id);
const item = db.prepare(` const item = db.prepare(`
SELECT pi.*, SELECT pi.*,
@ -220,7 +304,7 @@ router.put('/:id/items/:itemId', requirePlaylistOwnership, (req, res) => {
updates.push("updated_at = strftime('%s','now')"); updates.push("updated_at = strftime('%s','now')");
values.push(req.params.itemId); values.push(req.params.itemId);
db.prepare(`UPDATE playlist_items 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(req.params.id); markDraft(req.params.id);
} }
const updated = db.prepare(` const updated = db.prepare(`
@ -244,7 +328,7 @@ router.delete('/:id/items/:itemId', requirePlaylistOwnership, (req, res) => {
if (!item) return res.status(404).json({ error: 'item not found' }); 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('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); markDraft(req.params.id);
res.json({ success: true }); res.json({ success: true });
}); });
@ -261,7 +345,7 @@ router.post('/:id/items/reorder', requirePlaylistOwnership, (req, res) => {
}); });
transaction(); transaction();
db.prepare("UPDATE playlists SET updated_at = strftime('%s','now') WHERE id = ?").run(req.params.id); markDraft(req.params.id);
const items = db.prepare(` const items = db.prepare(`
SELECT pi.*, SELECT pi.*,

View file

@ -44,23 +44,16 @@ function logDeviceStatus(deviceId, status) {
// Build playlist payload with layout and zones // Build playlist payload with layout and zones
// Reads from the device's assigned playlist (Phase 2) instead of assignments table // Reads from published_snapshot (Phase 3) so draft edits don't affect live devices
function buildPlaylistPayload(deviceId) { function buildPlaylistPayload(deviceId) {
const device = db.prepare('SELECT playlist_id, layout_id, orientation FROM devices WHERE id = ?').get(deviceId); const device = db.prepare('SELECT playlist_id, layout_id, orientation FROM devices WHERE id = ?').get(deviceId);
let assignments = []; let assignments = [];
if (device?.playlist_id) { if (device?.playlist_id) {
assignments = db.prepare(` const playlist = db.prepare('SELECT published_snapshot FROM playlists WHERE id = ?').get(device.playlist_id);
SELECT pi.id, pi.content_id, pi.widget_id, pi.sort_order, pi.duration_sec, if (playlist?.published_snapshot) {
COALESCE(c.filename, w.name) as filename, c.mime_type, c.filepath, c.file_size, try { assignments = JSON.parse(playlist.published_snapshot); } catch (e) { assignments = []; }
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(device.playlist_id);
} }
let layout = null; let layout = null;