${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.'}
${hasPublished ? 'Devices are still showing the last published version.' : 'This playlist has never been published. Devices will show nothing until you publish.'}
+
+
+
+ ${hasPublished ? '' : ''}
+
+
+
+ ` : ''}
+
←
@@ -197,6 +217,37 @@ function renderDetailContent(container, playlist) {
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
document.getElementById('playlistTitle').addEventListener('click', () => inlineEdit(playlist, 'name'));
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) {
const itemsEl = document.getElementById('playlistItems');
if (!itemsEl) return;
@@ -263,6 +325,7 @@ function renderItems(items) {
if (!val || val < 1) { e.target.value = 10; return; }
try {
await api.updatePlaylistItem(currentPlaylistId, itemId, { duration_sec: val });
+ refreshAfterMutation();
} catch (err) {
showToast(err.message, 'error');
}
@@ -277,6 +340,7 @@ function renderItems(items) {
await api.deletePlaylistItem(currentPlaylistId, itemId);
const playlist = await api.getPlaylist(currentPlaylistId);
renderItems(playlist.items || []);
+ refreshAfterMutation();
showToast('Item removed');
} catch (err) {
showToast(err.message, 'error');
@@ -329,6 +393,7 @@ function setupDragReorder(container) {
try {
const items = await api.reorderPlaylistItems(currentPlaylistId, order);
renderItems(items);
+ refreshAfterMutation();
} catch (err) {
showToast(err.message, 'error');
// Reload to fix state
@@ -494,9 +559,8 @@ async function showAddItemModal(playlistId) {
btn.textContent = 'Added';
btn.classList.remove('btn-primary');
btn.classList.add('btn-secondary');
- // Refresh the detail view items
- const playlist = await api.getPlaylist(playlistId);
- renderItems(playlist.items || []);
+ // Refresh the detail view (items + draft banner)
+ refreshAfterMutation();
} catch (err) {
btn.disabled = false;
btn.textContent = 'Add';
diff --git a/server/db/database.js b/server/db/database.js
index 11d67b6..63de5b0 100644
--- a/server/db/database.js
+++ b/server/db/database.js
@@ -60,6 +60,9 @@ const migrations = [
"ALTER TABLE playlists ADD COLUMN is_auto_generated INTEGER NOT NULL DEFAULT 0",
// Device authentication token
"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) {
try { db.exec(sql); } catch (e) { /* already exists */ }
@@ -198,6 +201,49 @@ async function migrateAssignmentsToPlaylists() {
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)
function pruneTelemetry(deviceId) {
db.prepare(`
diff --git a/server/db/schema.sql b/server/db/schema.sql
index dc983f5..60fa7ae 100644
--- a/server/db/schema.sql
+++ b/server/db/schema.sql
@@ -321,6 +321,8 @@ CREATE TABLE IF NOT EXISTS playlists (
name TEXT NOT NULL,
description TEXT DEFAULT '',
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')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
diff --git a/server/routes/assignments.js b/server/routes/assignments.js
index f7c1e4c..d4f6e47 100644
--- a/server/routes/assignments.js
+++ b/server/routes/assignments.js
@@ -3,18 +3,37 @@ const router = express.Router();
const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database');
-// Push playlist update to a connected device via WebSocket
-function pushPlaylistToDevice(req, deviceId) {
+// 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');
+ const io = req?.app?.get('io');
if (!io) return;
const { buildPlaylistPayload } = require('../ws/deviceSocket');
if (!buildPlaylistPayload) return;
- const deviceNs = io.of('/device');
- deviceNs.to(deviceId).emit('device:playlist-update', buildPlaylistPayload(deviceId));
- } catch (e) {
- console.warn('Failed to push playlist update:', e.message);
- }
+ 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
@@ -98,10 +117,9 @@ router.post('/device/:deviceId', (req, res) => {
VALUES (?, ?, ?, ?, ?)
`).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);
- pushPlaylistToDevice(req, req.params.deviceId);
res.status(201).json(item);
} catch (err) {
if (err.message.includes('UNIQUE')) {
@@ -127,15 +145,10 @@ router.put('/:id', (req, res) => {
updates.push("updated_at = strftime('%s','now')");
values.push(req.params.id);
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);
-
- // 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);
});
@@ -145,11 +158,7 @@ router.delete('/:id', (req, res) => {
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("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);
+ autoPublish(item.playlist_id, req, null);
res.json({ success: true, content_id: item.content_id });
});
@@ -171,11 +180,10 @@ router.post('/device/:deviceId/reorder', (req, res) => {
});
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`)
.all(device.playlist_id);
- pushPlaylistToDevice(req, req.params.deviceId);
res.json(items);
});
@@ -208,8 +216,7 @@ router.post('/device/:deviceId/copy-to/:targetDeviceId', (req, res) => {
});
transaction();
- db.prepare("UPDATE playlists SET updated_at = strftime('%s','now') WHERE id = ?").run(targetPlaylistId);
- pushPlaylistToDevice(req, req.params.targetDeviceId);
+ autoPublish(targetPlaylistId, req, req.params.targetDeviceId);
res.json({ success: true, copied: sourceItems.length });
});
diff --git a/server/routes/content.js b/server/routes/content.js
index e299516..2d1d2f1 100644
--- a/server/routes/content.js
+++ b/server/routes/content.js
@@ -308,14 +308,43 @@ router.delete('/:id', (req, res) => {
if (fs.existsSync(thumbPath)) fs.unlinkSync(thumbPath);
}
- // Get devices that have this content assigned (to notify them)
- const affectedDevices = db.prepare(
- 'SELECT DISTINCT device_id FROM assignments WHERE content_id = ?'
- ).all(req.params.id);
+ // Get devices that have this content in their playlist (via playlist_items)
+ const affectedDevices = db.prepare(`
+ SELECT DISTINCT d.id as device_id FROM devices d
+ 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);
+ // 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) });
});
diff --git a/server/routes/device-groups.js b/server/routes/device-groups.js
index 683fa23..df07b3d 100644
--- a/server/routes/device-groups.js
+++ b/server/routes/device-groups.js
@@ -93,7 +93,33 @@ function ensureDevicePlaylist(deviceId, userId) {
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) {
try {
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);
db.prepare('INSERT INTO playlist_items (playlist_id, content_id, sort_order, duration_sec) VALUES (?, ?, ?, ?)')
.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();
- for (const m of members) pushPlaylistToDevice(req, m.device_id);
res.json({ success: true, devices_updated: members.length });
});
diff --git a/server/routes/devices.js b/server/routes/devices.js
index c32a3ad..85bb090 100644
--- a/server/routes/devices.js
+++ b/server/routes/devices.js
@@ -64,8 +64,10 @@ router.get('/:id', (req, res) => {
'SELECT * FROM screenshots WHERE device_id = ? ORDER BY captured_at DESC LIMIT 1'
).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 playlist_status = null;
+ let playlist_has_published = false;
if (device.playlist_id) {
assignments = db.prepare(`
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 = ?
ORDER BY pi.sort_order ASC
`).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
@@ -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'
).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
diff --git a/server/routes/playlists.js b/server/routes/playlists.js
index 5fb17d0..5b30037 100644
--- a/server/routes/playlists.js
+++ b/server/routes/playlists.js
@@ -41,7 +41,40 @@ function requirePlaylistOwnership(req, res, 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) => {
const playlists = db.prepare(`
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));
});
+// 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
router.delete('/:id', requirePlaylistOwnership, (req, res) => {
db.prepare('DELETE FROM playlists WHERE id = ?').run(req.params.id);
@@ -175,8 +259,8 @@ router.post('/:id/items', requirePlaylistOwnership, async (req, res) => {
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);
+ // Mark as draft (items changed since last publish)
+ markDraft(req.params.id);
const item = db.prepare(`
SELECT pi.*,
@@ -220,7 +304,7 @@ router.put('/:id/items/:itemId', requirePlaylistOwnership, (req, res) => {
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);
+ markDraft(req.params.id);
}
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' });
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 });
});
@@ -261,7 +345,7 @@ router.post('/:id/items/reorder', requirePlaylistOwnership, (req, res) => {
});
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(`
SELECT pi.*,
diff --git a/server/ws/deviceSocket.js b/server/ws/deviceSocket.js
index d5479fb..b47ebbd 100644
--- a/server/ws/deviceSocket.js
+++ b/server/ws/deviceSocket.js
@@ -44,23 +44,16 @@ function logDeviceStatus(deviceId, status) {
// 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) {
const device = db.prepare('SELECT playlist_id, layout_id, orientation FROM devices WHERE id = ?').get(deviceId);
let assignments = [];
if (device?.playlist_id) {
- assignments = db.prepare(`
- SELECT pi.id, 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(device.playlist_id);
+ const playlist = db.prepare('SELECT published_snapshot FROM playlists WHERE id = ?').get(device.playlist_id);
+ if (playlist?.published_snapshot) {
+ try { assignments = JSON.parse(playlist.published_snapshot); } catch (e) { assignments = []; }
+ }
}
let layout = null;