fix(server): floor duration_sec to prevent widget zero-duration player loop (#199)

A duration_sec=0 assignment (especially a widget) made the player schedule a 0ms
auto-advance, self-looping and black-screening the TV. #198 fixed the Android
client; this hardens the source so a 0 can't be stored or served in the first
place. assignments.js accepted an explicit 0 on the POST/PUT/copy write paths —
the `= 10` destructure default only covers an ABSENT field, not an explicit 0.

- Add normalizeDuration() and apply it on all assignment write paths so any
  missing/invalid/<1 duration is floored to the 10s default.
- Add an idempotent migration repairing existing playlist_items rows with
  duration_sec IS NULL OR < 1 (fixes the live widget on existing DBs).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
screentinker 2026-07-17 13:41:52 -05:00 committed by GitHub
parent af286826dc
commit 5c1cb4b992
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 18 additions and 3 deletions

View file

@ -358,6 +358,11 @@ const migrations = [
last_seen INTEGER,
removed_at INTEGER
)`,
// #widget zero-duration loop: repair any playlist_items with a non-positive duration
// (esp. duration_sec=0 on a widget), which made the player schedule a 0ms auto-advance
// -> self-loop + black screen. New writes are floored in routes/assignments.js; this
// fixes existing rows. Idempotent — a no-op once clean.
'UPDATE playlist_items SET duration_sec = 10 WHERE duration_sec IS NULL OR duration_sec < 1',
];
// Apply each ALTER idempotently. A "duplicate column name" / "already exists"
// error means the column is already present (expected on a migrated DB) - benign.

View file

@ -14,6 +14,15 @@ 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
@ -95,7 +104,8 @@ router.get('/device/:deviceId', (req, res) => {
router.post('/device/:deviceId', (req, res) => {
const access = checkDeviceAccess(req, res, 'deviceId', true);
if (!access) return;
const { content_id, widget_id, zone_id, duration_sec = 10, sort_order } = req.body;
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' });
@ -224,7 +234,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(duration_sec); }
if (duration_sec !== undefined) { updates.push('duration_sec = ?'); values.push(normalizeDuration(duration_sec)); }
// 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) {
@ -327,7 +337,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, a.duration_sec);
stmt.run(targetPlaylistId, a.content_id, a.widget_id, a.zone_id || null, maxOrder + i + 1, normalizeDuration(a.duration_sec));
});
});
transaction();