Default a video playlist item to the clip's own length (#237)

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
This commit is contained in:
ScreenTinker 2026-08-06 09:36:53 -05:00
parent afe3f7f57f
commit 6471c503ab
11 changed files with 299 additions and 30 deletions

View file

@ -673,7 +673,11 @@ paths:
widget_id: { type: string }
zone_id: { type: string }
sort_order: { type: integer }
duration_sec: { type: integer }
duration_sec:
type: integer
description: >
Omit to let the server choose: video content defaults to the clip's own
length (rounded up to a whole second), anything else to 10s.
responses:
'201': { description: Created playlist item. }
/playlists/{id}/items/reorder:
@ -815,7 +819,11 @@ paths:
content_id: { type: string }
widget_id: { type: string }
zone_id: { type: string }
duration_sec: { type: integer }
duration_sec:
type: integer
description: >
Omit to let the server choose: video content defaults to the clip's own
length (rounded up to a whole second), anything else to 10s.
sort_order: { type: integer }
responses:
'201': { description: Created item. }
@ -1242,7 +1250,11 @@ paths:
required: [content_id]
properties:
content_id: { type: string }
duration_sec: { type: integer }
duration_sec:
type: integer
description: >
Omit to let the server choose: video content defaults to the clip's own
length (rounded up to a whole second), anything else to 10s.
responses:
'200': { description: '{ success, devices_updated }.' }
/groups/{id}/assign-playlist:

View file

@ -1561,7 +1561,9 @@ async function setupPlaylistActions(device) {
</div>
<div class="form-group">
<label>${t('device.assign.duration_label')}</label>
<input type="number" id="assignDuration" class="input" value="10" min="1" max="3600">
<!-- max is the server's absurd-duration ceiling (12h): a feature-length clip
pre-filled from its own length must not land in an out-of-range field. -->
<input type="number" id="assignDuration" class="input" value="10" min="1" max="43200">
</div>
<!-- Tabs -->
<div style="display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:12px">
@ -1572,7 +1574,7 @@ async function setupPlaylistActions(device) {
<!-- Media grid -->
<div class="assign-content-grid" id="assignMedia">
${content.map(c => `
<div class="assign-content-item" data-content-id="${c.id}" data-type="content">
<div class="assign-content-item" data-content-id="${c.id}" data-type="content" data-duration="${Number(c.duration_sec) > 0 ? Math.ceil(c.duration_sec) : ''}">
${c.thumbnail_path
? `<img data-auth-src="/api/content/${c.id}/thumbnail" alt="">`
: c.remote_url
@ -1632,12 +1634,21 @@ async function setupPlaylistActions(device) {
let selectedId = null;
let selectedType = null;
// #237: this modal always SENDS a duration, so the server's "default a video to its own
// length" rule can never fire here — the field has to carry the clip length itself, or
// picking a 32s video silently assigns a 10s item that cuts off. Anything the operator
// typed is theirs and is never overwritten.
const durInput = modal.querySelector('#assignDuration');
let durationTouched = false;
durInput?.addEventListener('input', () => { durationTouched = true; });
modal.querySelectorAll('.assign-content-item').forEach(item => {
item.addEventListener('click', () => {
modal.querySelectorAll('.assign-content-item').forEach(i => i.classList.remove('selected'));
item.classList.add('selected');
selectedId = item.dataset.contentId;
selectedType = item.dataset.type;
const clip = parseInt(item.dataset.duration || '', 10);
if (durInput && !durationTouched) durInput.value = clip > 0 ? clip : 10;
});
});

View file

@ -219,7 +219,10 @@ export function render(container) {
await fetch(`/api/assignments/device/${pairedDeviceId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ content_id: content.id, duration_sec: 10 })
// #237: no duration_sec — onboarding never asked the operator for one, and a
// hardcoded 10 cut their very first video off mid-play. Omitting it lets the
// server default to the clip's own length (still 10s for a photo).
body: JSON.stringify({ content_id: content.id })
});
} catch {}
}

View file

@ -735,7 +735,12 @@ async function showAddItemModal(playlistId, opts = {}) {
list.innerHTML = filtered.map(item => {
const isWidget = activeTab === 'widgets';
const name = item.filename || item.name || t('common.unknown');
const sub = isWidget ? (item.widget_type || t('playlist.item_widget')) : (item.mime_type || '');
// #237: the server gives a video item the clip's own length instead of the 10s default.
// Show that length here so the duration the item lands with is something the operator
// saw coming, rather than a number that appears in the list after the fact.
const clipSec = !isWidget && Number(item.duration_sec) > 0 ? Math.ceil(item.duration_sec) : 0;
const clip = clipSec ? ` · ${Math.floor(clipSec / 60)}:${String(clipSec % 60).padStart(2, '0')}` : '';
const sub = isWidget ? (item.widget_type || t('playlist.item_widget')) : ((item.mime_type || '') + clip);
const thumb = item.thumbnail_path ? `/api/content/${esc(item.id)}/thumbnail` : null;
return `
<div class="add-item-row" data-id="${esc(item.id)}" data-type="${isWidget ? 'widget' : 'content'}" style="display:flex;align-items:center;gap:12px;padding:10px;border-radius:var(--radius);cursor:pointer;transition:background 0.1s">

View file

@ -0,0 +1,39 @@
'use strict';
// #237: a video added to a playlist got the flat 10s default, so a 32s clip was cut off at
// 10s unless the operator looked up the runtime and typed it in — per item, every time. The
// content row already carries the probed length, so that becomes the default. Shared by
// every playlist_items insert path (dashboard, device assign, group assign, agency portal,
// schedules, public API) because the operator sees one product, not six routes.
const DEFAULT_ITEM_DURATION = 10;
// A probe that reports longer than this is a broken container (streams and truncated files
// report absurd or near-infinite lengths), not a clip anyone means to schedule — honoring it
// would park a display on one item for days with no obvious cause. 12h.
const MAX_CONTENT_DURATION = 43200;
// The content's own length, or null when there isn't a trustworthy one: images, widgets,
// YouTube and remote-URL rows carry no duration, and a failed ffprobe leaves null/0. Rounded
// UP so a 31.7s clip gets 32 and not a 31 that clips the tail; whole seconds because the
// Android player reads duration_sec with optInt (a fractional value silently truncates) and
// an operator expects to see a round number in the duration box.
function contentDefaultDuration(content) {
const n = Number(content && content.duration_sec);
if (!Number.isFinite(n) || n <= 0 || n > MAX_CONTENT_DURATION) return null;
return Math.max(1, Math.ceil(n));
}
// The duration to STORE on a new playlist_item. An explicit operator value always wins; the
// content's own length is only a default for when none was given.
//
// Anything that isn't a usable number falls back rather than reaching the DB: a duration of
// 0 (or NaN, from a client that sent a string) makes the players schedule a 0ms advance,
// which self-loops and black-screens the TV (#widget zero-duration loop).
function resolveItemDuration(requested, content) {
const n = Number(requested);
if (Number.isFinite(n) && n >= 1) return Math.floor(n);
return contentDefaultDuration(content) ?? DEFAULT_ITEM_DURATION;
}
module.exports = { resolveItemDuration, contentDefaultDuration, DEFAULT_ITEM_DURATION, MAX_CONTENT_DURATION };

View file

@ -17,6 +17,7 @@ const { listDesignatedPlaylists, isZonedPlaylist, folderSubtree } = require('../
const { listLayoutGeometry } = require('../lib/agency-layouts');
const { publishPlaylist } = require('./playlists'); // #73: shared publish path for auto-publish
const { isConfigured } = require('../services/email'); // #73: gate digest enqueue on SMTP being set
const { resolveItemDuration } = require('../lib/item-duration');
const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
@ -112,7 +113,9 @@ router.post('/playlists/:playlistId/items', (req, res) => {
if (duration_sec != null && (typeof duration_sec !== 'number' || duration_sec < 1)) {
return res.status(400).json({ error: 'duration_sec must be a positive integer' });
}
duration_sec = duration_sec || content.duration_sec || 10;
// #237: the raw content duration used to be stored as probed (31.7s), and a fraction is
// truncated by the Android player's optInt read — so round to whole seconds here too.
duration_sec = resolveItemDuration(duration_sec, content);
const sd = start_date ?? null, ed = end_date ?? null;
for (const [k, v] of [['start_date', sd], ['end_date', ed]]) {

View file

@ -8,21 +8,15 @@ const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth');
// 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 (#widget zero-duration loop): a non-positive duration — especially
// duration_sec=0 on a widget — makes the player schedule a 0ms auto-advance, which
// self-loops and black-screens the TV. Never STORE a bad value: floor any missing/
// invalid/<1 duration to the 10s default so it can't reach a device.
function normalizeDuration(v) {
const n = Number(v);
return Number.isFinite(n) && n >= 1 ? Math.floor(n) : 10;
}
// 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
@ -105,17 +99,20 @@ 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;
const duration_sec = normalizeDuration(req.body.duration_sec);
if (!content_id && !widget_id) return res.status(400).json({ error: 'content_id or widget_id required' });
let content = null;
if (content_id) {
const content = db.prepare('SELECT id, workspace_id FROM content WHERE id = ?').get(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' });
@ -234,7 +231,7 @@ router.put('/:id', (req, res) => {
const values = [];
if (sort_order !== undefined) { updates.push('sort_order = ?'); values.push(sort_order); }
if (duration_sec !== undefined) { updates.push('duration_sec = ?'); values.push(normalizeDuration(duration_sec)); }
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) {
@ -337,7 +334,7 @@ router.post('/device/:deviceId/copy-to/:targetDeviceId', (req, res) => {
const transaction = db.transaction(() => {
sourceItems.forEach((a, i) => {
stmt.run(targetPlaylistId, a.content_id, a.widget_id, a.zone_id || null, maxOrder + i + 1, normalizeDuration(a.duration_sec));
stmt.run(targetPlaylistId, a.content_id, a.widget_id, a.zone_id || null, maxOrder + i + 1, resolveItemDuration(a.duration_sec, null));
});
});
transaction();

View file

@ -10,6 +10,7 @@ const { accessContext } = require('../lib/tenancy');
const { requireScope } = require('../middleware/apiToken');
const { resolveSyncBackend, BACKENDS } = require('../lib/sync-backend');
const playerCapabilities = require('../lib/player-capabilities');
const { resolveItemDuration } = require('../lib/item-duration');
const VALID_COLOR = /^#[0-9A-Fa-f]{6}$/;
const ALLOWED_COMMANDS = [
@ -326,11 +327,14 @@ router.post('/:id/assign-content', requireGroupWrite, (req, res) => {
// Verify content lives in the same workspace as the group (or is a
// platform-template row).
const content = db.prepare('SELECT id, workspace_id FROM content WHERE id = ?').get(content_id);
const 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 !== req.group.workspace_id) {
return res.status(403).json({ error: 'Content is not in this group\'s workspace' });
}
// #237: same duration rule as every other add path — a video defaults to its own length,
// and here a wrong default would be wrong on every screen in the group at once.
const itemDuration = resolveItemDuration(duration_sec, content);
const members = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ?').all(req.params.id);
@ -339,7 +343,7 @@ router.post('/:id/assign-content', requireGroupWrite, (req, res) => {
const playlistId = ensureDevicePlaylist(m.device_id, req.user.id);
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);
.run(playlistId, content_id, max.next, itemDuration);
markDraft(playlistId);
}
});

View file

@ -7,6 +7,7 @@ const config = require('../config');
// Phase 2.2k: workspace-aware access. requirePlaylistOwnership is replaced
// by read/write helpers gated on the playlist's workspace_id.
const { accessContext } = require('../lib/tenancy');
const { resolveItemDuration } = require('../lib/item-duration');
// Re-probe video duration with ffprobe if content.duration_sec is missing
async function probeAndUpdateDuration(content) {
@ -450,18 +451,20 @@ router.post('/:id/items', requirePlaylistWrite, async (req, res) => {
return res.status(400).json({ error: 'duration_sec must be a positive integer' });
}
let content = null;
if (content_id) {
const content = db.prepare('SELECT id, workspace_id, duration_sec, mime_type, filepath FROM content WHERE id = ?').get(content_id);
content = db.prepare('SELECT id, workspace_id, duration_sec, mime_type, filepath 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 !== req.playlist.workspace_id) {
return res.status(403).json({ error: 'Content is not in this playlist\'s workspace' });
}
// Rows ingested before the probe existed (or while ffprobe was missing) have no stored
// duration; re-probe once so this add still gets the clip's length, and backfill the row.
if (duration_sec === undefined || duration_sec === null) {
const contentDur = await probeAndUpdateDuration(content);
if (contentDur) duration_sec = Math.ceil(contentDur);
content.duration_sec = await probeAndUpdateDuration(content);
}
}
if (duration_sec === undefined || duration_sec === null) duration_sec = 10;
duration_sec = resolveItemDuration(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' });

View file

@ -9,6 +9,7 @@ const { db } = require('../db/database');
// payload refs with no ownership check at all (only the target was checked).
const { accessContext } = require('../lib/tenancy');
const { effectiveDeviceTz } = require('../lib/device-timezone');
const { resolveItemDuration } = require('../lib/item-duration');
// Helper: build the expanded schedule query for a device (device-level + group-level)
function getDeviceSchedulesQuery() {
@ -271,12 +272,14 @@ router.post('/', (req, res) => {
// playlist. That reuses the whole published/assign/push pipeline as-is.
let effectivePlaylistId = playlist_id || null;
if (!effectivePlaylistId && content_id) {
const c = db.prepare('SELECT filename FROM content WHERE id = ?').get(content_id);
const c = db.prepare('SELECT filename, duration_sec FROM content WHERE id = ?').get(content_id);
const genId = uuidv4();
db.prepare('INSERT INTO playlists (id, name, workspace_id, user_id, status) VALUES (?, ?, ?, ?, ?)')
.run(genId, `Scheduled: ${(c && c.filename) || 'item'}`, targetWorkspaceId, req.user.id, 'published');
db.prepare('INSERT INTO playlist_items (playlist_id, content_id, sort_order, duration_sec) VALUES (?, ?, 0, 10)')
.run(genId, content_id);
// #237: a scheduled video is a one-item playlist with nowhere to edit the duration, so a
// flat 10 here would cut the clip off with no visible knob to fix it.
db.prepare('INSERT INTO playlist_items (playlist_id, content_id, sort_order, duration_sec) VALUES (?, ?, 0, ?)')
.run(genId, content_id, resolveItemDuration(null, c));
// Publish through the shared path rather than hand-rolling the snapshot: players read
// denormalized fields (filename, mime_type, filepath, remote_url, schedules...) out of
// published_snapshot, and duplicating that shape here would rot the moment it changes.

View file

@ -0,0 +1,189 @@
'use strict';
// #237: adding a video to a playlist gave the item the flat 10s default, so a 32s clip was
// cut off at 10s unless the operator looked up the runtime and typed it in. These tests pin
// the rule that replaces it — and the ways it must NOT misfire:
// - a video defaults to its own probed length, rounded UP to whole seconds;
// - content with no trustworthy duration (image, widget, YouTube/remote, failed probe)
// keeps the 10s default, and a failed re-probe must not break the add;
// - an explicit duration from the operator always wins;
// - degenerate stored values (0, negative, NaN, absurd) never reach a device — a 0 makes
// the players schedule a 0ms advance, which self-loops and black-screens the TV.
// Unit-tests the shared helper (every insert path uses it), then proves it end to end on the
// two routes an operator actually clicks: playlist add and assign-to-display.
const { test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const path = require('node:path');
const os = require('node:os');
const fs = require('node:fs');
const crypto = require('node:crypto');
const Database = require('better-sqlite3');
const { resolveItemDuration, contentDefaultDuration, DEFAULT_ITEM_DURATION, MAX_CONTENT_DURATION } = require('../lib/item-duration');
const { freePort } = require('./helpers/free-port');
let PORT, BASE;
const DATA_DIR = path.join(os.tmpdir(), 'st-itemdur-test-' + crypto.randomBytes(4).toString('hex'));
const LOG = path.join(os.tmpdir(), 'st-itemdur-' + crypto.randomBytes(4).toString('hex') + '.log');
const PW = 'Passw0rd123';
let proc, db;
const S = {};
async function jfetch(p, opts = {}) {
const res = await fetch(BASE + p, opts);
let body = null; try { body = await res.json(); } catch { /* non-JSON */ }
return { status: res.status, body };
}
const auth = (tok) => ({ headers: { Authorization: 'Bearer ' + tok, 'Content-Type': 'application/json' } });
const post = (tok, obj) => ({ method: 'POST', ...auth(tok), body: JSON.stringify(obj || {}) });
// ---------------------------------------------------------------- unit: the shared helper
test('a video defaults to its own length, rounded UP (31.7s -> 32, never a clipped 31)', () => {
assert.equal(resolveItemDuration(undefined, { duration_sec: 31.7 }), 32);
assert.equal(resolveItemDuration(null, { duration_sec: 32 }), 32);
assert.equal(contentDefaultDuration({ duration_sec: 31.7 }), 32);
});
test('content with no known duration keeps the 10s default', () => {
for (const c of [null, undefined, {}, { duration_sec: null }, { duration_sec: undefined }]) {
assert.equal(resolveItemDuration(undefined, c), DEFAULT_ITEM_DURATION);
assert.equal(contentDefaultDuration(c), null);
}
});
test('an explicit duration always wins over the content length', () => {
assert.equal(resolveItemDuration(5, { duration_sec: 31.7 }), 5);
assert.equal(resolveItemDuration(600, { duration_sec: 31.7 }), 600);
// whole seconds: the Android player reads duration_sec with optInt, which truncates
assert.equal(resolveItemDuration(12.9, null), 12);
});
test('degenerate stored durations never produce a zero-length item', () => {
for (const bad of [0, -5, NaN, Infinity, 'abc', {}]) {
assert.equal(resolveItemDuration(undefined, { duration_sec: bad }), DEFAULT_ITEM_DURATION, `content duration ${String(bad)}`);
assert.equal(resolveItemDuration(bad, null), DEFAULT_ITEM_DURATION, `requested ${String(bad)}`);
}
// sub-second clip: honest about its length, but still at least a whole second
assert.equal(resolveItemDuration(undefined, { duration_sec: 0.4 }), 1);
});
test('an absurd probe result is treated as a broken probe, not a schedule', () => {
assert.equal(resolveItemDuration(undefined, { duration_sec: 1e9 }), DEFAULT_ITEM_DURATION);
assert.equal(resolveItemDuration(undefined, { duration_sec: MAX_CONTENT_DURATION }), MAX_CONTENT_DURATION);
assert.equal(resolveItemDuration(undefined, { duration_sec: MAX_CONTENT_DURATION + 1 }), DEFAULT_ITEM_DURATION);
// an operator who deliberately types a very long dwell is still obeyed
assert.equal(resolveItemDuration(1e6, { duration_sec: 30 }), 1e6);
});
// -------------------------------------------------------------- end to end on the routes
before(async () => {
PORT = await freePort();
BASE = `http://127.0.0.1:${PORT}`;
const logFd = fs.openSync(LOG, 'w');
proc = spawn('node', ['server.js'], {
cwd: path.join(__dirname, '..'),
env: { ...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test' },
stdio: ['ignore', logFd, logFd],
});
let up = false;
for (let i = 0; i < 80; i++) {
try { const r = await fetch(BASE + '/api/status'); if (r.ok) { up = true; break; } } catch { /* not yet */ }
await new Promise((r) => setTimeout(r, 250));
}
if (!up) throw new Error('server did not boot:\n' + fs.readFileSync(LOG, 'utf8').slice(-2000));
const reg = await jfetch('/api/auth/register', post(null, { email: 'd' + crypto.randomBytes(4).toString('hex') + '@x.local', password: PW }));
S.jwt = reg.body.token;
S.userId = reg.body.user.id;
S.wsA = reg.body.current_workspace_id;
const pl = await jfetch('/api/playlists', post(S.jwt, { name: 'dur-pl' }));
S.playlistId = pl.body.id;
// Seed the content library on one connection (FK off, as in mute.test.js) so each row can
// carry exactly the duration_sec shape under test without needing a real media file.
db = new Database(path.join(DATA_DIR, 'db', 'remote_display.db'), { timeout: 5000 });
db.pragma('foreign_keys = OFF');
const mkContent = (name, mime, duration, filepath = '') => {
const id = crypto.randomUUID();
db.prepare('INSERT INTO content (id, filename, filepath, mime_type, file_size, duration_sec, workspace_id) VALUES (?,?,?,?,0,?,?)')
.run(id, name, filepath, mime, duration, S.wsA);
return id;
};
S.cVideo = mkContent('clip.mp4', 'video/mp4', 31.7);
S.cVideoExplicit = mkContent('clip2.mp4', 'video/mp4', 31.7);
S.cImage = mkContent('poster.png', 'image/png', null);
S.cZero = mkContent('zero.mp4', 'video/mp4', 0);
S.cAbsurd = mkContent('absurd.mp4', 'video/mp4', 1e9);
// No stored duration AND a filepath that doesn't exist -> the re-probe runs and fails.
S.cUnprobeable = mkContent('missing.mp4', 'video/mp4', null, 'does-not-exist-' + crypto.randomBytes(4).toString('hex') + '.mp4');
S.cAssign = mkContent('assign.mp4', 'video/mp4', 44.2);
S.deviceId = crypto.randomUUID();
db.prepare('INSERT INTO devices (id, name, status, workspace_id, user_id) VALUES (?,?,?,?,?)')
.run(S.deviceId, 'DurDev', 'online', S.wsA, S.userId);
});
after(() => {
try { db?.close(); } catch { /* */ }
try { proc?.kill('SIGKILL'); } catch { /* */ }
for (const f of [DATA_DIR, LOG]) { try { fs.rmSync(f, { recursive: true, force: true }); } catch { /* */ } }
});
test('POST /playlists/:id/items gives a video the clip length', async () => {
const r = await jfetch(`/api/playlists/${S.playlistId}/items`, post(S.jwt, { content_id: S.cVideo }));
assert.equal(r.status, 201);
assert.equal(r.body.duration_sec, 32, '31.7s clip -> a 32s item, not the 10s default');
});
test('POST /playlists/:id/items honors an explicit duration on the same video', async () => {
const r = await jfetch(`/api/playlists/${S.playlistId}/items`, post(S.jwt, { content_id: S.cVideoExplicit, duration_sec: 5 }));
assert.equal(r.status, 201);
assert.equal(r.body.duration_sec, 5, 'the operator asked for 5s and gets 5s');
});
test('content with no duration (image) still gets the 10s default', async () => {
const r = await jfetch(`/api/playlists/${S.playlistId}/items`, post(S.jwt, { content_id: S.cImage }));
assert.equal(r.status, 201);
assert.equal(r.body.duration_sec, DEFAULT_ITEM_DURATION);
});
test('a stored 0 or absurd duration falls back to the default instead of reaching a device', async () => {
const zero = await jfetch(`/api/playlists/${S.playlistId}/items`, post(S.jwt, { content_id: S.cZero }));
assert.equal(zero.status, 201);
assert.equal(zero.body.duration_sec, DEFAULT_ITEM_DURATION, 'a 0 would be a 0ms self-advancing loop');
const absurd = await jfetch(`/api/playlists/${S.playlistId}/items`, post(S.jwt, { content_id: S.cAbsurd }));
assert.equal(absurd.status, 201);
assert.equal(absurd.body.duration_sec, DEFAULT_ITEM_DURATION, 'a nonsense probe must not park the screen for years');
});
test('a failed re-probe degrades to the default — the add still succeeds', async () => {
const r = await jfetch(`/api/playlists/${S.playlistId}/items`, post(S.jwt, { content_id: S.cUnprobeable }));
assert.equal(r.status, 201, 'ffprobe failing (or missing) must not break adding an item');
assert.equal(r.body.duration_sec, DEFAULT_ITEM_DURATION);
});
test('assigning a video straight to a display uses the clip length too, and it reaches the snapshot', async () => {
const r = await jfetch(`/api/assignments/device/${S.deviceId}`, post(S.jwt, { content_id: S.cAssign }));
assert.equal(r.status, 201);
assert.equal(r.body.duration_sec, 45, '44.2s clip -> a 45s item');
const explicit = await jfetch(`/api/assignments/device/${S.deviceId}`, post(S.jwt, { content_id: S.cVideo, duration_sec: 7 }));
assert.equal(explicit.status, 201);
assert.equal(explicit.body.duration_sec, 7, 'an explicit duration still wins on the assign path');
// The device only ever plays the published snapshot, so the defaulted value has to survive
// the publish — a correct playlist_items row that never reaches the player is no fix.
const playlistId = db.prepare('SELECT playlist_id FROM devices WHERE id = ?').get(S.deviceId).playlist_id;
const pub = await jfetch(`/api/playlists/${playlistId}/publish`, post(S.jwt, {}));
assert.equal(pub.status, 200);
const snap = JSON.parse(db.prepare('SELECT published_snapshot FROM playlists WHERE id = ?').get(playlistId).published_snapshot);
const item = snap.find((i) => i.content_id === S.cAssign);
assert.ok(item, 'the assigned clip is in the published snapshot');
assert.equal(item.duration_sec, 45, 'the device payload carries the clip length');
});