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 <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-04-11 22:24:56 -05:00
parent aee6766c4c
commit 1483500458
2 changed files with 83 additions and 44 deletions

View file

@ -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', [
const stdout = await new Promise((resolve, reject) => {
execFile('ffprobe', [
'-v', 'quiet', '-print_format', 'json', '-show_format', fullPath
], { timeout: 15000 });
], { 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) {

View file

@ -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];