mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 14:23:14 -06:00
Merge #237: default a playlist item's duration to the video's own length
This commit is contained in:
commit
63b9de1329
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
39
server/lib/item-duration.js
Normal file
39
server/lib/item-duration.js
Normal 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 };
|
||||
|
|
@ -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]]) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
189
server/test/item-duration-default.test.js
Normal file
189
server/test/item-duration-default.test.js
Normal 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');
|
||||
});
|
||||
Loading…
Reference in a new issue