This commit is contained in:
1 parent
c8e38d51f4
commit
133667de9b
1 file changed
+46
-25
+46
-25
@@ -58,20 +58,45 @@ export async function resolveVideo(name: string): Promise<ResolvedVideo | null>
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VideoMetadata {
|
|
||||||
title: string;
|
|
||||||
description: string | null;
|
|
||||||
longDescription: string | null;
|
|
||||||
}
|
|
||||||
interface FFprobeResult {
|
interface FFprobeResult {
|
||||||
streams?: {
|
streams?: {
|
||||||
|
index?: number;
|
||||||
codec_type?: string;
|
codec_type?: string;
|
||||||
tags?: Record<string, string>;
|
tags?: Record<string, string>;
|
||||||
|
disposition?: {
|
||||||
|
attached_pic?: number;
|
||||||
|
};
|
||||||
}[];
|
}[];
|
||||||
format?: {
|
format?: {
|
||||||
tags?: Record<string, string>;
|
tags?: Record<string, string>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
async function getVideoFFprobeMetadata(filePath: string): Promise<FFprobeResult | null> {
|
||||||
|
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<VideoMetadata | null> {
|
export async function getVideoMetadata(video: ResolvedVideo): Promise<VideoMetadata | null> {
|
||||||
const modifiedAtMs = video.modifiedAt.getTime();
|
const modifiedAtMs = video.modifiedAt.getTime();
|
||||||
const cached = videoMetadataCache.get(video.path);
|
const cached = videoMetadataCache.get(video.path);
|
||||||
@@ -82,26 +107,16 @@ export async function getVideoMetadata(video: ResolvedVideo): Promise<VideoMetad
|
|||||||
|
|
||||||
let metadata: VideoMetadata;
|
let metadata: VideoMetadata;
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execFileAsync(
|
const probe = await getVideoFFprobeMetadata(video.path);
|
||||||
'ffprobe',
|
if (!probe) return null;
|
||||||
// prettier-ignore
|
|
||||||
[
|
|
||||||
'-v', 'error',
|
|
||||||
'-show_entries', 'format_tags=title,description,synopsis,longdescription:stream=codec_type:stream_tags=title,description,synopsis,longdescription',
|
|
||||||
'-of', 'json',
|
|
||||||
video.path
|
|
||||||
],
|
|
||||||
{ timeout: 20_000, maxBuffer: 1024 * 1024 }
|
|
||||||
);
|
|
||||||
|
|
||||||
const parsed = JSON.parse(stdout) as FFprobeResult;
|
const videoStream = probe.streams?.find((stream) => stream.codec_type === 'video');
|
||||||
const videoStream = parsed.streams?.find((stream) => stream.codec_type === 'video');
|
const tags = { ...videoStream?.tags, ...probe.format?.tags };
|
||||||
const tags = { ...videoStream?.tags, ...parsed.format?.tags };
|
|
||||||
|
|
||||||
metadata = {
|
metadata = {
|
||||||
title: tags['title'] || video.name,
|
title: tags['title'] || video.name,
|
||||||
description: tags['description'],
|
description: tags['description'],
|
||||||
longDescription: tags['synopsis'],
|
longDescription: tags['synopsis']
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn({ error: e instanceof Error ? e.message : String(e) }, 'error while reading video metadata');
|
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<VideoThum
|
|||||||
if (!VIDEO_PATH) return null;
|
if (!VIDEO_PATH) return null;
|
||||||
|
|
||||||
const cacheDirectory = path.join(path.resolve(CACHE_PATH), '.thumbnails');
|
const cacheDirectory = path.join(path.resolve(CACHE_PATH), '.thumbnails');
|
||||||
const cacheKey = createHash('sha1').update(`${video.path}:${video.size}:${video.modifiedAt.getTime()}`).digest('hex');
|
const cacheKey = createHash('sha1')
|
||||||
|
.update(`cover:${video.path}:${video.size}:${video.modifiedAt.getTime()}`)
|
||||||
|
.digest('hex');
|
||||||
const thumbnailPath = path.join(cacheDirectory, `${cacheKey}.jpg`);
|
const thumbnailPath = path.join(cacheDirectory, `${cacheKey}.jpg`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stats = await fs.promises.stat(thumbnailPath);
|
const stats = await fs.promises.stat(thumbnailPath);
|
||||||
if (stats.isFile() && stats.size > 0) {
|
if (stats.isFile()) return { path: thumbnailPath, contentType: 'image/jpeg' };
|
||||||
return { path: thumbnailPath, contentType: 'image/jpeg' };
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if ((e as NodeJS.ErrnoException).code !== 'ENOENT') {
|
if ((e as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||||
logger.warn({ error: (e as Error).message }, 'error while reading video thumbnail');
|
logger.warn({ error: (e as Error).message }, 'error while reading video thumbnail');
|
||||||
@@ -143,6 +158,11 @@ export async function getVideoThumbnail(video: ResolvedVideo): Promise<VideoThum
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.promises.mkdir(cacheDirectory, { recursive: true });
|
await fs.promises.mkdir(cacheDirectory, { recursive: true });
|
||||||
|
|
||||||
|
const probe = await getVideoFFprobeMetadata(video.path);
|
||||||
|
const coverIndex = probe?.streams?.find((stream) => stream.disposition?.attached_pic === 1)?.index ?? null;
|
||||||
|
|
||||||
|
// extract embedded cover or first frame
|
||||||
await execFileAsync(
|
await execFileAsync(
|
||||||
'ffmpeg',
|
'ffmpeg',
|
||||||
// prettier-ignore
|
// prettier-ignore
|
||||||
@@ -150,10 +170,11 @@ export async function getVideoThumbnail(video: ResolvedVideo): Promise<VideoThum
|
|||||||
'-v', 'error',
|
'-v', 'error',
|
||||||
'-y',
|
'-y',
|
||||||
'-i', video.path,
|
'-i', video.path,
|
||||||
|
...(coverIndex !== null ? ['-map', `0:${coverIndex}`] : []),
|
||||||
'-frames:v', '1',
|
'-frames:v', '1',
|
||||||
'-vf', 'scale=1280:-2',
|
'-vf', 'scale=1280:-2',
|
||||||
'-q:v', '2',
|
'-q:v', '2',
|
||||||
tempPath
|
tempPath,
|
||||||
],
|
],
|
||||||
{ timeout: 20_000, maxBuffer: 1024 * 1024 }
|
{ timeout: 20_000, maxBuffer: 1024 * 1024 }
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in new issue
Block a user