mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
Adding a 32s video gave it the flat 10s default, so it was cut off mid-play unless the operator looked up the runtime and typed it — per item, every time. The content row already carries the probed duration; it now becomes the default. The rule lives in one place (lib/item-duration.js) because the operator sees one product, not six insert paths: playlist add, assign-to-display, group assign, agency portal, content-only schedule, and the public API all share it. Only the playlist route defaulted before, and it stored the raw probe (31.7) which the Android player's optInt read silently truncated back to 31. Explicit values always win. Content with no trustworthy duration (image, widget, YouTube, remote URL, failed probe) keeps the 10s default, and a duration that is 0/negative/NaN or absurd (> 12h, i.e. a broken probe) falls back rather than reaching a device — a 0 makes the players schedule a 0ms advance, which self-loops and black-screens the TV. Dashboard: the add-item picker shows a clip's length, the assign-to-display modal pre-fills the duration field from the selected clip (never overwriting a value the operator typed), and onboarding stops hardcoding 10 on the first assignment. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
347 lines
18 KiB
JavaScript
347 lines
18 KiB
JavaScript
const express = require('express');
|
|
const router = express.Router();
|
|
const { v4: uuidv4 } = require('uuid');
|
|
const { db } = require('../db/database');
|
|
const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth');
|
|
// Phase 2.2j: workspace-aware access. Underlying tables (devices, playlists)
|
|
// already carry workspace_id from Phase 1; this route can use them even
|
|
// though playlists.js itself isn't yet workspace-filtered.
|
|
const { accessContext } = require('../lib/tenancy');
|
|
const { zoneInLayout } = require('../lib/zone-validate');
|
|
// #237 + #widget zero-duration loop: one place decides what duration a new item gets —
|
|
// explicit value, else the content's own length, else the 10s default (and never a 0).
|
|
const { resolveItemDuration } = require('../lib/item-duration');
|
|
|
|
// Mark playlist as draft (called after any item mutation)
|
|
function markDraft(playlistId) {
|
|
db.prepare("UPDATE playlists SET status = 'draft', updated_at = strftime('%s','now') WHERE id = ?").run(playlistId);
|
|
}
|
|
|
|
// Hardening (#zone-orphan): a zone_id only renders if it belongs to the layout the
|
|
// device is actually showing. Assigning a zone from a DIFFERENT layout (e.g. after a
|
|
// layout switch/duplicate) creates an item that the players can't place. We CLEAR a
|
|
// stale zone_id to null here (-> "unassigned", which the players route sensibly) rather
|
|
// than reject, so this can't break a caller; the cleared write is logged. NOTE for
|
|
// review: switch to a 400 reject if you'd rather surface the bad zone to the operator.
|
|
// Returns the zone_id to persist (the given one, or null if it isn't in the device's
|
|
// active layout). deviceLayoutId may be null (device on fullscreen) -> any zone_id is
|
|
// stale, so cleared.
|
|
function validZoneForLayout(zoneId, deviceLayoutId, ctx) {
|
|
if (!zoneId) return null;
|
|
if (zoneInLayout(zoneId, deviceLayoutId)) return zoneId;
|
|
console.warn(`[assign] cleared stale zone_id ${zoneId} (not in active layout ${deviceLayoutId || 'none'})${ctx ? ' ' + ctx : ''}`);
|
|
return null;
|
|
}
|
|
|
|
// Phase 2.2j: workspace-aware device access check. Returns access context
|
|
// (with workspaceRole/actingAs) or null. Caller decides if read or write.
|
|
function checkDeviceAccess(req, res, paramName = 'deviceId', requireWrite = true) {
|
|
const device = db.prepare('SELECT workspace_id FROM devices WHERE id = ?').get(req.params[paramName]);
|
|
if (!device) { res.status(404).json({ error: 'Device not found' }); return null; }
|
|
if (!device.workspace_id) { res.status(403).json({ error: 'Device not assigned to a workspace' }); return null; }
|
|
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(device.workspace_id);
|
|
const ctx = ws && accessContext(req.user.id, req.user.role, ws);
|
|
if (!ctx) { res.status(403).json({ error: 'Access denied' }); return null; }
|
|
if (requireWrite && !ctx.actingAs && ctx.workspaceRole === 'workspace_viewer') {
|
|
res.status(403).json({ error: 'Read-only access' }); return null;
|
|
}
|
|
return { device, ctx };
|
|
}
|
|
|
|
// Ensure device has a playlist; auto-create one if missing.
|
|
// Phase 2.2j: stamps workspace_id on the auto-created playlist so it remains
|
|
// visible once playlists.js migrates. Mirrors the 2.2i fix in device-groups.js.
|
|
function ensureDevicePlaylist(deviceId, userId) {
|
|
const device = db.prepare('SELECT playlist_id, workspace_id, name FROM devices WHERE id = ?').get(deviceId);
|
|
if (device?.playlist_id) return device.playlist_id;
|
|
|
|
const playlistId = uuidv4();
|
|
db.prepare('INSERT INTO playlists (id, user_id, workspace_id, name, is_auto_generated) VALUES (?, ?, ?, ?, 1)')
|
|
.run(playlistId, userId, device?.workspace_id || null, `${device?.name || 'Display'} playlist`);
|
|
db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?').run(playlistId, deviceId);
|
|
return playlistId;
|
|
}
|
|
|
|
// Standard item query with joined content/widget info
|
|
const ITEM_SELECT = `
|
|
SELECT pi.id, pi.playlist_id, pi.content_id, pi.widget_id, pi.zone_id, pi.sort_order, pi.duration_sec, pi.muted,
|
|
pi.created_at, pi.updated_at,
|
|
COALESCE(c.filename, w.name) as filename,
|
|
c.mime_type, c.filepath, c.thumbnail_path,
|
|
c.duration_sec as content_duration, c.file_size, c.remote_url,
|
|
w.name as widget_name, w.widget_type, w.config as widget_config
|
|
FROM playlist_items pi
|
|
LEFT JOIN content c ON pi.content_id = c.id
|
|
LEFT JOIN widgets w ON pi.widget_id = w.id
|
|
`;
|
|
|
|
// Get assignments (playlist items) for a device
|
|
router.get('/device/:deviceId', (req, res) => {
|
|
if (!checkDeviceAccess(req, res, 'deviceId', false)) return;
|
|
const device = db.prepare('SELECT playlist_id FROM devices WHERE id = ?').get(req.params.deviceId);
|
|
if (!device?.playlist_id) return res.json([]);
|
|
|
|
const items = db.prepare(`${ITEM_SELECT} WHERE pi.playlist_id = ? ORDER BY pi.sort_order ASC`)
|
|
.all(device.playlist_id);
|
|
res.json(items);
|
|
});
|
|
|
|
// Add content or widget to device playlist.
|
|
// Phase 2.2j: closes 2 pre-existing cross-tenant leaks:
|
|
// 1. Content gate: today checks content.user_id == caller. A workspace_admin
|
|
// who happens to own content in another workspace could push it into a
|
|
// device in this workspace. Now: content must be in device's workspace
|
|
// (or be a platform-template, workspace_id IS NULL).
|
|
// 2. Widget gate: today checks ONLY existence - any user could attach any
|
|
// widget UUID to their own device's playlist. Now: widget must be in
|
|
// device's workspace (or be a platform-template).
|
|
router.post('/device/:deviceId', (req, res) => {
|
|
const access = checkDeviceAccess(req, res, 'deviceId', true);
|
|
if (!access) return;
|
|
const { content_id, widget_id, zone_id, sort_order } = req.body;
|
|
|
|
if (!content_id && !widget_id) return res.status(400).json({ error: 'content_id or widget_id required' });
|
|
|
|
let content = null;
|
|
if (content_id) {
|
|
content = db.prepare('SELECT id, workspace_id, duration_sec FROM content WHERE id = ?').get(content_id);
|
|
if (!content) return res.status(404).json({ error: 'Content not found' });
|
|
if (content.workspace_id && content.workspace_id !== access.device.workspace_id) {
|
|
return res.status(403).json({ error: 'Content is not in this device\'s workspace' });
|
|
}
|
|
}
|
|
// #237: pushing a video straight at a display is the shortest path in the product, so it
|
|
// has to default to the clip's length too — not the 10s that cut it off mid-play.
|
|
const duration_sec = resolveItemDuration(req.body.duration_sec, content);
|
|
if (widget_id) {
|
|
const widget = db.prepare('SELECT id, workspace_id FROM widgets WHERE id = ?').get(widget_id);
|
|
if (!widget) return res.status(404).json({ error: 'Widget not found' });
|
|
if (widget.workspace_id && widget.workspace_id !== access.device.workspace_id) {
|
|
return res.status(403).json({ error: 'Widget is not in this device\'s workspace' });
|
|
}
|
|
}
|
|
|
|
const playlistId = ensureDevicePlaylist(req.params.deviceId, req.user.id);
|
|
|
|
// Hardening: clear a zone_id that isn't in THIS device's active layout (prevents new orphans).
|
|
const devLayout = db.prepare('SELECT layout_id FROM devices WHERE id = ?').get(req.params.deviceId);
|
|
const effZone = validZoneForLayout(zone_id, devLayout?.layout_id, `on add to device ${req.params.deviceId}`);
|
|
|
|
let order = sort_order;
|
|
if (order === undefined || order === null) {
|
|
const max = db.prepare('SELECT MAX(sort_order) as max_order FROM playlist_items WHERE playlist_id = ?')
|
|
.get(playlistId);
|
|
order = (max.max_order || 0) + 1;
|
|
}
|
|
|
|
try {
|
|
const result = db.prepare(`
|
|
INSERT INTO playlist_items (playlist_id, content_id, widget_id, zone_id, sort_order, duration_sec)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
`).run(playlistId, content_id || null, widget_id || null, effZone, order, duration_sec);
|
|
|
|
markDraft(playlistId);
|
|
|
|
const item = db.prepare(`${ITEM_SELECT} WHERE pi.id = ?`).get(result.lastInsertRowid);
|
|
res.status(201).json(item);
|
|
} catch (err) {
|
|
if (err.message.includes('UNIQUE')) {
|
|
return res.status(409).json({ error: 'Content already in playlist' });
|
|
}
|
|
throw err;
|
|
}
|
|
});
|
|
|
|
// Helper: load a playlist item and check write access via the parent
|
|
// playlist's workspace. Returns the item row or null after sending 403/404.
|
|
function checkItemWrite(req, res) {
|
|
const item = db.prepare('SELECT pi.*, p.workspace_id AS pl_workspace_id FROM playlist_items pi JOIN playlists p ON pi.playlist_id = p.id WHERE pi.id = ?').get(req.params.id);
|
|
if (!item) { res.status(404).json({ error: 'Item not found' }); return null; }
|
|
if (!item.pl_workspace_id) { res.status(403).json({ error: 'Playlist not assigned to a workspace' }); return null; }
|
|
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(item.pl_workspace_id);
|
|
const ctx = ws && accessContext(req.user.id, req.user.role, ws);
|
|
if (!ctx) { res.status(403).json({ error: 'Access denied' }); return null; }
|
|
if (!ctx.actingAs && ctx.workspaceRole === 'workspace_viewer') {
|
|
res.status(403).json({ error: 'Read-only access' }); return null;
|
|
}
|
|
return item;
|
|
}
|
|
|
|
// #129 + mute-fix: per-item mute has to do TWO things, because the device plays from
|
|
// playlists.published_snapshot (deviceSocket.buildPlaylistPayload), NOT the draft
|
|
// playlist_items the toggle writes:
|
|
// (1) LIVE — tell every device on this playlist to silence the matching currently-playing
|
|
// item NOW (device matches by content_id/widget_id). Mutes the in-progress playthrough.
|
|
// (2) PERSIST — patch the matching item's `muted` inside the published_snapshot the device
|
|
// actually plays, then re-push the playlist. Without this the snapshot kept muted=0, so
|
|
// every loop/reload re-applied full volume — the "icon red but audio plays across 3
|
|
// playthroughs" bug (Android re-loads each loop; web's native <video> loop masked it).
|
|
// We patch the snapshot SURGICALLY (just the muted field of matching items) rather than calling
|
|
// publishPlaylist, so a mute toggle can't prematurely publish other pending draft edits or flip
|
|
// the playlist's draft/published status. muted is written as 0/1 to match buildSnapshotItems'
|
|
// format (the player reads it via optInt). playlist_items.muted is still updated by the caller,
|
|
// so a later full publish stays consistent.
|
|
function emitMuteChanged(req, item, muted) {
|
|
try {
|
|
const io = req.app.get('io');
|
|
if (!io) return;
|
|
const deviceNs = io.of('/device');
|
|
const m = !!muted;
|
|
|
|
// (2) PERSIST: patch the published snapshot the device reads from.
|
|
const pl = db.prepare('SELECT published_snapshot FROM playlists WHERE id = ?').get(item.playlist_id);
|
|
if (pl && pl.published_snapshot) {
|
|
let snap = null;
|
|
try { snap = JSON.parse(pl.published_snapshot); } catch (e) { snap = null; }
|
|
if (Array.isArray(snap)) {
|
|
let changed = false;
|
|
for (const s of snap) {
|
|
const match = item.content_id ? s.content_id === item.content_id
|
|
: (item.widget_id ? s.widget_id === item.widget_id : false);
|
|
if (match && (s.muted ? 1 : 0) !== (m ? 1 : 0)) { s.muted = m ? 1 : 0; changed = true; }
|
|
}
|
|
if (changed) {
|
|
db.prepare('UPDATE playlists SET published_snapshot = ? WHERE id = ?')
|
|
.run(JSON.stringify(snap), item.playlist_id);
|
|
}
|
|
}
|
|
}
|
|
|
|
// (1) LIVE toggle + re-deliver the patched snapshot so loops re-apply the correct flag.
|
|
// Lazy require (matches playlists.pushToDevices) to avoid a route<->ws circular import.
|
|
const { buildPlaylistPayload } = require('../ws/deviceSocket');
|
|
const commandQueue = require('../lib/command-queue');
|
|
const devices = db.prepare('SELECT id FROM devices WHERE playlist_id = ?').all(item.playlist_id);
|
|
const payload = { content_id: item.content_id || null, widget_id: item.widget_id || null, muted: m };
|
|
for (const d of devices) {
|
|
deviceNs.to(d.id).emit('device:mute-changed', payload); // current playthrough
|
|
commandQueue.queueOrEmitPlaylistUpdate(deviceNs, d.id, buildPlaylistPayload); // future loads (no reload of current item)
|
|
}
|
|
console.log(`[mute] item ${item.id} (content ${item.content_id || item.widget_id}) -> ${m ? 'MUTED' : 'unmuted'}; snapshot patched + notified ${devices.length} device(s)`);
|
|
} catch (e) { /* best-effort; playlist_items.muted is still updated for the next full publish */ }
|
|
}
|
|
|
|
// Update playlist item
|
|
router.put('/:id', (req, res) => {
|
|
const item = checkItemWrite(req, res);
|
|
if (!item) return;
|
|
|
|
const { sort_order, duration_sec, zone_id, muted } = req.body;
|
|
const updates = [];
|
|
const values = [];
|
|
|
|
if (sort_order !== undefined) { updates.push('sort_order = ?'); values.push(sort_order); }
|
|
if (duration_sec !== undefined) { updates.push('duration_sec = ?'); values.push(resolveItemDuration(duration_sec, null)); }
|
|
// zone_id can be null (clear the zone) - treat undefined as "no change",
|
|
// any other value (including null) as "write this".
|
|
if (zone_id !== undefined) {
|
|
// Hardening: if this playlist is bound to exactly ONE device with a layout, clear a
|
|
// zone_id that isn't in that layout (prevents new orphans). Multi-device / fullscreen
|
|
// playlists can't be bound to one layout here, so we leave those to the player fallback.
|
|
let effZone = zone_id || null;
|
|
if (effZone) {
|
|
const devs = db.prepare('SELECT layout_id FROM devices WHERE playlist_id = ? AND layout_id IS NOT NULL').all(item.playlist_id);
|
|
if (devs.length === 1) effZone = validZoneForLayout(effZone, devs[0].layout_id, `on update of item ${req.params.id}`);
|
|
}
|
|
updates.push('zone_id = ?'); values.push(effZone);
|
|
}
|
|
// #129: per-item mute (coerced to 0/1). Was silently dropped here before, so the
|
|
// dashboard toggle did nothing.
|
|
const mutedChanged = muted !== undefined && (item.muted ? 1 : 0) !== (muted ? 1 : 0);
|
|
if (muted !== undefined) { updates.push('muted = ?'); values.push(muted ? 1 : 0); }
|
|
|
|
if (updates.length > 0) {
|
|
updates.push("updated_at = strftime('%s','now')");
|
|
values.push(req.params.id);
|
|
db.prepare(`UPDATE playlist_items SET ${updates.join(', ')} WHERE id = ?`).run(...values);
|
|
markDraft(item.playlist_id);
|
|
if (mutedChanged) emitMuteChanged(req, item, muted ? 1 : 0);
|
|
}
|
|
|
|
const updated = db.prepare(`${ITEM_SELECT} WHERE pi.id = ?`).get(req.params.id);
|
|
res.json(updated);
|
|
});
|
|
|
|
// Delete playlist item
|
|
router.delete('/:id', (req, res) => {
|
|
const item = checkItemWrite(req, res);
|
|
if (!item) return;
|
|
|
|
db.prepare('DELETE FROM playlist_items WHERE id = ?').run(req.params.id);
|
|
markDraft(item.playlist_id);
|
|
|
|
res.json({ success: true, content_id: item.content_id });
|
|
});
|
|
|
|
// Reorder items for a device's playlist
|
|
router.post('/device/:deviceId/reorder', (req, res) => {
|
|
if (!checkDeviceAccess(req, res, 'deviceId', true)) return;
|
|
const { order } = req.body;
|
|
if (!Array.isArray(order)) return res.status(400).json({ error: 'order must be an array of item IDs' });
|
|
|
|
const device = db.prepare('SELECT playlist_id FROM devices WHERE id = ?').get(req.params.deviceId);
|
|
if (!device?.playlist_id) return res.json([]);
|
|
|
|
const updateStmt = db.prepare('UPDATE playlist_items SET sort_order = ? WHERE id = ? AND playlist_id = ?');
|
|
const transaction = db.transaction(() => {
|
|
order.forEach((itemId, index) => {
|
|
updateStmt.run(index, itemId, device.playlist_id);
|
|
});
|
|
});
|
|
transaction();
|
|
|
|
markDraft(device.playlist_id);
|
|
|
|
const items = db.prepare(`${ITEM_SELECT} WHERE pi.playlist_id = ? ORDER BY pi.sort_order ASC`)
|
|
.all(device.playlist_id);
|
|
res.json(items);
|
|
});
|
|
|
|
// Copy playlist from one device to another.
|
|
// Phase 2.2j: closes a pre-existing cross-tenant leak. Today both deviceIds
|
|
// only got the user_id ownership check; a caller with reach into a foreign
|
|
// workspace could copy that workspace's playlist into a device in their own
|
|
// workspace (or vice versa). Now: both devices must be in the same workspace,
|
|
// and the caller must have write access there.
|
|
router.post('/device/:deviceId/copy-to/:targetDeviceId', (req, res) => {
|
|
const sourceAccess = checkDeviceAccess(req, res, 'deviceId', true);
|
|
if (!sourceAccess) return;
|
|
const targetAccess = checkDeviceAccess(req, res, 'targetDeviceId', true);
|
|
if (!targetAccess) return;
|
|
if (sourceAccess.device.workspace_id !== targetAccess.device.workspace_id) {
|
|
return res.status(403).json({ error: 'Source and target devices must be in the same workspace' });
|
|
}
|
|
|
|
const sourceDevice = db.prepare('SELECT playlist_id FROM devices WHERE id = ?').get(req.params.deviceId);
|
|
if (!sourceDevice?.playlist_id) return res.status(404).json({ error: 'Source device has no playlist' });
|
|
|
|
const sourceItems = db.prepare('SELECT * FROM playlist_items WHERE playlist_id = ? ORDER BY sort_order')
|
|
.all(sourceDevice.playlist_id);
|
|
if (!sourceItems.length) return res.status(404).json({ error: 'Source playlist is empty' });
|
|
|
|
const target = db.prepare('SELECT id, user_id FROM devices WHERE id = ?').get(req.params.targetDeviceId);
|
|
if (!target) return res.status(404).json({ error: 'Target device not found' });
|
|
|
|
const targetPlaylistId = ensureDevicePlaylist(req.params.targetDeviceId, target.user_id || req.user.id);
|
|
|
|
if (req.body.replace) {
|
|
db.prepare('DELETE FROM playlist_items WHERE playlist_id = ?').run(targetPlaylistId);
|
|
}
|
|
|
|
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM playlist_items WHERE playlist_id = ?')
|
|
.get(targetPlaylistId).m || 0;
|
|
const stmt = db.prepare('INSERT INTO playlist_items (playlist_id, content_id, widget_id, zone_id, sort_order, duration_sec) VALUES (?, ?, ?, ?, ?, ?)');
|
|
|
|
const transaction = db.transaction(() => {
|
|
sourceItems.forEach((a, i) => {
|
|
stmt.run(targetPlaylistId, a.content_id, a.widget_id, a.zone_id || null, maxOrder + i + 1, resolveItemDuration(a.duration_sec, null));
|
|
});
|
|
});
|
|
transaction();
|
|
|
|
markDraft(targetPlaylistId);
|
|
res.json({ success: true, copied: sourceItems.length });
|
|
});
|
|
|
|
module.exports = router;
|