videos #17
@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
// src/components/Video.astro - SIMPLE wrapper component
|
// src/components/Video.astro - SIMPLE responsive video component
|
||||||
export interface Props {
|
export interface Props {
|
||||||
src: string;
|
src: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
@ -8,6 +8,7 @@ export interface Props {
|
|||||||
muted?: boolean;
|
muted?: boolean;
|
||||||
loop?: boolean;
|
loop?: boolean;
|
||||||
aspectRatio?: '16:9' | '4:3' | '1:1';
|
aspectRatio?: '16:9' | '4:3' | '1:1';
|
||||||
|
preload?: 'none' | 'metadata' | 'auto';
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -17,17 +18,21 @@ const {
|
|||||||
autoplay = false,
|
autoplay = false,
|
||||||
muted = false,
|
muted = false,
|
||||||
loop = false,
|
loop = false,
|
||||||
aspectRatio = '16:9'
|
aspectRatio = '16:9',
|
||||||
|
preload = 'metadata'
|
||||||
} = Astro.props;
|
} = Astro.props;
|
||||||
|
|
||||||
|
const aspectClass = `aspect-${aspectRatio.replace(':', '-')}`;
|
||||||
---
|
---
|
||||||
|
|
||||||
<div class={`video-container aspect-${aspectRatio}`}>
|
<div class={`video-container ${aspectClass}`}>
|
||||||
<video
|
<video
|
||||||
src={src}
|
src={src}
|
||||||
controls={controls}
|
controls={controls}
|
||||||
autoplay={autoplay}
|
autoplay={autoplay}
|
||||||
muted={muted}
|
muted={muted}
|
||||||
loop={loop}
|
loop={loop}
|
||||||
|
preload={preload}
|
||||||
style="width: 100%; height: 100%;"
|
style="width: 100%; height: 100%;"
|
||||||
data-video-title={title}
|
data-video-title={title}
|
||||||
>
|
>
|
||||||
|
@ -17,10 +17,8 @@ sections:
|
|||||||
advanced_topics: false
|
advanced_topics: false
|
||||||
review_status: "published"
|
review_status: "published"
|
||||||
---
|
---
|
||||||
<video src="https://cloud.cc24.dev/s/HdRwZXJ8NL6CT2q/download" controls title="Nextcloud Demo"></video>
|
|
||||||
<video src="https://cloud.cc24.dev/s/HdRwZXJ8NL6CT2q/download" controls title="Training Video"></video>
|
|
||||||
<video src="https://cloud.cc24.dev/s/HdRwZXJ8NL6CT2q/download" controls></video>
|
|
||||||
|
|
||||||
|
<video src="https://cloud.cc24.dev/s/ZmPK86M86fWyGQk" controls title="Training Video"></video>
|
||||||
> **⚠️ Hinweis**: Dies ist ein vorläufiger, KI-generierter Knowledgebase-Eintrag. Wir freuen uns über Verbesserungen und Ergänzungen durch die Community!
|
> **⚠️ Hinweis**: Dies ist ein vorläufiger, KI-generierter Knowledgebase-Eintrag. Wir freuen uns über Verbesserungen und Ergänzungen durch die Community!
|
||||||
|
|
||||||
|
|
||||||
|
@ -18,6 +18,8 @@ sections:
|
|||||||
advanced_topics: true
|
advanced_topics: true
|
||||||
review_status: "published"
|
review_status: "published"
|
||||||
---
|
---
|
||||||
|
<video src="https://cloud.cc24.dev/s/ZmPK86M86fWyGQk" controls title="Training Video"></video>
|
||||||
|
|
||||||
|
|
||||||
> **⚠️ Hinweis**: Dies ist ein vorläufiger, KI-generierter Knowledgebase-Eintrag. Wir freuen uns über Verbesserungen und Ergänzungen durch die Community!
|
> **⚠️ Hinweis**: Dies ist ein vorläufiger, KI-generierter Knowledgebase-Eintrag. Wir freuen uns über Verbesserungen und Ergänzungen durch die Community!
|
||||||
|
|
||||||
|
@ -352,6 +352,37 @@ const { title, description = 'ForensicPathways - A comprehensive directory of di
|
|||||||
};
|
};
|
||||||
initAIButton();
|
initAIButton();
|
||||||
});
|
});
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const isFirefox = navigator.userAgent.toLowerCase().includes('firefox') ||
|
||||||
|
navigator.userAgent.toLowerCase().includes('librewolf');
|
||||||
|
|
||||||
|
if (isFirefox) {
|
||||||
|
console.log('[VIDEO] Firefox detected - setting up error recovery');
|
||||||
|
|
||||||
|
document.querySelectorAll('video').forEach(video => {
|
||||||
|
let errorCount = 0;
|
||||||
|
|
||||||
|
video.addEventListener('error', () => {
|
||||||
|
errorCount++;
|
||||||
|
console.log(`[VIDEO] Error ${errorCount} in Firefox for: ${video.getAttribute('data-video-title')}`);
|
||||||
|
|
||||||
|
// Only try once to avoid infinite loops
|
||||||
|
if (errorCount === 1 && video.src.includes('/download')) {
|
||||||
|
console.log('[VIDEO] Trying /preview URL for Firefox compatibility');
|
||||||
|
video.src = video.src.replace('/download', '/preview');
|
||||||
|
video.load();
|
||||||
|
} else if (errorCount === 1) {
|
||||||
|
console.log('[VIDEO] Video failed to load in Firefox');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
video.addEventListener('loadedmetadata', () => {
|
||||||
|
const title = video.getAttribute('data-video-title') || 'Video';
|
||||||
|
console.log(`[VIDEO] Successfully loaded: ${title}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
@ -1,142 +0,0 @@
|
|||||||
// src/pages/api/video/cached/[...path].ts - Production video serving only
|
|
||||||
import type { APIRoute } from 'astro';
|
|
||||||
import { promises as fs } from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
export const GET: APIRoute = async ({ params, request }) => {
|
|
||||||
try {
|
|
||||||
const videoPath = params.path;
|
|
||||||
|
|
||||||
console.log(`[VIDEO SERVE] Request for cached video: ${videoPath}`);
|
|
||||||
|
|
||||||
if (!videoPath || typeof videoPath !== 'string') {
|
|
||||||
console.warn('[VIDEO SERVE] Invalid video path provided');
|
|
||||||
return new Response('Video not found', { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Security: Prevent path traversal
|
|
||||||
const safePath = path.normalize(videoPath).replace(/^(\.\.[\/\\])+/, '');
|
|
||||||
const cacheDir = process.env.VIDEO_CACHE_DIR || './cache/videos';
|
|
||||||
const fullPath = path.join(cacheDir, safePath);
|
|
||||||
|
|
||||||
console.log(`[VIDEO SERVE] Resolved cache path: ${fullPath}`);
|
|
||||||
|
|
||||||
// Ensure the requested file is within the cache directory
|
|
||||||
if (!fullPath.startsWith(path.resolve(cacheDir))) {
|
|
||||||
console.error(`[VIDEO SERVE] Path traversal attempt blocked: ${fullPath}`);
|
|
||||||
return new Response('Access denied', { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const stat = await fs.stat(fullPath);
|
|
||||||
|
|
||||||
if (!stat.isFile()) {
|
|
||||||
console.warn(`[VIDEO SERVE] Requested path is not a file: ${fullPath}`);
|
|
||||||
return new Response('Video not found', { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[VIDEO SERVE] Serving cached video: ${safePath} (${Math.round(stat.size / 1024 / 1024)}MB)`);
|
|
||||||
|
|
||||||
// Update access time for LRU tracking (for emergency cleanup)
|
|
||||||
const now = new Date();
|
|
||||||
await fs.utimes(fullPath, now, stat.mtime).catch((err) => {
|
|
||||||
console.warn(`[VIDEO SERVE] Failed to update access time for ${safePath}: ${err.message}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Determine content type
|
|
||||||
const ext = path.extname(fullPath).toLowerCase();
|
|
||||||
const contentType = getVideoMimeType(ext);
|
|
||||||
|
|
||||||
console.log(`[VIDEO SERVE] Content type: ${contentType}, File size: ${stat.size} bytes`);
|
|
||||||
|
|
||||||
// Handle range requests for video streaming
|
|
||||||
const range = request.headers.get('range');
|
|
||||||
const fileSize = stat.size;
|
|
||||||
|
|
||||||
if (range) {
|
|
||||||
console.log(`[VIDEO SERVE] Range request: ${range}`);
|
|
||||||
|
|
||||||
// Parse range header
|
|
||||||
const rangeMatch = range.match(/bytes=(\d+)-(\d*)/);
|
|
||||||
if (rangeMatch) {
|
|
||||||
const start = parseInt(rangeMatch[1]);
|
|
||||||
const end = rangeMatch[2] ? parseInt(rangeMatch[2]) : fileSize - 1;
|
|
||||||
const chunkSize = end - start + 1;
|
|
||||||
|
|
||||||
console.log(`[VIDEO SERVE] Range: ${start}-${end}, chunk size: ${chunkSize}`);
|
|
||||||
|
|
||||||
if (start >= fileSize || end >= fileSize || start > end) {
|
|
||||||
console.warn(`[VIDEO SERVE] Invalid range: ${start}-${end} for file size ${fileSize}`);
|
|
||||||
return new Response('Range not satisfiable', {
|
|
||||||
status: 416,
|
|
||||||
headers: {
|
|
||||||
'Content-Range': `bytes */${fileSize}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileStream = await fs.readFile(fullPath);
|
|
||||||
const chunk = fileStream.slice(start, end + 1);
|
|
||||||
|
|
||||||
console.log(`[VIDEO SERVE] Serving partial content: ${chunk.length} bytes`);
|
|
||||||
|
|
||||||
return new Response(chunk, {
|
|
||||||
status: 206,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': contentType,
|
|
||||||
'Content-Length': chunkSize.toString(),
|
|
||||||
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
|
|
||||||
'Accept-Ranges': 'bytes',
|
|
||||||
'Cache-Control': 'public, max-age=31536000', // Cache for 1 year
|
|
||||||
'Last-Modified': stat.mtime.toUTCString()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Serve entire file
|
|
||||||
console.log(`[VIDEO SERVE] Serving complete file: ${safePath}`);
|
|
||||||
const fileBuffer = await fs.readFile(fullPath);
|
|
||||||
|
|
||||||
return new Response(fileBuffer, {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': contentType,
|
|
||||||
'Content-Length': fileSize.toString(),
|
|
||||||
'Accept-Ranges': 'bytes',
|
|
||||||
'Cache-Control': 'public, max-age=31536000', // Cache for 1 year
|
|
||||||
'Last-Modified': stat.mtime.toUTCString()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
if (error.code === 'ENOENT') {
|
|
||||||
console.warn(`[VIDEO SERVE] File not found: ${fullPath}`);
|
|
||||||
return new Response('Video not found', { status: 404 });
|
|
||||||
}
|
|
||||||
console.error(`[VIDEO SERVE] File system error for ${fullPath}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[VIDEO SERVE] Unexpected error serving cached video:', error);
|
|
||||||
return new Response('Internal server error', { status: 500 });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function getVideoMimeType(extension: string): string {
|
|
||||||
const mimeTypes: Record<string, string> = {
|
|
||||||
'.mp4': 'video/mp4',
|
|
||||||
'.webm': 'video/webm',
|
|
||||||
'.ogg': 'video/ogg',
|
|
||||||
'.mov': 'video/quicktime',
|
|
||||||
'.avi': 'video/x-msvideo',
|
|
||||||
'.m4v': 'video/x-m4v',
|
|
||||||
'.3gp': 'video/3gpp'
|
|
||||||
};
|
|
||||||
|
|
||||||
const mimeType = mimeTypes[extension] || 'application/octet-stream';
|
|
||||||
console.log(`[VIDEO SERVE] MIME type for ${extension}: ${mimeType}`);
|
|
||||||
|
|
||||||
return mimeType;
|
|
||||||
}
|
|
@ -1,43 +0,0 @@
|
|||||||
// src/pages/api/video/process.ts
|
|
||||||
import type { APIRoute } from 'astro';
|
|
||||||
import { videoProcessor, type VideoMetadata } from '../../../utils/videoUtils.js';
|
|
||||||
import { apiResponse, apiError, apiServerError } from '../../../utils/api.js';
|
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request }) => {
|
|
||||||
try {
|
|
||||||
const body = await request.json().catch(() => null);
|
|
||||||
|
|
||||||
if (!body) {
|
|
||||||
return apiError.badRequest('Request body must be valid JSON');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { url, metadata = {}, options = {} } = body;
|
|
||||||
|
|
||||||
if (!url || typeof url !== 'string') {
|
|
||||||
return apiError.badRequest('Video URL is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate URL
|
|
||||||
try {
|
|
||||||
new URL(url);
|
|
||||||
} catch {
|
|
||||||
return apiError.badRequest('Invalid video URL format');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[VIDEO API] Processing video: ${url}`);
|
|
||||||
|
|
||||||
const processedVideo = await videoProcessor.processVideoUrl(url, metadata as Partial<VideoMetadata>);
|
|
||||||
const html = videoProcessor.generateVideoHTML(processedVideo, options);
|
|
||||||
|
|
||||||
return apiResponse.success({
|
|
||||||
processedVideo,
|
|
||||||
html,
|
|
||||||
cached: processedVideo.sources.some(s => s.cached),
|
|
||||||
requiresAuth: processedVideo.requiresAuth
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[VIDEO API] Processing error:', error);
|
|
||||||
return apiServerError.internal(`Video processing failed: ${error.message}`);
|
|
||||||
}
|
|
||||||
};
|
|
@ -1,58 +1,95 @@
|
|||||||
// src/utils/remarkVideoPlugin.ts - MINIMAL wrapper only
|
// src/utils/remarkVideoPlugin.ts - Simple, working approach
|
||||||
import { visit } from 'unist-util-visit';
|
import { visit } from 'unist-util-visit';
|
||||||
import type { Plugin } from 'unified';
|
import type { Plugin } from 'unified';
|
||||||
import type { Root } from 'hast';
|
import type { Root } from 'hast';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MINIMAL plugin - just wraps <video> tags in responsive containers
|
* Simple video plugin - just makes videos responsive and adds /download to bare Nextcloud URLs
|
||||||
|
* No CORS complications, no crossorigin attributes
|
||||||
*/
|
*/
|
||||||
export const remarkVideoPlugin: Plugin<[], Root> = () => {
|
export const remarkVideoPlugin: Plugin<[], Root> = () => {
|
||||||
return (tree: Root) => {
|
return (tree: Root) => {
|
||||||
// Find HTML nodes containing <video> tags
|
// Find HTML nodes containing <video> tags
|
||||||
visit(tree, 'html', (node: any, index: number | undefined, parent: any) => {
|
visit(tree, 'html', (node: any, index: number | undefined, parent: any) => {
|
||||||
if (node.value && node.value.includes('<video') && typeof index === 'number') {
|
if (node.value && node.value.includes('<video') && typeof index === 'number') {
|
||||||
|
|
||||||
// Extract video attributes
|
// Extract video attributes
|
||||||
const srcMatch = node.value.match(/src=["']([^"']+)["']/);
|
const srcMatch = node.value.match(/src=["']([^"']+)["']/);
|
||||||
const titleMatch = node.value.match(/title=["']([^"']+)["']/);
|
const titleMatch = node.value.match(/title=["']([^"']+)["']/);
|
||||||
|
|
||||||
if (srcMatch) {
|
if (srcMatch) {
|
||||||
const src = srcMatch[1];
|
const originalSrc = srcMatch[1];
|
||||||
const title = titleMatch?.[1] || 'Video';
|
const title = titleMatch?.[1] || 'Video';
|
||||||
|
|
||||||
// Check for existing attributes
|
// Smart URL processing - add /download to bare Nextcloud URLs
|
||||||
|
const finalSrc = processNextcloudUrl(originalSrc);
|
||||||
|
|
||||||
|
// Check for existing attributes to preserve them
|
||||||
const hasControls = node.value.includes('controls');
|
const hasControls = node.value.includes('controls');
|
||||||
const hasAutoplay = node.value.includes('autoplay');
|
const hasAutoplay = node.value.includes('autoplay');
|
||||||
const hasMuted = node.value.includes('muted');
|
const hasMuted = node.value.includes('muted');
|
||||||
const hasLoop = node.value.includes('loop');
|
const hasLoop = node.value.includes('loop');
|
||||||
|
const hasPreload = node.value.match(/preload=["']([^"']+)["']/);
|
||||||
|
|
||||||
// Create wrapped HTML
|
// Create simple, working video HTML - NO crossorigin attribute
|
||||||
const wrappedHTML = `
|
const enhancedHTML = `
|
||||||
<div class="video-container aspect-16:9">
|
<div class="video-container aspect-16-9">
|
||||||
<video
|
<video
|
||||||
src="${escapeHtml(src)}"
|
src="${escapeHtml(finalSrc)}"
|
||||||
${hasControls ? 'controls' : ''}
|
${hasControls ? 'controls' : ''}
|
||||||
${hasAutoplay ? 'autoplay' : ''}
|
${hasAutoplay ? 'autoplay' : ''}
|
||||||
${hasMuted ? 'muted' : ''}
|
${hasMuted ? 'muted' : ''}
|
||||||
${hasLoop ? 'loop' : ''}
|
${hasLoop ? 'loop' : ''}
|
||||||
|
${hasPreload ? `preload="${hasPreload[1]}"` : 'preload="metadata"'}
|
||||||
style="width: 100%; height: 100%;"
|
style="width: 100%; height: 100%;"
|
||||||
data-video-title="${escapeHtml(title)}"
|
data-video-title="${escapeHtml(title)}"
|
||||||
|
data-original-src="${escapeHtml(originalSrc)}"
|
||||||
>
|
>
|
||||||
<p>Your browser does not support the video element.</p>
|
<p>Your browser does not support the video element.</p>
|
||||||
</video>
|
</video>
|
||||||
<div class="video-metadata">
|
${title !== 'Video' ? `
|
||||||
<div class="video-title">${escapeHtml(title)}</div>
|
<div class="video-metadata">
|
||||||
</div>
|
<div class="video-title">${escapeHtml(title)}</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
`.trim();
|
`.trim();
|
||||||
|
|
||||||
// Replace the node
|
// Replace the node
|
||||||
parent.children[index] = { type: 'html', value: wrappedHTML };
|
parent.children[index] = { type: 'html', value: enhancedHTML };
|
||||||
|
|
||||||
|
console.log(`[VIDEO] Processed: ${title}`);
|
||||||
|
console.log(`[VIDEO] Final URL: ${finalSrc}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple URL processing - just add /download to bare Nextcloud URLs if needed
|
||||||
|
*/
|
||||||
|
function processNextcloudUrl(originalUrl: string): string {
|
||||||
|
// If it's a bare Nextcloud share URL, add /download
|
||||||
|
if (isNextcloudShareUrl(originalUrl) && !originalUrl.includes('/download')) {
|
||||||
|
const downloadUrl = `${originalUrl}/download`;
|
||||||
|
console.log(`[VIDEO] Auto-added /download: ${originalUrl} → ${downloadUrl}`);
|
||||||
|
return downloadUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, use the URL as-is
|
||||||
|
return originalUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if URL is a Nextcloud share URL (bare or with /download)
|
||||||
|
* Format: https://cloud.cc24.dev/s/TOKEN
|
||||||
|
*/
|
||||||
|
function isNextcloudShareUrl(url: string): boolean {
|
||||||
|
const pattern = /\/s\/[a-zA-Z0-9]+/;
|
||||||
|
return pattern.test(url) && (url.includes('nextcloud') || url.includes('cloud.'));
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(unsafe: string): string {
|
function escapeHtml(unsafe: string): string {
|
||||||
if (typeof unsafe !== 'string') return '';
|
if (typeof unsafe !== 'string') return '';
|
||||||
|
|
||||||
|
@ -1,449 +1,134 @@
|
|||||||
// src/utils/videoUtils.ts - NEXTCLOUD ONLY
|
// src/utils/videoUtils.ts - SIMPLIFIED - Basic utilities only
|
||||||
import { NextcloudUploader } from './nextcloud.js';
|
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
|
|
||||||
export interface VideoSource {
|
/**
|
||||||
type: 'nextcloud' | 'cdn' | 'local';
|
* Simple video utilities for basic video support
|
||||||
url: string;
|
* No caching, no complex processing, no authentication
|
||||||
originalUrl?: string;
|
*/
|
||||||
cached?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VideoMetadata {
|
export interface SimpleVideoMetadata {
|
||||||
title?: string;
|
title?: string;
|
||||||
duration?: number;
|
|
||||||
format?: string;
|
|
||||||
size?: number;
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
poster?: string;
|
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProcessedVideo {
|
/**
|
||||||
sources: VideoSource[];
|
* Get video MIME type from file extension
|
||||||
metadata: VideoMetadata;
|
*/
|
||||||
fallbackText: string;
|
export function getVideoMimeType(url: string): string {
|
||||||
requiresAuth: boolean;
|
let extension: string | undefined;
|
||||||
|
try {
|
||||||
|
const pathname = new URL(url).pathname;
|
||||||
|
extension = pathname.split('.').pop()?.toLowerCase();
|
||||||
|
} catch {
|
||||||
|
extension = url.split('?')[0].split('.').pop()?.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
const mimeTypes: Record<string, string> = {
|
||||||
|
mp4: 'video/mp4',
|
||||||
|
webm: 'video/webm',
|
||||||
|
ogg: 'video/ogg',
|
||||||
|
mov: 'video/quicktime',
|
||||||
|
avi: 'video/x-msvideo',
|
||||||
|
m4v: 'video/m4v',
|
||||||
|
mkv: 'video/x-matroska',
|
||||||
|
flv: 'video/x-flv'
|
||||||
|
};
|
||||||
|
|
||||||
|
return (extension && mimeTypes[extension]) || 'video/mp4';
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VideoConfig {
|
/**
|
||||||
enableCaching: boolean;
|
* Format duration in MM:SS or HH:MM:SS format
|
||||||
cacheDirectory: string;
|
*/
|
||||||
maxCacheSize: number;
|
export function formatDuration(seconds: number): string {
|
||||||
supportedFormats: string[];
|
const hours = Math.floor(seconds / 3600);
|
||||||
maxFileSize: number;
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
const remainingSeconds = Math.floor(seconds % 60);
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class VideoProcessor {
|
/**
|
||||||
private config: VideoConfig;
|
* Format file size in human readable format
|
||||||
private nextcloudUploader: NextcloudUploader;
|
*/
|
||||||
|
export function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
constructor() {
|
/**
|
||||||
this.config = {
|
* Escape HTML for safe output
|
||||||
enableCaching: process.env.VIDEO_CACHE_ENABLED === 'true',
|
*/
|
||||||
cacheDirectory: process.env.VIDEO_CACHE_DIR || './cache/videos',
|
export function escapeHtml(unsafe: string): string {
|
||||||
maxCacheSize: parseInt(process.env.VIDEO_CACHE_MAX_SIZE || '2000'),
|
if (typeof unsafe !== 'string') return '';
|
||||||
supportedFormats: ['mp4', 'webm', 'ogg', 'mov', 'avi'],
|
|
||||||
maxFileSize: parseInt(process.env.VIDEO_MAX_SIZE || '200')
|
|
||||||
};
|
|
||||||
|
|
||||||
this.nextcloudUploader = new NextcloudUploader();
|
return unsafe
|
||||||
console.log('[VIDEO] Nextcloud-only video processor initialized');
|
.replace(/&/g, "&")
|
||||||
}
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
async processVideoUrl(url: string, metadata: Partial<VideoMetadata> = {}): Promise<ProcessedVideo> {
|
/**
|
||||||
console.log(`[VIDEO] Processing: ${url}`);
|
* Generate basic responsive video HTML
|
||||||
|
*/
|
||||||
try {
|
export function generateVideoHTML(
|
||||||
const videoSource = this.identifyVideoSource(url);
|
src: string,
|
||||||
console.log(`[VIDEO] Source type: ${videoSource.type}`);
|
options: {
|
||||||
|
title?: string;
|
||||||
const sources: VideoSource[] = [];
|
|
||||||
|
|
||||||
switch (videoSource.type) {
|
|
||||||
case 'nextcloud':
|
|
||||||
sources.push(...await this.processNextcloudVideo(url));
|
|
||||||
break;
|
|
||||||
case 'cdn':
|
|
||||||
sources.push(await this.processCdnVideo(url));
|
|
||||||
break;
|
|
||||||
case 'local':
|
|
||||||
sources.push(await this.processLocalVideo(url));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const enhancedMetadata = await this.enhanceMetadata(sources[0], metadata);
|
|
||||||
|
|
||||||
return {
|
|
||||||
sources,
|
|
||||||
metadata: enhancedMetadata,
|
|
||||||
fallbackText: this.generateFallbackText(enhancedMetadata),
|
|
||||||
requiresAuth: this.requiresAuthentication(videoSource.type)
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[VIDEO] Processing failed: ${error.message}`);
|
|
||||||
return {
|
|
||||||
sources: [],
|
|
||||||
metadata: { ...metadata, title: metadata.title || 'Video unavailable' },
|
|
||||||
fallbackText: `Video could not be loaded: ${error.message}`,
|
|
||||||
requiresAuth: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private identifyVideoSource(url: string): { type: VideoSource['type']; url: string } {
|
|
||||||
console.log(`[VIDEO] Identifying source for: ${url}`);
|
|
||||||
|
|
||||||
// Check for Nextcloud patterns
|
|
||||||
if (url.includes('/s/') || url.includes('nextcloud') || url.includes('owncloud')) {
|
|
||||||
console.log(`[VIDEO] Detected Nextcloud URL`);
|
|
||||||
return { type: 'nextcloud', url };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Local files
|
|
||||||
if (url.startsWith('/') && !url.startsWith('http')) {
|
|
||||||
console.log(`[VIDEO] Detected local file`);
|
|
||||||
return { type: 'local', url };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Everything else is a CDN
|
|
||||||
console.log(`[VIDEO] Detected CDN URL`);
|
|
||||||
return { type: 'cdn', url };
|
|
||||||
}
|
|
||||||
|
|
||||||
private async processNextcloudVideo(url: string): Promise<VideoSource[]> {
|
|
||||||
console.log(`[VIDEO] Processing Nextcloud: ${url}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const shareMatch = url.match(/\/s\/([^\/\?]+)/);
|
|
||||||
if (!shareMatch) {
|
|
||||||
throw new Error('Invalid Nextcloud share URL format');
|
|
||||||
}
|
|
||||||
|
|
||||||
const shareToken = shareMatch[1];
|
|
||||||
const directUrl = await this.getNextcloudDirectUrl(url, shareToken);
|
|
||||||
const sources: VideoSource[] = [];
|
|
||||||
|
|
||||||
// Try caching if enabled
|
|
||||||
if (this.config.enableCaching && directUrl) {
|
|
||||||
const cachedSource = await this.cacheVideo(directUrl, shareToken);
|
|
||||||
if (cachedSource) {
|
|
||||||
sources.push(cachedSource);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always include direct source as fallback
|
|
||||||
sources.push({
|
|
||||||
type: 'nextcloud',
|
|
||||||
url: directUrl || url,
|
|
||||||
originalUrl: url,
|
|
||||||
cached: false
|
|
||||||
});
|
|
||||||
|
|
||||||
return sources;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[VIDEO] Nextcloud processing failed:', error);
|
|
||||||
return [{
|
|
||||||
type: 'nextcloud',
|
|
||||||
url: url,
|
|
||||||
cached: false
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getNextcloudDirectUrl(shareUrl: string, shareToken: string): Promise<string | null> {
|
|
||||||
try {
|
|
||||||
const baseUrl = shareUrl.split('/s/')[0];
|
|
||||||
const directUrl = `${baseUrl}/s/${shareToken}/download`;
|
|
||||||
|
|
||||||
const response = await fetch(directUrl, { method: 'HEAD' });
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
return directUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[VIDEO] Direct URL extraction failed:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async processCdnVideo(url: string): Promise<VideoSource> {
|
|
||||||
console.log(`[VIDEO] Processing CDN: ${url}`);
|
|
||||||
return {
|
|
||||||
type: 'cdn',
|
|
||||||
url,
|
|
||||||
cached: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async processLocalVideo(url: string): Promise<VideoSource> {
|
|
||||||
return {
|
|
||||||
type: 'local',
|
|
||||||
url,
|
|
||||||
cached: true
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async cacheVideo(sourceUrl: string, identifier: string): Promise<VideoSource | null> {
|
|
||||||
if (!this.config.enableCaching) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const fs = await import('fs/promises');
|
|
||||||
const path = await import('path');
|
|
||||||
const crypto = await import('crypto');
|
|
||||||
|
|
||||||
const urlHash = crypto.createHash('sha256').update(sourceUrl).digest('hex').substring(0, 16);
|
|
||||||
const extension = path.extname(new URL(sourceUrl).pathname) || '.mp4';
|
|
||||||
const cacheFilename = `${identifier}_${urlHash}${extension}`;
|
|
||||||
const cachePath = path.join(this.config.cacheDirectory, cacheFilename);
|
|
||||||
|
|
||||||
// Check if already cached
|
|
||||||
try {
|
|
||||||
const stat = await fs.stat(cachePath);
|
|
||||||
console.log(`[VIDEO] Using cached: ${cacheFilename}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'local',
|
|
||||||
url: `/api/video/cached/${cacheFilename}`,
|
|
||||||
originalUrl: sourceUrl,
|
|
||||||
cached: true
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
// File doesn't exist, proceed with download
|
|
||||||
}
|
|
||||||
|
|
||||||
await fs.mkdir(this.config.cacheDirectory, { recursive: true });
|
|
||||||
|
|
||||||
const response = await fetch(sourceUrl);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentLength = parseInt(response.headers.get('content-length') || '0');
|
|
||||||
|
|
||||||
if (contentLength > this.config.maxFileSize * 1024 * 1024) {
|
|
||||||
console.warn(`[VIDEO] File too large for caching: ${Math.round(contentLength / 1024 / 1024)}MB`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const buffer = await response.arrayBuffer();
|
|
||||||
await fs.writeFile(cachePath, new Uint8Array(buffer));
|
|
||||||
|
|
||||||
console.log(`[VIDEO] Cached: ${cacheFilename}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'local',
|
|
||||||
url: `/api/video/cached/${cacheFilename}`,
|
|
||||||
originalUrl: sourceUrl,
|
|
||||||
cached: true
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[VIDEO] Caching failed: ${error.message}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async enhanceMetadata(source: VideoSource, providedMetadata: Partial<VideoMetadata>): Promise<VideoMetadata> {
|
|
||||||
const metadata: VideoMetadata = { ...providedMetadata };
|
|
||||||
|
|
||||||
if (source.type === 'local' && source.cached) {
|
|
||||||
try {
|
|
||||||
const path = await import('path');
|
|
||||||
const ext = path.extname(source.url).toLowerCase().replace('.', '');
|
|
||||||
|
|
||||||
if (!metadata.format) {
|
|
||||||
metadata.format = ext;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!metadata.title) {
|
|
||||||
metadata.title = path.basename(source.url, path.extname(source.url));
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[VIDEO] Metadata extraction failed:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!metadata.title) {
|
|
||||||
metadata.title = 'Embedded Video';
|
|
||||||
}
|
|
||||||
|
|
||||||
return metadata;
|
|
||||||
}
|
|
||||||
|
|
||||||
private generateFallbackText(metadata: VideoMetadata): string {
|
|
||||||
let fallback = `[Video: ${metadata.title}]`;
|
|
||||||
|
|
||||||
if (metadata.description) {
|
|
||||||
fallback += ` - ${metadata.description}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (metadata.duration) {
|
|
||||||
fallback += ` (${Math.floor(metadata.duration / 60)}:${(metadata.duration % 60).toString().padStart(2, '0')})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
private requiresAuthentication(sourceType: VideoSource['type']): boolean {
|
|
||||||
return sourceType === 'nextcloud';
|
|
||||||
}
|
|
||||||
|
|
||||||
generateVideoHTML(processedVideo: ProcessedVideo, options: {
|
|
||||||
controls?: boolean;
|
controls?: boolean;
|
||||||
autoplay?: boolean;
|
autoplay?: boolean;
|
||||||
muted?: boolean;
|
muted?: boolean;
|
||||||
loop?: boolean;
|
loop?: boolean;
|
||||||
preload?: 'none' | 'metadata' | 'auto';
|
preload?: 'none' | 'metadata' | 'auto';
|
||||||
width?: string;
|
|
||||||
height?: string;
|
|
||||||
aspectRatio?: '16:9' | '4:3' | '1:1';
|
aspectRatio?: '16:9' | '4:3' | '1:1';
|
||||||
showMetadata?: boolean;
|
showMetadata?: boolean;
|
||||||
} = {}): string {
|
} = {}
|
||||||
|
): string {
|
||||||
|
const {
|
||||||
|
title = 'Video',
|
||||||
|
controls = true,
|
||||||
|
autoplay = false,
|
||||||
|
muted = false,
|
||||||
|
loop = false,
|
||||||
|
preload = 'metadata',
|
||||||
|
aspectRatio = '16:9',
|
||||||
|
showMetadata = true
|
||||||
|
} = options;
|
||||||
|
|
||||||
const {
|
const aspectClass = `aspect-${aspectRatio.replace(':', '-')}`;
|
||||||
controls = true,
|
const videoAttributes = [
|
||||||
autoplay = false,
|
controls ? 'controls' : '',
|
||||||
muted = false,
|
autoplay ? 'autoplay' : '',
|
||||||
loop = false,
|
muted ? 'muted' : '',
|
||||||
preload = 'metadata',
|
loop ? 'loop' : '',
|
||||||
aspectRatio = '16:9',
|
`preload="${preload}"`
|
||||||
showMetadata = true
|
].filter(Boolean).join(' ');
|
||||||
} = options;
|
|
||||||
|
|
||||||
if (processedVideo.sources.length === 0) {
|
const metadataHTML = showMetadata && title !== 'Video' ? `
|
||||||
return `
|
<div class="video-metadata">
|
||||||
<div class="video-container aspect-${aspectRatio}">
|
<div class="video-title">${escapeHtml(title)}</div>
|
||||||
<div class="video-error">
|
</div>
|
||||||
<div class="error-icon">⚠️</div>
|
` : '';
|
||||||
<div>${processedVideo.fallbackText}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const primarySource = processedVideo.sources[0];
|
return `
|
||||||
const { metadata } = processedVideo;
|
<div class="video-container ${aspectClass}">
|
||||||
|
<video
|
||||||
// Simple video attributes - no crossorigin complications
|
src="${escapeHtml(src)}"
|
||||||
const videoAttributes = [
|
${videoAttributes}
|
||||||
controls ? 'controls' : '',
|
style="width: 100%; height: 100%;"
|
||||||
autoplay ? 'autoplay' : '',
|
data-video-title="${escapeHtml(title)}"
|
||||||
muted ? 'muted' : '',
|
>
|
||||||
loop ? 'loop' : '',
|
<p>Your browser does not support the video element.</p>
|
||||||
`preload="${preload}"`,
|
</video>
|
||||||
metadata.poster ? `poster="${this.escapeHtml(metadata.poster)}"` : '',
|
${metadataHTML}
|
||||||
`data-video-title="${this.escapeHtml(metadata.title || 'Embedded Video')}"`
|
</div>
|
||||||
].filter(Boolean).join(' ');
|
`.trim();
|
||||||
|
|
||||||
const sourceTags = processedVideo.sources
|
|
||||||
.map(source => {
|
|
||||||
const mimeType = this.getMimeType(source.url);
|
|
||||||
return `<source src="${this.escapeHtml(source.url)}" type="${mimeType}">`;
|
|
||||||
})
|
|
||||||
.join('\n ');
|
|
||||||
|
|
||||||
const metadataHTML = showMetadata && (metadata.title || metadata.duration || metadata.format) ? `
|
|
||||||
<div class="video-metadata">
|
|
||||||
${metadata.title ? `<div class="video-title">${this.escapeHtml(metadata.title)}</div>` : ''}
|
|
||||||
<div class="video-info">
|
|
||||||
${metadata.duration ? `<div class="video-duration">⏱️ ${this.formatDuration(metadata.duration)}</div>` : ''}
|
|
||||||
${metadata.format ? `<div class="video-format">🎥 ${metadata.format.toUpperCase()}</div>` : ''}
|
|
||||||
${metadata.size ? `<div class="video-size">💾 ${this.formatFileSize(metadata.size)}</div>` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
` : '';
|
|
||||||
|
|
||||||
const html = `
|
|
||||||
<div class="video-container aspect-${aspectRatio}">
|
|
||||||
<video ${videoAttributes}>
|
|
||||||
${sourceTags}
|
|
||||||
<p>Your browser does not support the video element. ${processedVideo.fallbackText}</p>
|
|
||||||
</video>
|
|
||||||
${metadataHTML}
|
|
||||||
</div>
|
|
||||||
`.trim();
|
|
||||||
|
|
||||||
console.log(`[VIDEO] Generated HTML for ${processedVideo.sources[0]?.url}:`);
|
|
||||||
console.log(html.substring(0, 200) + '...');
|
|
||||||
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getMimeType(url: string): string {
|
|
||||||
let extension: string | undefined;
|
|
||||||
try {
|
|
||||||
const pathname = new URL(url).pathname;
|
|
||||||
extension = pathname.split('.').pop()?.toLowerCase();
|
|
||||||
} catch {
|
|
||||||
extension = url.split('?')[0].split('.').pop()?.toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
const mimeTypes: Record<string, string> = {
|
|
||||||
mp4: 'video/mp4',
|
|
||||||
webm: 'video/webm',
|
|
||||||
ogg: 'video/ogg',
|
|
||||||
mov: 'video/quicktime',
|
|
||||||
avi: 'video/x-msvideo',
|
|
||||||
m4v: 'video/m4v'
|
|
||||||
};
|
|
||||||
|
|
||||||
return (extension && mimeTypes[extension]) || 'video/mp4';
|
|
||||||
}
|
|
||||||
|
|
||||||
private formatDuration(seconds: number): string {
|
|
||||||
const hours = Math.floor(seconds / 3600);
|
|
||||||
const minutes = Math.floor((seconds % 3600) / 60);
|
|
||||||
const remainingSeconds = Math.floor(seconds % 60);
|
|
||||||
|
|
||||||
if (hours > 0) {
|
|
||||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private formatFileSize(bytes: number): string {
|
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
||||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
||||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private escapeHtml(unsafe: string): string {
|
|
||||||
if (typeof unsafe !== 'string') return '';
|
|
||||||
|
|
||||||
return unsafe
|
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, """)
|
|
||||||
.replace(/'/g, "'");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export singleton instance
|
|
||||||
export const videoProcessor = new VideoProcessor();
|
|
||||||
|
|
||||||
// Utility functions for markdown integration
|
|
||||||
export async function processVideoEmbed(url: string, metadata?: Partial<VideoMetadata>, options?: {
|
|
||||||
controls?: boolean;
|
|
||||||
autoplay?: boolean;
|
|
||||||
muted?: boolean;
|
|
||||||
loop?: boolean;
|
|
||||||
preload?: 'none' | 'metadata' | 'auto';
|
|
||||||
aspectRatio?: '16:9' | '4:3' | '1:1';
|
|
||||||
showMetadata?: boolean;
|
|
||||||
}): Promise<string> {
|
|
||||||
const processedVideo = await videoProcessor.processVideoUrl(url, metadata);
|
|
||||||
return videoProcessor.generateVideoHTML(processedVideo, options);
|
|
||||||
}
|
}
|
Loading…
x
Reference in New Issue
Block a user