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' }),
|
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 }) }),
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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">←</a>
|
<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 || []);
|
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';
|
||||||
|
|
|
||||||
|
|
@ -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(`
|
||||||
|
|
|
||||||
|
|
@ -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'))
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.*,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue