mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
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:
parent
1cf6b93512
commit
436a3be7f6
|
|
@ -115,6 +115,8 @@ export const api = {
|
|||
deletePlaylistItem: (id, itemId) => request(`/playlists/${id}/items/${itemId}`, { method: 'DELETE' }),
|
||||
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 }) }),
|
||||
publishPlaylist: (id) => request(`/playlists/${id}/publish`, { method: 'POST' }),
|
||||
discardPlaylistDraft: (id) => request(`/playlists/${id}/discard`, { method: 'POST' }),
|
||||
|
||||
// Device Groups - Playlist
|
||||
groupAssignPlaylist: (groupId, playlist_id) => request(`/groups/${groupId}/assign-playlist`, { method: 'POST', body: JSON.stringify({ playlist_id }) }),
|
||||
|
|
|
|||
|
|
@ -159,6 +159,21 @@ async function loadDevice(deviceId, activeTab = null) {
|
|||
|
||||
<!-- Playlist Tab -->
|
||||
<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 -->
|
||||
<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">
|
||||
|
|
@ -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
|
||||
const playlistPicker = document.getElementById('playlistPicker');
|
||||
if (playlistPicker) {
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ async function loadPlaylists() {
|
|||
<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>
|
||||
${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 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>
|
||||
|
|
@ -175,7 +176,26 @@ async function renderDetail(container, playlistId) {
|
|||
}
|
||||
|
||||
function renderDetailContent(container, playlist) {
|
||||
const isDraft = playlist.status === 'draft';
|
||||
const hasPublished = !!playlist.published_snapshot;
|
||||
|
||||
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 style="display:flex;align-items:center;gap:12px">
|
||||
<a href="#/playlists" style="color:var(--text-muted);text-decoration:none;font-size:20px" title="Back">←</a>
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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(`
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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) });
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.*,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue