From 133667de9bb3ff49aaa0ccb6e0347ecda99ec168 Mon Sep 17 00:00:00 2001 From: bytedream Date: Sat, 31 Oct 2026 12:23:09 +0100 Subject: [PATCH] get thumbnail from attached pic if present --- src/util/video.ts | 71 ++++++++++++++++++++++++++++++----------------- 1 file changed, 46 insertions(+), 25 deletions(-) diff --git a/src/util/video.ts b/src/util/video.ts index fff8fb6..f115d5b 100644 --- a/src/util/video.ts +++ b/src/util/video.ts @@ -58,20 +58,45 @@ export async function resolveVideo(name: string): Promise return null; } -export interface VideoMetadata { - title: string; - description: string | null; - longDescription: string | null; -} interface FFprobeResult { streams?: { + index?: number; codec_type?: string; tags?: Record; + disposition?: { + attached_pic?: number; + }; }[]; format?: { tags?: Record; }; } +async function getVideoFFprobeMetadata(filePath: string): Promise { + try { + const { stdout } = await execFileAsync( + 'ffprobe', + // prettier-ignore + [ + '-v', 'error', + '-show_entries', 'format_tags=title,description,synopsis,longdescription:stream=index,codec_type:stream_tags=title,description,synopsis,longdescription:stream_disposition=attached_pic', + '-of', 'json', + filePath + ], + { timeout: 20_000, maxBuffer: 1024 * 1024 } + ); + + return JSON.parse(stdout) as FFprobeResult; + } catch (e) { + logger.warn({ error: e instanceof Error ? e.message : String(e) }, 'error while reading video metadata'); + return null; + } +} + +export interface VideoMetadata { + title: string; + description: string | null; + longDescription: string | null; +} export async function getVideoMetadata(video: ResolvedVideo): Promise { const modifiedAtMs = video.modifiedAt.getTime(); const cached = videoMetadataCache.get(video.path); @@ -82,26 +107,16 @@ export async function getVideoMetadata(video: ResolvedVideo): Promise stream.codec_type === 'video'); - const tags = { ...videoStream?.tags, ...parsed.format?.tags }; + const videoStream = probe.streams?.find((stream) => stream.codec_type === 'video'); + const tags = { ...videoStream?.tags, ...probe.format?.tags }; metadata = { title: tags['title'] || video.name, description: tags['description'], - longDescription: tags['synopsis'], + longDescription: tags['synopsis'] }; } catch (e) { logger.warn({ error: e instanceof Error ? e.message : String(e) }, 'error while reading video metadata'); @@ -125,14 +140,14 @@ export async function getVideoThumbnail(video: ResolvedVideo): Promise 0) { - return { path: thumbnailPath, contentType: 'image/jpeg' }; - } + if (stats.isFile()) return { path: thumbnailPath, contentType: 'image/jpeg' }; } catch (e) { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') { logger.warn({ error: (e as Error).message }, 'error while reading video thumbnail'); @@ -143,6 +158,11 @@ export async function getVideoThumbnail(video: ResolvedVideo): Promise stream.disposition?.attached_pic === 1)?.index ?? null; + + // extract embedded cover or first frame await execFileAsync( 'ffmpeg', // prettier-ignore @@ -150,10 +170,11 @@ export async function getVideoThumbnail(video: ResolvedVideo): Promise