From aee6766c4c08a62a278bd542010f57f9f6c4b45c Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Sat, 11 Apr 2026 22:21:40 -0500 Subject: [PATCH] Migration: probe video durations with ffprobe during assignment-to-playlist conversion Videos were getting the default 10s from the assignments table. Now ffprobe runs for each video content item during migration, backfills the content table, and uses the real duration in playlist_items. Co-Authored-By: Claude Opus 4.6 --- server/db/database.js | 63 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 51 insertions(+), 12 deletions(-) diff --git a/server/db/database.js b/server/db/database.js index 6e8769f..38c9426 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -115,6 +115,30 @@ function migrateAssignmentsToPlaylists() { console.log(`Migrating ${devicesWithAssignments.length} device(s) from assignments to playlists...`); + // Probe video duration with ffprobe (synchronous — fine for one-time migration) + const { execFileSync } = require('child_process'); + function probeVideoDuration(content) { + if (!content || !content.mime_type || !content.mime_type.startsWith('video/')) return null; + if (content.duration_sec) return Math.ceil(content.duration_sec); + if (!content.filepath) return null; + try { + const fullPath = path.join(config.contentDir, content.filepath); + const stdout = execFileSync('ffprobe', [ + '-v', 'quiet', '-print_format', 'json', '-show_format', fullPath + ], { timeout: 15000 }); + const info = JSON.parse(stdout); + if (info.format?.duration) { + const dur = parseFloat(info.format.duration); + // Backfill the content table too + db.prepare('UPDATE content SET duration_sec = ? WHERE id = ?').run(dur, content.id); + return Math.ceil(dur); + } + } catch (e) { + console.warn(` ffprobe failed for ${content.id}:`, e.message); + } + return null; + } + const insertPlaylist = db.prepare(` INSERT INTO playlists (id, user_id, name, description, is_auto_generated) VALUES (?, ?, ?, ?, 1) @@ -125,22 +149,37 @@ function migrateAssignmentsToPlaylists() { `); const setDevicePlaylist = db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?'); const getAssignments = db.prepare(` - SELECT content_id, widget_id, sort_order, duration_sec - FROM assignments - WHERE device_id = ? AND enabled = 1 - ORDER BY sort_order ASC + SELECT a.content_id, a.widget_id, a.sort_order, a.duration_sec, + c.mime_type, c.filepath, c.duration_sec as content_duration + FROM assignments a + LEFT JOIN content c ON a.content_id = c.id + WHERE a.device_id = ? AND a.enabled = 1 + ORDER BY a.sort_order ASC `); - const migrate = db.transaction(() => { - for (const device of devicesWithAssignments) { - const playlistId = uuidv4(); - insertPlaylist.run(playlistId, device.user_id, `${device.name} (migrated)`, 'Auto-generated from previous assignments'); - - const assignments = getAssignments.all(device.id); - for (const a of assignments) { - insertItem.run(playlistId, a.content_id || null, a.widget_id || null, a.sort_order, a.duration_sec); + // Probe durations outside the transaction (ffprobe can't run inside SQLite transaction) + const devicePlaylists = []; + for (const device of devicesWithAssignments) { + const playlistId = uuidv4(); + const assignments = getAssignments.all(device.id); + const items = assignments.map(a => { + let duration = a.duration_sec; + if (a.content_id && a.mime_type?.startsWith('video/')) { + const probed = probeVideoDuration({ id: a.content_id, mime_type: a.mime_type, filepath: a.filepath, duration_sec: a.content_duration }); + if (probed) duration = probed; } + return { content_id: a.content_id, widget_id: a.widget_id, sort_order: a.sort_order, duration_sec: duration }; + }); + devicePlaylists.push({ device, playlistId, items }); + } + // Insert everything in a single transaction + const migrate = db.transaction(() => { + for (const { device, playlistId, items } of devicePlaylists) { + insertPlaylist.run(playlistId, device.user_id, `${device.name} (migrated)`, 'Auto-generated from previous assignments'); + for (const item of items) { + insertItem.run(playlistId, item.content_id || null, item.widget_id || null, item.sort_order, item.duration_sec); + } setDevicePlaylist.run(playlistId, device.id); } });