From 14835004586758fb609a87781802d065c8faa253 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Sat, 11 Apr 2026 22:24:56 -0500 Subject: [PATCH] Probe video durations during migration and v1 import Migration (database.js): switched from sync execFileSync to async execFile with promise wrapper, matching the pattern in playlists.js. Probes each video content item, backfills content.duration_sec, and uses the real duration in playlist_items. Falls back to the assignment's original duration_sec if the probe fails or content isn't a video. V1 import (status.js): moved assignment-to-playlist conversion out of the synchronous db.transaction() so async ffprobe can run. Content files are already on disk from the transaction, so probing works. Same fallback logic. Co-Authored-By: Claude Opus 4.6 --- server/db/database.js | 43 ++++++++++----------- server/routes/status.js | 84 ++++++++++++++++++++++++++++++----------- 2 files changed, 83 insertions(+), 44 deletions(-) diff --git a/server/db/database.js b/server/db/database.js index 38c9426..bd889bd 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -96,12 +96,13 @@ try { } // Phase 2 migration: convert existing assignments into per-device playlists -function migrateAssignmentsToPlaylists() { +async function migrateAssignmentsToPlaylists() { // Skip if already migrated (any device has a playlist_id set) const migrated = db.prepare('SELECT 1 FROM devices WHERE playlist_id IS NOT NULL LIMIT 1').get(); if (migrated) return; const { v4: uuidv4 } = require('uuid'); + const { execFile } = require('child_process'); // Find devices that have at least one assignment const devicesWithAssignments = db.prepare(` @@ -115,21 +116,21 @@ 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) { + // Async ffprobe — matches the pattern in playlists.js probeAndUpdateDuration + async 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 stdout = await new Promise((resolve, reject) => { + execFile('ffprobe', [ + '-v', 'quiet', '-print_format', 'json', '-show_format', fullPath + ], { timeout: 15000 }, (err, out) => err ? reject(err) : resolve(out)); + }); 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); } @@ -139,15 +140,6 @@ function migrateAssignmentsToPlaylists() { return null; } - const insertPlaylist = db.prepare(` - INSERT INTO playlists (id, user_id, name, description, is_auto_generated) - VALUES (?, ?, ?, ?, 1) - `); - const insertItem = db.prepare(` - INSERT INTO playlist_items (playlist_id, content_id, widget_id, sort_order, duration_sec) - VALUES (?, ?, ?, ?, ?) - `); - const setDevicePlaylist = db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?'); const getAssignments = db.prepare(` SELECT a.content_id, a.widget_id, a.sort_order, a.duration_sec, c.mime_type, c.filepath, c.duration_sec as content_duration @@ -157,23 +149,28 @@ function migrateAssignmentsToPlaylists() { ORDER BY a.sort_order ASC `); - // Probe durations outside the transaction (ffprobe can't run inside SQLite transaction) + // Probe durations outside the transaction (async 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 => { + const items = []; + for (const a of assignments) { 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 }); + const probed = await 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 }; - }); + items.push({ 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 insertPlaylist = db.prepare(`INSERT INTO playlists (id, user_id, name, description, is_auto_generated) VALUES (?, ?, ?, ?, 1)`); + const insertItem = db.prepare(`INSERT INTO playlist_items (playlist_id, content_id, widget_id, sort_order, duration_sec) VALUES (?, ?, ?, ?, ?)`); + const setDevicePlaylist = db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?'); + 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'); @@ -188,7 +185,7 @@ function migrateAssignmentsToPlaylists() { console.log(`Migration complete: ${devicesWithAssignments.length} playlist(s) created.`); } -migrateAssignmentsToPlaylists(); +migrateAssignmentsToPlaylists().catch(e => console.error('Migration error:', e)); // Prune old telemetry (keep last 24h worth at 15s intervals = ~5760, cap at 6000) function pruneTelemetry(deviceId) { diff --git a/server/routes/status.js b/server/routes/status.js index a541337..dc268fb 100644 --- a/server/routes/status.js +++ b/server/routes/status.js @@ -349,27 +349,8 @@ router.post('/import', importUpload.single('file'), async (req, res) => { } } } else { - // v1: convert assignments to per-device playlists - const assignmentsByDevice = {}; - for (const a of (data.assignments || [])) { - if (!assignmentsByDevice[a.device_id]) assignmentsByDevice[a.device_id] = []; - assignmentsByDevice[a.device_id].push(a); - } - for (const [oldDevId, assignments] of Object.entries(assignmentsByDevice)) { - const devId = idMap.devices[oldDevId]; - if (!devId) continue; - const devName = (data.devices || []).find(d => d.id === oldDevId)?.name || 'Display'; - const playlistId = uuid.v4(); - db.prepare('INSERT INTO playlists (id, user_id, name, description, is_auto_generated) VALUES (?, ?, ?, ?, 1)').run(playlistId, userId, `${devName} (imported)`, 'Converted from v1 assignments'); - for (const a of assignments) { - const contentId = a.content_id ? idMap.content[a.content_id] : null; - const widgetId = a.widget_id ? idMap.widgets[a.widget_id] : null; - if (!contentId && !widgetId) continue; - db.prepare('INSERT INTO playlist_items (playlist_id, content_id, widget_id, sort_order, duration_sec) VALUES (?, ?, ?, ?, ?)').run(playlistId, contentId, widgetId, a.sort_order || 0, a.duration_sec || 10); - } - db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?').run(playlistId, devId); - stats.playlists++; - } + // v1: defer playlist creation to after the transaction so we can async-probe videos + // Just stash the mapping for now; actual insertion happens below after importDb() } // Import schedules @@ -440,6 +421,67 @@ router.post('/import', importUpload.single('file'), async (req, res) => { try { importDb(); + + // v1: convert assignments to per-device playlists AFTER transaction (content files now on disk) + if (!isV2 && data.assignments?.length) { + const { execFile } = require('child_process'); + + async function probeImportedContent(newContentId) { + const c = db.prepare('SELECT id, mime_type, filepath, duration_sec FROM content WHERE id = ?').get(newContentId); + if (!c || !c.mime_type?.startsWith('video/') || !c.filepath) return c?.duration_sec ? Math.ceil(c.duration_sec) : null; + if (c.duration_sec) return Math.ceil(c.duration_sec); + try { + const fullPath = path.join(config.contentDir, c.filepath); + const stdout = await new Promise((resolve, reject) => { + execFile('ffprobe', ['-v', 'quiet', '-print_format', 'json', '-show_format', fullPath], + { timeout: 15000 }, (err, out) => err ? reject(err) : resolve(out)); + }); + const info = JSON.parse(stdout); + if (info.format?.duration) { + const dur = parseFloat(info.format.duration); + db.prepare('UPDATE content SET duration_sec = ? WHERE id = ?').run(dur, c.id); + return Math.ceil(dur); + } + } catch (e) { /* probe failed, fall back to default */ } + return null; + } + + const assignmentsByDevice = {}; + for (const a of (data.assignments || [])) { + if (!assignmentsByDevice[a.device_id]) assignmentsByDevice[a.device_id] = []; + assignmentsByDevice[a.device_id].push(a); + } + + for (const [oldDevId, assignments] of Object.entries(assignmentsByDevice)) { + const devId = idMap.devices[oldDevId]; + if (!devId) continue; + const devName = (data.devices || []).find(d => d.id === oldDevId)?.name || 'Display'; + const playlistId = uuid.v4(); + + const items = []; + for (const a of assignments) { + const contentId = a.content_id ? idMap.content[a.content_id] : null; + const widgetId = a.widget_id ? idMap.widgets[a.widget_id] : null; + if (!contentId && !widgetId) continue; + let duration = a.duration_sec || 10; + if (contentId) { + const probed = await probeImportedContent(contentId); + if (probed) duration = probed; + } + items.push({ contentId, widgetId, sort_order: a.sort_order || 0, duration }); + } + + db.prepare('INSERT INTO playlists (id, user_id, name, description, is_auto_generated) VALUES (?, ?, ?, ?, 1)') + .run(playlistId, userId, `${devName} (imported)`, 'Converted from v1 assignments'); + for (const item of items) { + db.prepare('INSERT INTO playlist_items (playlist_id, content_id, widget_id, sort_order, duration_sec) VALUES (?, ?, ?, ?, ?)') + .run(playlistId, item.contentId, item.widgetId, item.sort_order, item.duration); + } + db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?').run(playlistId, devId); + stats.playlists++; + } + } + // Collect pairing codes for imported devices const devicePairings = (data.devices || []).map(d => { const newId = idMap.devices[d.id];