This commit is contained in:
1 parent
2a0e5e5591
commit
83b91bb122
8 files changed
+149
-216
No files matched your search
@@ -7,6 +7,8 @@ import svelte, { vitePreprocess } from '@astrojs/svelte';
|
||||
|
||||
import node from '@astrojs/node';
|
||||
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
output: 'server',
|
||||
@@ -35,6 +37,7 @@ export default defineConfig({
|
||||
ADMIN_PASSWORD: envField.string({ context: 'server', access: 'secret', optional: true }),
|
||||
ADMIN_COOKIE: envField.string({ context: 'server', access: 'secret', default: 'muelleel' }),
|
||||
|
||||
CACHE_PATH: envField.string({ context: 'server', access: 'secret', default: tmpdir() }),
|
||||
UPLOAD_PATH: envField.string({ context: 'server', access: 'secret', optional: true }),
|
||||
VIDEO_PATH: envField.string({ context: 'server', access: 'secret', optional: true }),
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
runtimeInputs = [
|
||||
pkgs.nodejs_26
|
||||
pkgs.curl
|
||||
pkgs.ffmpeg-headless
|
||||
];
|
||||
text = ''
|
||||
if [ ! -d node_modules ] || [ package-lock.json -nt node_modules/.package-lock.json ]; then
|
||||
|
||||
Generated
-182
@@ -19,7 +19,6 @@
|
||||
"bcrypt": "^6.0.0",
|
||||
"daisyui": "^5.7.50",
|
||||
"drizzle-orm": "^0.45.4",
|
||||
"music-metadata": "^12.3.2",
|
||||
"mysql2": "^3.24.6",
|
||||
"nanostores": "^1.5.5",
|
||||
"pino": "^10.4.0",
|
||||
@@ -427,16 +426,6 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@borewit/text-codec": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.3.tgz",
|
||||
"integrity": "sha512-49CycTz1aAZmvFcwkeFyhud1HUmRIB5Tabf7ALV+VA4Iu85MTfz2cUwzPagMFAdVBvrWdkCxG67I30tUwwpKsw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/@bruits/satteri-darwin-arm64": {
|
||||
"version": "0.10.5",
|
||||
"resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-arm64/-/satteri-darwin-arm64-0.10.5.tgz",
|
||||
@@ -2800,30 +2789,6 @@
|
||||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
||||
}
|
||||
},
|
||||
"node_modules/@tokenizer/inflate": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.6.0.tgz",
|
||||
"integrity": "sha512-+0RDxhBkyT9xMnGdJAwmats9yxRLR8ypJrQgrnLqOu4Gv/aHlnxYgdGVy2QTwvBl6IF/w2Hr+ZNirH+iMUbVZA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.3",
|
||||
"strtok3": "^10.6.2",
|
||||
"token-types": "^6.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/@tokenizer/token": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
|
||||
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.4",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz",
|
||||
@@ -3749,19 +3714,6 @@
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/content-type": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-3.1.1.tgz",
|
||||
"integrity": "sha512-GW4qUsfFo59d0HbUibDlWv5wPz+vAAcaTWbKIuKCf0JkC7wWkSyf8f13IpXn5JkeMlB2P8iTSSIjwXljorg2vA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz",
|
||||
@@ -4641,24 +4593,6 @@
|
||||
"flat-cache": "^6.1.23"
|
||||
}
|
||||
},
|
||||
"node_modules/file-type": {
|
||||
"version": "22.3.0",
|
||||
"resolved": "https://registry.npmjs.org/file-type/-/file-type-22.3.0.tgz",
|
||||
"integrity": "sha512-PrPedgnnWl5bWz3JHgar0QSzRvzeyFh7dZ9JpYOgcPZu4KALS9zw4MMa6qXRYyjeBsAlpaWB2boLuG991+0HIQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tokenizer/inflate": "^0.6.0",
|
||||
"strtok3": "^10.6.3",
|
||||
"token-types": "^6.1.2",
|
||||
"uint8array-extras": "^1.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sindresorhus/file-type?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
@@ -4969,26 +4903,6 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@@ -5540,19 +5454,6 @@
|
||||
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/media-typer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-2.0.0.tgz",
|
||||
"integrity": "sha512-kOy3OxT2HH39N70UnKgu4NWDZjLOz8W/mfyvniHjRH/DrL3f2pOfvWQ4p60offbbtDAnXWp0v9LfMIqMec269Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/meshoptimizer": {
|
||||
"version": "0.18.1",
|
||||
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz",
|
||||
@@ -5740,37 +5641,6 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/music-metadata": {
|
||||
"version": "12.3.2",
|
||||
"resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-12.3.2.tgz",
|
||||
"integrity": "sha512-U+Wu8c/7xa8wP331oVuRTAJSHcEWQIkLjpuyRJG+CAIszGYhpr5CRDv6PqkkVHDoX3ndjmdJlB6S49AaLq8SbQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
},
|
||||
{
|
||||
"type": "buymeacoffee",
|
||||
"url": "https://buymeacoffee.com/borewit"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@borewit/text-codec": "^0.2.2",
|
||||
"@tokenizer/token": "^0.3.0",
|
||||
"content-type": "^3.1.1",
|
||||
"debug": "^4.4.3",
|
||||
"file-type": "^22.2.0",
|
||||
"media-typer": "^2.0.0",
|
||||
"strtok3": "^10.6.2",
|
||||
"token-types": "^6.1.2",
|
||||
"uint8array-extras": "^1.6.0",
|
||||
"win-guid": "^0.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
},
|
||||
"node_modules/mysql2": {
|
||||
"version": "3.24.6",
|
||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.6.tgz",
|
||||
@@ -7293,22 +7163,6 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/strtok3": {
|
||||
"version": "10.6.3",
|
||||
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.6.3.tgz",
|
||||
"integrity": "sha512-MD8mENgyXMr2mDaDfzP3RWYDSyYQxIIou7HhCiCJiBekrsoDtUG0id3vYnjDfY9ndOEbazFXe13Qw6u/1AB8ew==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tokenizer/token": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/suf-log": {
|
||||
"version": "2.5.3",
|
||||
"resolved": "https://registry.npmjs.org/suf-log/-/suf-log-2.5.3.tgz",
|
||||
@@ -7641,24 +7495,6 @@
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/token-types": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz",
|
||||
"integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@borewit/text-codec": "^0.2.1",
|
||||
"@tokenizer/token": "^0.3.0",
|
||||
"ieee754": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/trim-lines": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
|
||||
@@ -7754,18 +7590,6 @@
|
||||
"integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uint8array-extras": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.6.0.tgz",
|
||||
"integrity": "sha512-8iAasVS4wUx0gPLjH8Xtz2PeDzTSiy8QiLGu2DT3X5GU+egFEQhpnbtkehbAwjvDcxIERmCYcysiODFmE3Ud0Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/ultrahtml": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/ultrahtml/-/ultrahtml-1.7.0.tgz",
|
||||
@@ -8169,12 +7993,6 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/win-guid": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/win-guid/-/win-guid-0.2.1.tgz",
|
||||
"integrity": "sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
"bcrypt": "^6.0.0",
|
||||
"daisyui": "^5.7.50",
|
||||
"drizzle-orm": "^0.45.4",
|
||||
"music-metadata": "^12.3.2",
|
||||
"mysql2": "^3.24.6",
|
||||
"nanostores": "^1.5.5",
|
||||
"pino": "^10.4.0",
|
||||
|
||||
@@ -8,13 +8,14 @@
|
||||
src: string;
|
||||
type?: string;
|
||||
title?: string;
|
||||
poster?: string;
|
||||
}
|
||||
|
||||
let { src, type, title }: Props = $props();
|
||||
let { src, type, title, poster }: Props = $props();
|
||||
</script>
|
||||
|
||||
<media-i18n lang="de">
|
||||
<video-player content-title={title}>
|
||||
<video-player content-title={title} {poster}>
|
||||
<video-skin>
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video {src} {type} playsinline preload="metadata">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import WebsiteLayout from '@layouts/website/WebsiteLayout.astro';
|
||||
import VideoPlayer from '@app/website/video/VideoPlayer.svelte';
|
||||
import { getVideoMetadata, resolveVideo } from '@util/video.ts';
|
||||
import { siteBase, siteUrl } from '@util/url.ts';
|
||||
import { siteUrl } from '@util/url.ts';
|
||||
|
||||
const { name } = Astro.params;
|
||||
|
||||
@@ -14,11 +14,18 @@ if (!metadata) return new Response(null, { status: 404 });
|
||||
|
||||
const { title, description, width, height, duration } = metadata;
|
||||
const streamUrl = siteUrl(`video/${encodeURIComponent(video.name)}/stream`);
|
||||
const thumbnailUrl = siteUrl(`video/${encodeURIComponent(video.name)}/thumbnail.jpg`);
|
||||
const thumbnailWidth = 1280;
|
||||
const thumbnailHeight = width && Math.round((height / width) * thumbnailWidth);
|
||||
---
|
||||
|
||||
<WebsiteLayout title={`${title} - Video`} description={description ?? undefined} openGraph="minimal">
|
||||
<Fragment slot="head">
|
||||
<meta property="og:type" content="video" />
|
||||
<meta property="og:image" content={thumbnailUrl} />
|
||||
<meta property="og:image:type" content="image/jpeg" />
|
||||
<meta property="og:image:width" content={thumbnailWidth.toString()} />
|
||||
<meta property="og:image:height" content={thumbnailHeight.toString()} />
|
||||
<meta property="og:video" content={streamUrl} />
|
||||
{streamUrl.startsWith('https') && <meta property="og:video:secure_url" content={streamUrl} />}
|
||||
<meta property="og:video:type" content={video.contentType} />
|
||||
@@ -33,7 +40,7 @@ const streamUrl = siteUrl(`video/${encodeURIComponent(video.name)}/stream`);
|
||||
<h1 class="mt-1 text-2xl font-semibold break-words lg:text-4xl">{title}</h1>
|
||||
</div>
|
||||
|
||||
<VideoPlayer client:load src={streamUrl} type={video.contentType} title={title} />
|
||||
<VideoPlayer client:load src={streamUrl} type={video.contentType} title={title} poster={thumbnailUrl} />
|
||||
|
||||
{description && (
|
||||
<p class="max-w-3xl text-base leading-relaxed whitespace-pre-line text-base-content/70">{description}</p>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import fs from 'node:fs';
|
||||
import type { APIContext, APIRoute } from 'astro';
|
||||
import { getVideoThumbnail, resolveVideo } from '@util/video.ts';
|
||||
|
||||
async function handle(context: APIContext, headersOnly = false) {
|
||||
const video = await resolveVideo(context.params.name ?? '');
|
||||
if (!video) return new Response(null, { status: 404 });
|
||||
|
||||
const thumbnail = await getVideoThumbnail(video);
|
||||
if (!thumbnail) return new Response(null, { status: 404 });
|
||||
|
||||
const stats = await fs.promises.stat(thumbnail.path);
|
||||
const headers = {
|
||||
'Content-Type': thumbnail.contentType,
|
||||
'Content-Length': stats.size.toString(),
|
||||
'Cache-Control': 'public, max-age=86400'
|
||||
};
|
||||
|
||||
if (headersOnly) return new Response(null, { headers });
|
||||
|
||||
const data = await fs.promises.readFile(thumbnail.path);
|
||||
return new Response(new Uint8Array(data), { headers });
|
||||
}
|
||||
|
||||
export const HEAD: APIRoute = (context) => handle(context, true);
|
||||
export const GET: APIRoute = (context) => handle(context);
|
||||
+107
-29
@@ -1,7 +1,9 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { parseFile } from 'music-metadata';
|
||||
import { VIDEO_PATH } from 'astro:env/server';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { CACHE_PATH, VIDEO_PATH } from 'astro:env/server';
|
||||
import { logger } from './log';
|
||||
|
||||
const videoTypes = [
|
||||
@@ -9,6 +11,8 @@ const videoTypes = [
|
||||
['webm', 'video/webm']
|
||||
];
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export interface ResolvedVideo {
|
||||
name: string;
|
||||
filename: string;
|
||||
@@ -23,12 +27,32 @@ export interface VideoRange {
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface VideoThumbnail {
|
||||
path: string;
|
||||
contentType: string;
|
||||
}
|
||||
|
||||
export interface VideoMetadata {
|
||||
title: string;
|
||||
description: string | null;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
duration: number | null;
|
||||
width: number;
|
||||
height: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
interface FFprobeStream {
|
||||
codec_type?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
tags?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface FFprobeResult {
|
||||
streams?: FFprobeStream[];
|
||||
format?: {
|
||||
duration?: string;
|
||||
tags?: Record<string, string>;
|
||||
};
|
||||
}
|
||||
|
||||
interface VideoMetadataCacheEntry {
|
||||
@@ -63,17 +87,12 @@ export async function resolveVideo(name: string): Promise<ResolvedVideo | null>
|
||||
size: stats.size,
|
||||
modifiedAt: stats.mtime
|
||||
};
|
||||
} catch (e: NodeJS.ErrnoException) {
|
||||
} catch (e) {
|
||||
// check if file doesn't exist
|
||||
if (e.code === 'ENOENT') {
|
||||
if ((e as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
continue;
|
||||
}
|
||||
logger.warn(
|
||||
{
|
||||
error: e.message
|
||||
},
|
||||
'error while reading video file'
|
||||
);
|
||||
logger.warn({ error: e }, 'error while reading video file');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,26 +109,38 @@ export async function getVideoMetadata(video: ResolvedVideo): Promise<VideoMetad
|
||||
|
||||
let metadata: VideoMetadata;
|
||||
try {
|
||||
const parsed = await parseFile(video.path, {
|
||||
duration: false,
|
||||
skipCovers: true
|
||||
});
|
||||
const { stdout } = await execFileAsync(
|
||||
'ffprobe',
|
||||
// prettier-ignore
|
||||
[
|
||||
'-v',
|
||||
'error', '-show_entries',
|
||||
'format=duration:format_tags=title,description,synopsis:stream=codec_type,width,height:stream_tags=title,description,synopsis',
|
||||
'-of', 'json',
|
||||
video.path
|
||||
],
|
||||
{ timeout: 20_000, maxBuffer: 1024 * 1024 }
|
||||
);
|
||||
|
||||
const videoTrack = parsed.format.trackInfo?.find((track) => track.video);
|
||||
const duration = parsed.format.containerDuration ?? parsed.format.duration;
|
||||
const parsed = JSON.parse(stdout) as FFprobeResult;
|
||||
if (!parsed.format) throw new Error('invalid video stream');
|
||||
|
||||
const videoStream = parsed.streams?.find((stream) => stream.codec_type === 'video');
|
||||
if (!videoStream) throw new Error('no video stream');
|
||||
else if (!videoStream.width || !videoStream.height) throw new Error('invalid video stream height');
|
||||
|
||||
const tags = { ...videoStream.tags, ...parsed.format.tags };
|
||||
const duration = Number(parsed.format.duration);
|
||||
|
||||
metadata = {
|
||||
title: parsed.common.title?.trim() || video.name,
|
||||
description:
|
||||
parsed.common.longDescription?.trim() ||
|
||||
parsed.common.description?.map((value) => value.trim()).find(Boolean) ||
|
||||
null,
|
||||
width: videoTrack?.video?.displayWidth ?? videoTrack?.video?.pixelWidth ?? null,
|
||||
height: videoTrack?.video?.displayHeight ?? videoTrack?.video?.pixelHeight ?? null,
|
||||
duration: duration ? Math.round(duration) : null
|
||||
title: tags['title'] || video.name,
|
||||
description: (tags['synopsis'] || tags['longdescription'] || tags['description']) ?? null,
|
||||
width: videoStream.width,
|
||||
height: videoStream.height,
|
||||
duration: Math.round(duration)
|
||||
};
|
||||
} catch {
|
||||
// couldn't read metadata
|
||||
} catch (e) {
|
||||
logger.warn({ error: e instanceof Error ? e.message : String(e) }, 'error while reading video metadata');
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -122,6 +153,53 @@ export async function getVideoMetadata(video: ResolvedVideo): Promise<VideoMetad
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export async function getVideoThumbnail(video: ResolvedVideo): Promise<VideoThumbnail | null> {
|
||||
if (!VIDEO_PATH) return null;
|
||||
|
||||
const cacheDirectory = path.join(path.resolve(CACHE_PATH), '.thumbnails');
|
||||
const cacheKey = createHash('sha1').update(`${video.path}:${video.size}:${video.modifiedAt.getTime()}`).digest('hex');
|
||||
const thumbnailPath = path.join(cacheDirectory, `${cacheKey}.jpg`);
|
||||
|
||||
try {
|
||||
const stats = await fs.promises.stat(thumbnailPath);
|
||||
if (stats.isFile() && stats.size > 0) {
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
const tempPath = path.join(cacheDirectory, `${cacheKey}-${randomUUID()}.jpg`);
|
||||
|
||||
try {
|
||||
await fs.promises.mkdir(cacheDirectory, { recursive: true });
|
||||
await execFileAsync(
|
||||
'ffmpeg',
|
||||
// prettier-ignore
|
||||
[
|
||||
'-v', 'error',
|
||||
'-y',
|
||||
'-i', video.path,
|
||||
'-frames:v', '1',
|
||||
'-vf', 'scale=1280:-2',
|
||||
'-q:v', '2',
|
||||
tempPath
|
||||
],
|
||||
{ timeout: 20_000, maxBuffer: 1024 * 1024 }
|
||||
);
|
||||
|
||||
await fs.promises.rename(tempPath, thumbnailPath);
|
||||
|
||||
return { path: thumbnailPath, contentType: 'image/jpeg' };
|
||||
} catch (e) {
|
||||
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
|
||||
logger.warn({ error: e instanceof Error ? e.message : String(e) }, 'error while generating video thumbnail');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseVideoRange(rangeHeader: string | null, size: number): VideoRange | 'invalid' | null {
|
||||
if (!rangeHeader) return null;
|
||||
if (size <= 0) return 'invalid';
|
||||
|
||||
Reference in new issue
Block a user