From 5c1cb4b992dde5f26731ac1914f19bd1046e7331 Mon Sep 17 00:00:00 2001 From: screentinker Date: Fri, 17 Jul 2026 13:41:52 -0500 Subject: [PATCH] fix(server): floor duration_sec to prevent widget zero-duration player loop (#199) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- server/db/database.js | 5 +++++ server/routes/assignments.js | 16 +++++++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/server/db/database.js b/server/db/database.js index 10825c2..5b6f3d5 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -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. diff --git a/server/routes/assignments.js b/server/routes/assignments.js index bf8e42c..896deee 100644 --- a/server/routes/assignments.js +++ b/server/routes/assignments.js @@ -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();