show description and long description
This commit is contained in:
1 parent
e5e26222ce
commit
c8e38d51f4
2 files changed
+45
-53
No files matched your search
@@ -12,12 +12,12 @@ if (!video) return new Response(null, { status: 404 });
|
|||||||
const metadata = await getVideoMetadata(video);
|
const metadata = await getVideoMetadata(video);
|
||||||
if (!metadata) return new Response(null, { status: 404 });
|
if (!metadata) return new Response(null, { status: 404 });
|
||||||
|
|
||||||
const { title, description } = metadata;
|
const { title, description, longDescription } = metadata;
|
||||||
const streamUrl = siteUrl(`video/${encodeURIComponent(video.name)}/stream`);
|
const streamUrl = siteUrl(`video/${encodeURIComponent(video.name)}/stream`);
|
||||||
const thumbnailUrl = siteUrl(`video/${encodeURIComponent(video.name)}/thumbnail.jpg`);
|
const thumbnailUrl = siteUrl(`video/${encodeURIComponent(video.name)}/thumbnail.jpg`);
|
||||||
---
|
---
|
||||||
|
|
||||||
<WebsiteLayout title={`${title} - Video`} description={description ?? undefined} openGraph="minimal">
|
<WebsiteLayout title={`${title} - Video`} description={description ?? longDescription ?? undefined} openGraph="minimal">
|
||||||
<Fragment slot="head">
|
<Fragment slot="head">
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:image" content={thumbnailUrl} />
|
<meta property="og:image" content={thumbnailUrl} />
|
||||||
@@ -31,10 +31,14 @@ const thumbnailUrl = siteUrl(`video/${encodeURIComponent(video.name)}/thumbnail.
|
|||||||
<h1 class="mt-1 text-2xl font-semibold break-words lg:text-4xl">{title}</h1>
|
<h1 class="mt-1 text-2xl font-semibold break-words lg:text-4xl">{title}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{description && (
|
||||||
|
<p class="max-w-3xl text-base leading-relaxed whitespace-pre-line text-base-content/80">{description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<VideoPlayer client:load src={streamUrl} type={video.contentType} title={title} poster={thumbnailUrl} />
|
<VideoPlayer client:load src={streamUrl} type={video.contentType} title={title} poster={thumbnailUrl} />
|
||||||
|
|
||||||
{description && (
|
{longDescription && (
|
||||||
<p class="max-w-3xl text-base leading-relaxed whitespace-pre-line text-base-content/70">{description}</p>
|
<p class="max-w-3xl text-base leading-relaxed whitespace-pre-line text-base-content/70">{longDescription}</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</WebsiteLayout>
|
</WebsiteLayout>
|
||||||
+37
-49
@@ -6,49 +6,8 @@ import { createHash, randomUUID } from 'node:crypto';
|
|||||||
import { CACHE_PATH, VIDEO_PATH } from 'astro:env/server';
|
import { CACHE_PATH, VIDEO_PATH } from 'astro:env/server';
|
||||||
import { logger } from './log';
|
import { logger } from './log';
|
||||||
|
|
||||||
const videoTypes = [
|
|
||||||
['mp4', 'video/mp4'],
|
|
||||||
['webm', 'video/webm']
|
|
||||||
];
|
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
export interface ResolvedVideo {
|
|
||||||
name: string;
|
|
||||||
filename: string;
|
|
||||||
path: string;
|
|
||||||
contentType: string;
|
|
||||||
size: number;
|
|
||||||
modifiedAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VideoRange {
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VideoThumbnail {
|
|
||||||
path: string;
|
|
||||||
contentType: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VideoMetadata {
|
|
||||||
title: string;
|
|
||||||
description: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FFprobeStream {
|
|
||||||
codec_type?: string;
|
|
||||||
tags?: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FFprobeResult {
|
|
||||||
streams?: FFprobeStream[];
|
|
||||||
format?: {
|
|
||||||
tags?: Record<string, string>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VideoMetadataCacheEntry {
|
interface VideoMetadataCacheEntry {
|
||||||
modifiedAtMs: number;
|
modifiedAtMs: number;
|
||||||
size: number;
|
size: number;
|
||||||
@@ -57,6 +16,14 @@ interface VideoMetadataCacheEntry {
|
|||||||
|
|
||||||
const videoMetadataCache = new Map<string, VideoMetadataCacheEntry>();
|
const videoMetadataCache = new Map<string, VideoMetadataCacheEntry>();
|
||||||
|
|
||||||
|
export interface ResolvedVideo {
|
||||||
|
name: string;
|
||||||
|
filename: string;
|
||||||
|
path: string;
|
||||||
|
contentType: string;
|
||||||
|
size: number;
|
||||||
|
modifiedAt: Date;
|
||||||
|
}
|
||||||
export async function resolveVideo(name: string): Promise<ResolvedVideo | null> {
|
export async function resolveVideo(name: string): Promise<ResolvedVideo | null> {
|
||||||
if (!VIDEO_PATH || !name) return null;
|
if (!VIDEO_PATH || !name) return null;
|
||||||
|
|
||||||
@@ -65,34 +32,46 @@ export async function resolveVideo(name: string): Promise<ResolvedVideo | null>
|
|||||||
|
|
||||||
if (!safeName) return null;
|
if (!safeName) return null;
|
||||||
|
|
||||||
for (const [extension, contentType] of videoTypes) {
|
const filename = `${safeName}.mp4`;
|
||||||
const filename = `${safeName}.${extension}`;
|
|
||||||
const filePath = path.join(directory, filename);
|
const filePath = path.join(directory, filename);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stats = await fs.promises.stat(filePath);
|
const stats = await fs.promises.stat(filePath);
|
||||||
if (!stats.isFile()) continue;
|
if (!stats.isFile()) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: safeName,
|
name: safeName,
|
||||||
filename,
|
filename,
|
||||||
path: filePath,
|
path: filePath,
|
||||||
contentType,
|
contentType: 'video/mp4',
|
||||||
size: stats.size,
|
size: stats.size,
|
||||||
modifiedAt: stats.mtime
|
modifiedAt: stats.mtime
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// check if file doesn't exist
|
// check if file doesn't exist
|
||||||
if ((e as NodeJS.ErrnoException).code === 'ENOENT') {
|
if ((e as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
continue;
|
return null;
|
||||||
}
|
}
|
||||||
logger.warn({ error: e }, 'error while reading video file');
|
logger.warn({ error: e }, 'error while reading video file');
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VideoMetadata {
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
longDescription: string | null;
|
||||||
|
}
|
||||||
|
interface FFprobeResult {
|
||||||
|
streams?: {
|
||||||
|
codec_type?: string;
|
||||||
|
tags?: Record<string, string>;
|
||||||
|
}[];
|
||||||
|
format?: {
|
||||||
|
tags?: Record<string, string>;
|
||||||
|
};
|
||||||
|
}
|
||||||
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);
|
||||||
@@ -108,7 +87,7 @@ export async function getVideoMetadata(video: ResolvedVideo): Promise<VideoMetad
|
|||||||
// prettier-ignore
|
// prettier-ignore
|
||||||
[
|
[
|
||||||
'-v', 'error',
|
'-v', 'error',
|
||||||
'-show_entries', 'format_tags=title,description,synopsis:stream=codec_type:stream_tags=title,description,synopsis',
|
'-show_entries', 'format_tags=title,description,synopsis,longdescription:stream=codec_type:stream_tags=title,description,synopsis,longdescription',
|
||||||
'-of', 'json',
|
'-of', 'json',
|
||||||
video.path
|
video.path
|
||||||
],
|
],
|
||||||
@@ -121,7 +100,8 @@ export async function getVideoMetadata(video: ResolvedVideo): Promise<VideoMetad
|
|||||||
|
|
||||||
metadata = {
|
metadata = {
|
||||||
title: tags['title'] || video.name,
|
title: tags['title'] || video.name,
|
||||||
description: (tags['synopsis'] || tags['longdescription'] || tags['description']) ?? null
|
description: tags['description'],
|
||||||
|
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');
|
||||||
@@ -137,6 +117,10 @@ export async function getVideoMetadata(video: ResolvedVideo): Promise<VideoMetad
|
|||||||
return metadata;
|
return metadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VideoThumbnail {
|
||||||
|
path: string;
|
||||||
|
contentType: string;
|
||||||
|
}
|
||||||
export async function getVideoThumbnail(video: ResolvedVideo): Promise<VideoThumbnail | null> {
|
export async function getVideoThumbnail(video: ResolvedVideo): Promise<VideoThumbnail | null> {
|
||||||
if (!VIDEO_PATH) return null;
|
if (!VIDEO_PATH) return null;
|
||||||
|
|
||||||
@@ -184,6 +168,10 @@ export async function getVideoThumbnail(video: ResolvedVideo): Promise<VideoThum
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VideoRange {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
export function parseVideoRange(rangeHeader: string | null, size: number): VideoRange | 'invalid' | null {
|
export function parseVideoRange(rangeHeader: string | null, size: number): VideoRange | 'invalid' | null {
|
||||||
if (!rangeHeader) return null;
|
if (!rangeHeader) return null;
|
||||||
if (size <= 0) return 'invalid';
|
if (size <= 0) return 'invalid';
|
||||||
|
|||||||
Reference in new issue
Block a user