mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
Make ffprobe re-probe async to avoid blocking the event loop
Swap execFileSync to execFile with promise wrapper in probeAndUpdateDuration(). Wrap the add-item handler in try/catch for Express 4.x async safety (Express 4 doesn't catch rejected promises from async handlers). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1ad390229b
commit
19fc38a59e
|
|
@ -6,16 +6,21 @@ const { db } = require('../db/database');
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
|
|
||||||
// Re-probe video duration with ffprobe if content.duration_sec is missing
|
// Re-probe video duration with ffprobe if content.duration_sec is missing
|
||||||
function probeAndUpdateDuration(content) {
|
async function probeAndUpdateDuration(content) {
|
||||||
if (content.duration_sec) return content.duration_sec;
|
if (content.duration_sec) return content.duration_sec;
|
||||||
if (!content.mime_type || !content.mime_type.startsWith('video/')) return null;
|
if (!content.mime_type || !content.mime_type.startsWith('video/')) return null;
|
||||||
if (!content.filepath) return null;
|
if (!content.filepath) return null;
|
||||||
try {
|
try {
|
||||||
const { execFileSync } = require('child_process');
|
const { execFile } = require('child_process');
|
||||||
const fullPath = path.join(config.contentDir, content.filepath);
|
const fullPath = path.join(config.contentDir, content.filepath);
|
||||||
const probe = execFileSync('ffprobe', [
|
const probe = await new Promise((resolve, reject) => {
|
||||||
'-v', 'quiet', '-print_format', 'json', '-show_format', fullPath
|
execFile('ffprobe', [
|
||||||
], { timeout: 15000 }).toString();
|
'-v', 'quiet', '-print_format', 'json', '-show_format', fullPath
|
||||||
|
], { timeout: 15000 }, (err, stdout) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
resolve(stdout);
|
||||||
|
});
|
||||||
|
});
|
||||||
const info = JSON.parse(probe);
|
const info = JSON.parse(probe);
|
||||||
if (info.format?.duration) {
|
if (info.format?.duration) {
|
||||||
const dur = parseFloat(info.format.duration);
|
const dur = parseFloat(info.format.duration);
|
||||||
|
|
@ -126,63 +131,68 @@ router.get('/:id/items', requirePlaylistOwnership, (req, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add item
|
// Add item
|
||||||
router.post('/:id/items', requirePlaylistOwnership, (req, res) => {
|
router.post('/:id/items', requirePlaylistOwnership, async (req, res) => {
|
||||||
const { content_id, widget_id, sort_order } = req.body;
|
try {
|
||||||
let { duration_sec } = req.body;
|
const { content_id, widget_id, sort_order } = req.body;
|
||||||
|
let { duration_sec } = req.body;
|
||||||
|
|
||||||
if (!content_id && !widget_id) return res.status(400).json({ error: 'content_id or widget_id required' });
|
if (!content_id && !widget_id) return res.status(400).json({ error: 'content_id or widget_id required' });
|
||||||
if (duration_sec !== undefined && duration_sec !== null && (typeof duration_sec !== 'number' || duration_sec < 1)) {
|
if (duration_sec !== undefined && duration_sec !== null && (typeof duration_sec !== 'number' || duration_sec < 1)) {
|
||||||
return res.status(400).json({ error: 'duration_sec must be a positive integer' });
|
return res.status(400).json({ error: 'duration_sec must be a positive integer' });
|
||||||
}
|
|
||||||
|
|
||||||
// Validate content ownership; use content's native duration as default for videos
|
|
||||||
if (content_id) {
|
|
||||||
const content = db.prepare('SELECT id, user_id, duration_sec, mime_type, filepath FROM content WHERE id = ?').get(content_id);
|
|
||||||
if (!content) return res.status(404).json({ error: 'Content not found' });
|
|
||||||
if (!['admin', 'superadmin'].includes(req.user.role) && content.user_id && content.user_id !== req.user.id) {
|
|
||||||
return res.status(403).json({ error: 'Content not owned by you' });
|
|
||||||
}
|
}
|
||||||
if (duration_sec === undefined || duration_sec === null) {
|
|
||||||
// Use stored duration, or re-probe if missing (backfills content table too)
|
// Validate content ownership; use content's native duration as default for videos
|
||||||
const contentDur = probeAndUpdateDuration(content);
|
if (content_id) {
|
||||||
if (contentDur) duration_sec = Math.ceil(contentDur);
|
const content = db.prepare('SELECT id, user_id, duration_sec, mime_type, filepath FROM content WHERE id = ?').get(content_id);
|
||||||
|
if (!content) return res.status(404).json({ error: 'Content not found' });
|
||||||
|
if (!['admin', 'superadmin'].includes(req.user.role) && content.user_id && content.user_id !== req.user.id) {
|
||||||
|
return res.status(403).json({ error: 'Content not owned by you' });
|
||||||
|
}
|
||||||
|
if (duration_sec === undefined || duration_sec === null) {
|
||||||
|
// Use stored duration, or re-probe if missing (backfills content table too)
|
||||||
|
const contentDur = await probeAndUpdateDuration(content);
|
||||||
|
if (contentDur) duration_sec = Math.ceil(contentDur);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if (duration_sec === undefined || duration_sec === null) duration_sec = 10;
|
||||||
|
if (widget_id) {
|
||||||
|
const widget = db.prepare('SELECT id FROM widgets WHERE id = ?').get(widget_id);
|
||||||
|
if (!widget) return res.status(404).json({ error: 'Widget not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-increment sort_order if not specified
|
||||||
|
let order = sort_order;
|
||||||
|
if (order === undefined || order === null) {
|
||||||
|
const max = db.prepare('SELECT MAX(sort_order) as max_order FROM playlist_items WHERE playlist_id = ?')
|
||||||
|
.get(req.params.id);
|
||||||
|
order = (max.max_order || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = db.prepare(`
|
||||||
|
INSERT INTO playlist_items (playlist_id, content_id, widget_id, sort_order, duration_sec)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`).run(req.params.id, content_id || null, widget_id || null, order, duration_sec);
|
||||||
|
|
||||||
|
// Touch playlist updated_at
|
||||||
|
db.prepare("UPDATE playlists SET updated_at = strftime('%s','now') WHERE id = ?").run(req.params.id);
|
||||||
|
|
||||||
|
const item = db.prepare(`
|
||||||
|
SELECT pi.*,
|
||||||
|
COALESCE(c.filename, w.name) as filename,
|
||||||
|
c.mime_type, c.filepath, c.thumbnail_path,
|
||||||
|
c.duration_sec as content_duration, c.file_size, c.remote_url,
|
||||||
|
w.name as widget_name, w.widget_type, w.config as widget_config
|
||||||
|
FROM playlist_items pi
|
||||||
|
LEFT JOIN content c ON pi.content_id = c.id
|
||||||
|
LEFT JOIN widgets w ON pi.widget_id = w.id
|
||||||
|
WHERE pi.id = ?
|
||||||
|
`).get(result.lastInsertRowid);
|
||||||
|
|
||||||
|
res.status(201).json(item);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to add playlist item:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to add item' });
|
||||||
}
|
}
|
||||||
if (duration_sec === undefined || duration_sec === null) duration_sec = 10;
|
|
||||||
if (widget_id) {
|
|
||||||
const widget = db.prepare('SELECT id FROM widgets WHERE id = ?').get(widget_id);
|
|
||||||
if (!widget) return res.status(404).json({ error: 'Widget not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-increment sort_order if not specified
|
|
||||||
let order = sort_order;
|
|
||||||
if (order === undefined || order === null) {
|
|
||||||
const max = db.prepare('SELECT MAX(sort_order) as max_order FROM playlist_items WHERE playlist_id = ?')
|
|
||||||
.get(req.params.id);
|
|
||||||
order = (max.max_order || 0) + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = db.prepare(`
|
|
||||||
INSERT INTO playlist_items (playlist_id, content_id, widget_id, sort_order, duration_sec)
|
|
||||||
VALUES (?, ?, ?, ?, ?)
|
|
||||||
`).run(req.params.id, content_id || null, widget_id || null, order, duration_sec);
|
|
||||||
|
|
||||||
// Touch playlist updated_at
|
|
||||||
db.prepare("UPDATE playlists SET updated_at = strftime('%s','now') WHERE id = ?").run(req.params.id);
|
|
||||||
|
|
||||||
const item = db.prepare(`
|
|
||||||
SELECT pi.*,
|
|
||||||
COALESCE(c.filename, w.name) as filename,
|
|
||||||
c.mime_type, c.filepath, c.thumbnail_path,
|
|
||||||
c.duration_sec as content_duration, c.file_size, c.remote_url,
|
|
||||||
w.name as widget_name, w.widget_type, w.config as widget_config
|
|
||||||
FROM playlist_items pi
|
|
||||||
LEFT JOIN content c ON pi.content_id = c.id
|
|
||||||
LEFT JOIN widgets w ON pi.widget_id = w.id
|
|
||||||
WHERE pi.id = ?
|
|
||||||
`).get(result.lastInsertRowid);
|
|
||||||
|
|
||||||
res.status(201).json(item);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update item
|
// Update item
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue