1 parent
0fc0024cb6
commit
73d4f6af42
9 files changed
+678
-2
No files matched your search
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import '@videojs/html/video/player';
|
||||
import '@videojs/html/video/skin';
|
||||
import '@videojs/html/i18n';
|
||||
import '@videojs/html/i18n/locales/de/register';
|
||||
|
||||
interface Props {
|
||||
src: string;
|
||||
type?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
let { src, type, title }: Props = $props();
|
||||
</script>
|
||||
|
||||
<media-i18n lang="de">
|
||||
<video-player content-title={title}>
|
||||
<video-skin>
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video {src} {type} playsinline preload="metadata">
|
||||
<a href={src}>Video kann nicht abgespielt werden</a>
|
||||
</video>
|
||||
</video-skin>
|
||||
</video-player>
|
||||
</media-i18n>
|
||||
|
||||
<style>
|
||||
media-i18n {
|
||||
display: block;
|
||||
}
|
||||
|
||||
video-player {
|
||||
--media-accent-color: var(--color-primary);
|
||||
--media-border-radius: var(--radius-box);
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
video-skin {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
import WebsiteLayout from '@layouts/website/WebsiteLayout.astro';
|
||||
import VideoPlayer from '@app/website/video/VideoPlayer.svelte';
|
||||
import { getVideoMetadata, resolveVideo } from '@util/video.ts';
|
||||
|
||||
const { name } = Astro.params;
|
||||
|
||||
const video = name ? await resolveVideo(name) : null;
|
||||
if (!video) return new Response(null, { status: 404 });
|
||||
|
||||
const metadata = await getVideoMetadata(video);
|
||||
if (!metadata) return new Response(null, { status: 404 });
|
||||
|
||||
const { title, description } = metadata;
|
||||
const basePath = import.meta.env.BASE_URL.replace(/\/$/, '');
|
||||
const streamUrl = `${basePath}/video/${encodeURIComponent(video.name)}/stream`;
|
||||
---
|
||||
|
||||
<WebsiteLayout title={`${title} – Video`}>
|
||||
<section class="mx-auto flex w-full max-w-6xl flex-col gap-5 px-4 py-8 lg:py-12">
|
||||
<div>
|
||||
<p class="text-sm tracking-widest text-base-content/60 uppercase">Video</p>
|
||||
<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} />
|
||||
|
||||
{description && (
|
||||
<p class="max-w-3xl text-base leading-relaxed whitespace-pre-line text-base-content/70">{description}</p>
|
||||
)}
|
||||
</section>
|
||||
</WebsiteLayout>
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { APIContext, APIRoute } from 'astro';
|
||||
import { createVideoResponse, 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 });
|
||||
|
||||
return createVideoResponse(video, context.request, headersOnly);
|
||||
}
|
||||
|
||||
export const HEAD: APIRoute = (context) => handle(context, true);
|
||||
export const GET: APIRoute = (context) => handle(context);
|
||||
@@ -0,0 +1,185 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { parseFile } from 'music-metadata';
|
||||
import { VIDEO_PATH } from 'astro:env/server';
|
||||
import { logger } from './log';
|
||||
|
||||
const videoTypes = [
|
||||
['mp4', 'video/mp4'],
|
||||
['webm', 'video/webm']
|
||||
];
|
||||
|
||||
export interface ResolvedVideo {
|
||||
name: string;
|
||||
filename: string;
|
||||
path: string;
|
||||
contentType: string;
|
||||
size: number;
|
||||
modifiedAt: Date;
|
||||
}
|
||||
|
||||
export interface VideoRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface VideoMetadata {
|
||||
title: string;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
interface VideoMetadataCacheEntry {
|
||||
modifiedAtMs: number;
|
||||
size: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
const videoMetadataCache = new Map<string, VideoMetadataCacheEntry>();
|
||||
|
||||
export async function resolveVideo(name: string): Promise<ResolvedVideo | null> {
|
||||
if (!VIDEO_PATH || !name) return null;
|
||||
|
||||
const directory = path.resolve(VIDEO_PATH);
|
||||
const safeName = path.basename(name);
|
||||
|
||||
if (!safeName) return null;
|
||||
|
||||
for (const [extension, contentType] of videoTypes) {
|
||||
const filename = `${safeName}.${extension}`;
|
||||
const filePath = path.join(directory, filename);
|
||||
|
||||
try {
|
||||
const stats = await fs.promises.stat(filePath);
|
||||
if (!stats.isFile()) continue;
|
||||
|
||||
return {
|
||||
name: safeName,
|
||||
filename,
|
||||
path: filePath,
|
||||
contentType,
|
||||
size: stats.size,
|
||||
modifiedAt: stats.mtime
|
||||
};
|
||||
} catch (e: NodeJS.ErrnoException) {
|
||||
// check if file doesn't exist
|
||||
if (e.code === 'ENOENT') {
|
||||
continue;
|
||||
}
|
||||
logger.warn(
|
||||
{
|
||||
error: e.message
|
||||
},
|
||||
'error while reading video file'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getVideoMetadata(video: ResolvedVideo): Promise<VideoMetadata | null> {
|
||||
const modifiedAtMs = video.modifiedAt.getTime();
|
||||
const cached = videoMetadataCache.get(video.path);
|
||||
|
||||
if (cached && cached.modifiedAtMs === modifiedAtMs && cached.size === video.size) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
let metadata;
|
||||
try {
|
||||
const parsed = await parseFile(video.path, {
|
||||
duration: false,
|
||||
skipCovers: true
|
||||
});
|
||||
|
||||
metadata = {
|
||||
title: parsed.common.title?.trim() || video.name,
|
||||
description:
|
||||
parsed.common.longDescription?.trim() ||
|
||||
parsed.common.description?.map((value) => value.trim()).find(Boolean) ||
|
||||
null
|
||||
};
|
||||
} catch {
|
||||
// couldn't read metadata
|
||||
return null;
|
||||
}
|
||||
|
||||
videoMetadataCache.set(video.path, {
|
||||
modifiedAtMs,
|
||||
size: video.size,
|
||||
...metadata
|
||||
});
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export function parseVideoRange(rangeHeader: string | null, size: number): VideoRange | 'invalid' | null {
|
||||
if (!rangeHeader) return null;
|
||||
if (size <= 0) return 'invalid';
|
||||
|
||||
const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim());
|
||||
if (!match) return 'invalid';
|
||||
|
||||
const [, startValue, endValue] = match;
|
||||
if (!startValue && !endValue) return 'invalid';
|
||||
|
||||
let start: number;
|
||||
let end: number;
|
||||
|
||||
if (!startValue) {
|
||||
const suffixLength = Number(endValue);
|
||||
if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0) return 'invalid';
|
||||
|
||||
start = Math.max(size - suffixLength, 0);
|
||||
end = size - 1;
|
||||
} else {
|
||||
start = Number(startValue);
|
||||
if (!Number.isSafeInteger(start) || start < 0 || start >= size) return 'invalid';
|
||||
|
||||
end = endValue ? Number(endValue) : size - 1;
|
||||
if (!Number.isSafeInteger(end) || end < start) return 'invalid';
|
||||
|
||||
end = Math.min(end, size - 1);
|
||||
}
|
||||
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
export function createVideoResponse(video: ResolvedVideo, request: Request, headersOnly = false): Response {
|
||||
const range = parseVideoRange(request.headers.get('range'), video.size);
|
||||
|
||||
if (range === 'invalid') {
|
||||
return new Response(null, {
|
||||
status: 416,
|
||||
headers: {
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Range': `bytes */${video.size}`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const start = range?.start ?? 0;
|
||||
const end = range?.end ?? video.size - 1;
|
||||
const contentLength = video.size === 0 ? 0 : end - start + 1;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': contentLength.toString(),
|
||||
'Content-Type': video.contentType,
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
'Last-Modified': video.modifiedAt.toUTCString()
|
||||
};
|
||||
|
||||
if (range) {
|
||||
headers['Content-Range'] = `bytes ${start}-${end}/${video.size}`;
|
||||
}
|
||||
|
||||
if (headersOnly) {
|
||||
return new Response(null, { status: range ? 206 : 200, headers });
|
||||
}
|
||||
|
||||
const stream = fs.createReadStream(video.path, video.size === 0 ? undefined : { start, end });
|
||||
|
||||
return new Response(stream as any, { status: range ? 206 : 200, headers });
|
||||
}
|
||||
Reference in new issue
Block a user