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 <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-04-11 22:21:40 -05:00
parent aca1558702
commit aee6766c4c

View file

@ -115,6 +115,30 @@ function migrateAssignmentsToPlaylists() {
console.log(`Migrating ${devicesWithAssignments.length} device(s) from assignments to playlists...`); 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(` const insertPlaylist = db.prepare(`
INSERT INTO playlists (id, user_id, name, description, is_auto_generated) INSERT INTO playlists (id, user_id, name, description, is_auto_generated)
VALUES (?, ?, ?, ?, 1) VALUES (?, ?, ?, ?, 1)
@ -125,22 +149,37 @@ function migrateAssignmentsToPlaylists() {
`); `);
const setDevicePlaylist = db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?'); const setDevicePlaylist = db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?');
const getAssignments = db.prepare(` const getAssignments = db.prepare(`
SELECT content_id, widget_id, sort_order, duration_sec SELECT a.content_id, a.widget_id, a.sort_order, a.duration_sec,
FROM assignments c.mime_type, c.filepath, c.duration_sec as content_duration
WHERE device_id = ? AND enabled = 1 FROM assignments a
ORDER BY sort_order ASC 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(() => { // Probe durations outside the transaction (ffprobe can't run inside SQLite transaction)
const devicePlaylists = [];
for (const device of devicesWithAssignments) { for (const device of devicesWithAssignments) {
const playlistId = uuidv4(); const playlistId = uuidv4();
insertPlaylist.run(playlistId, device.user_id, `${device.name} (migrated)`, 'Auto-generated from previous assignments');
const assignments = getAssignments.all(device.id); const assignments = getAssignments.all(device.id);
for (const a of assignments) { const items = assignments.map(a => {
insertItem.run(playlistId, a.content_id || null, a.widget_id || null, a.sort_order, a.duration_sec); 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); setDevicePlaylist.run(playlistId, device.id);
} }
}); });