simplify video stuff
This commit is contained in:
parent
b291492e2d
commit
27b94edcfa
@ -1,5 +1,5 @@
|
||||
---
|
||||
// src/components/Video.astro - SIMPLE wrapper component
|
||||
// src/components/Video.astro - SIMPLE responsive video component
|
||||
export interface Props {
|
||||
src: string;
|
||||
title?: string;
|
||||
@ -8,6 +8,7 @@ export interface Props {
|
||||
muted?: boolean;
|
||||
loop?: boolean;
|
||||
aspectRatio?: '16:9' | '4:3' | '1:1';
|
||||
preload?: 'none' | 'metadata' | 'auto';
|
||||
}
|
||||
|
||||
const {
|
||||
@ -17,17 +18,21 @@ const {
|
||||
autoplay = false,
|
||||
muted = false,
|
||||
loop = false,
|
||||
aspectRatio = '16:9'
|
||||
aspectRatio = '16:9',
|
||||
preload = 'metadata'
|
||||
} = Astro.props;
|
||||
|
||||
const aspectClass = `aspect-${aspectRatio.replace(':', '-')}`;
|
||||
---
|
||||
|
||||
<div class={`video-container aspect-${aspectRatio}`}>
|
||||
<div class={`video-container ${aspectClass}`}>
|
||||
<video
|
||||
src={src}
|
||||
controls={controls}
|
||||
autoplay={autoplay}
|
||||
muted={muted}
|
||||
loop={loop}
|
||||
preload={preload}
|
||||
style="width: 100%; height: 100%;"
|
||||
data-video-title={title}
|
||||
>
|
||||
|
@ -17,10 +17,8 @@ sections:
|
||||
advanced_topics: false
|
||||
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!
|
||||
|
||||
|
||||
|
@ -18,6 +18,8 @@ sections:
|
||||
advanced_topics: true
|
||||
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!
|
||||
|
||||
|
@ -352,6 +352,37 @@ const { title, description = 'ForensicPathways - A comprehensive directory of di
|
||||
};
|
||||
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>
|
||||
</head>
|
||||
<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 type { Plugin } from 'unified';
|
||||
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> = () => {
|
||||
return (tree: Root) => {
|
||||
// Find HTML nodes containing <video> tags
|
||||
visit(tree, 'html', (node: any, index: number | undefined, parent: any) => {
|
||||
if (node.value && node.value.includes('<video') && typeof index === 'number') {
|
||||
|
||||
// Extract video attributes
|
||||
const srcMatch = node.value.match(/src=["']([^"']+)["']/);
|
||||
const titleMatch = node.value.match(/title=["']([^"']+)["']/);
|
||||
|
||||
if (srcMatch) {
|
||||
const src = srcMatch[1];
|
||||
const originalSrc = srcMatch[1];
|
||||
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 hasAutoplay = node.value.includes('autoplay');
|
||||
const hasMuted = node.value.includes('muted');
|
||||
const hasLoop = node.value.includes('loop');
|
||||
const hasPreload = node.value.match(/preload=["']([^"']+)["']/);
|
||||
|
||||
// Create wrapped HTML
|
||||
const wrappedHTML = `
|
||||
<div class="video-container aspect-16:9">
|
||||
// Create simple, working video HTML - NO crossorigin attribute
|
||||
const enhancedHTML = `
|
||||
<div class="video-container aspect-16-9">
|
||||
<video
|
||||
src="${escapeHtml(src)}"
|
||||
src="${escapeHtml(finalSrc)}"
|
||||
${hasControls ? 'controls' : ''}
|
||||
${hasAutoplay ? 'autoplay' : ''}
|
||||
${hasMuted ? 'muted' : ''}
|
||||
${hasLoop ? 'loop' : ''}
|
||||
${hasPreload ? `preload="${hasPreload[1]}"` : 'preload="metadata"'}
|
||||
style="width: 100%; height: 100%;"
|
||||
data-video-title="${escapeHtml(title)}"
|
||||
data-original-src="${escapeHtml(originalSrc)}"
|
||||
>
|
||||
<p>Your browser does not support the video element.</p>
|
||||
</video>
|
||||
<div class="video-metadata">
|
||||
<div class="video-title">${escapeHtml(title)}</div>
|
||||
</div>
|
||||
${title !== 'Video' ? `
|
||||
<div class="video-metadata">
|
||||
<div class="video-title">${escapeHtml(title)}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`.trim();
|
||||
|
||||
// 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 {
|
||||
if (typeof unsafe !== 'string') return '';
|
||||
|
||||
|
@ -1,449 +1,134 @@
|
||||
// src/utils/videoUtils.ts - NEXTCLOUD ONLY
|
||||
import { NextcloudUploader } from './nextcloud.js';
|
||||
// src/utils/videoUtils.ts - SIMPLIFIED - Basic utilities only
|
||||
import 'dotenv/config';
|
||||
|
||||
export interface VideoSource {
|
||||
type: 'nextcloud' | 'cdn' | 'local';
|
||||
url: string;
|
||||
originalUrl?: string;
|
||||
cached?: boolean;
|
||||
}
|
||||
/**
|
||||
* Simple video utilities for basic video support
|
||||
* No caching, no complex processing, no authentication
|
||||
*/
|
||||
|
||||
export interface VideoMetadata {
|
||||
export interface SimpleVideoMetadata {
|
||||
title?: string;
|
||||
duration?: number;
|
||||
format?: string;
|
||||
size?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
poster?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ProcessedVideo {
|
||||
sources: VideoSource[];
|
||||
metadata: VideoMetadata;
|
||||
fallbackText: string;
|
||||
requiresAuth: boolean;
|
||||
/**
|
||||
* Get video MIME type from file extension
|
||||
*/
|
||||
export function getVideoMimeType(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',
|
||||
mkv: 'video/x-matroska',
|
||||
flv: 'video/x-flv'
|
||||
};
|
||||
|
||||
return (extension && mimeTypes[extension]) || 'video/mp4';
|
||||
}
|
||||
|
||||
interface VideoConfig {
|
||||
enableCaching: boolean;
|
||||
cacheDirectory: string;
|
||||
maxCacheSize: number;
|
||||
supportedFormats: string[];
|
||||
maxFileSize: number;
|
||||
/**
|
||||
* Format duration in MM:SS or HH:MM:SS format
|
||||
*/
|
||||
export function 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')}`;
|
||||
}
|
||||
|
||||
export class VideoProcessor {
|
||||
private config: VideoConfig;
|
||||
private nextcloudUploader: NextcloudUploader;
|
||||
|
||||
constructor() {
|
||||
this.config = {
|
||||
enableCaching: process.env.VIDEO_CACHE_ENABLED === 'true',
|
||||
cacheDirectory: process.env.VIDEO_CACHE_DIR || './cache/videos',
|
||||
maxCacheSize: parseInt(process.env.VIDEO_CACHE_MAX_SIZE || '2000'),
|
||||
supportedFormats: ['mp4', 'webm', 'ogg', 'mov', 'avi'],
|
||||
maxFileSize: parseInt(process.env.VIDEO_MAX_SIZE || '200')
|
||||
};
|
||||
|
||||
this.nextcloudUploader = new NextcloudUploader();
|
||||
console.log('[VIDEO] Nextcloud-only video processor initialized');
|
||||
}
|
||||
|
||||
async processVideoUrl(url: string, metadata: Partial<VideoMetadata> = {}): Promise<ProcessedVideo> {
|
||||
console.log(`[VIDEO] Processing: ${url}`);
|
||||
|
||||
try {
|
||||
const videoSource = this.identifyVideoSource(url);
|
||||
console.log(`[VIDEO] Source type: ${videoSource.type}`);
|
||||
|
||||
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 };
|
||||
}
|
||||
/**
|
||||
* Format file size in human readable format
|
||||
*/
|
||||
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`;
|
||||
}
|
||||
|
||||
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
|
||||
}];
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Escape HTML for safe output
|
||||
*/
|
||||
export function escapeHtml(unsafe: string): string {
|
||||
if (typeof unsafe !== 'string') return '';
|
||||
|
||||
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: {
|
||||
return unsafe
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate basic responsive video HTML
|
||||
*/
|
||||
export function generateVideoHTML(
|
||||
src: string,
|
||||
options: {
|
||||
title?: string;
|
||||
controls?: boolean;
|
||||
autoplay?: boolean;
|
||||
muted?: boolean;
|
||||
loop?: boolean;
|
||||
preload?: 'none' | 'metadata' | 'auto';
|
||||
width?: string;
|
||||
height?: string;
|
||||
aspectRatio?: '16:9' | '4:3' | '1:1';
|
||||
showMetadata?: boolean;
|
||||
} = {}): string {
|
||||
|
||||
const {
|
||||
controls = true,
|
||||
autoplay = false,
|
||||
muted = false,
|
||||
loop = false,
|
||||
preload = 'metadata',
|
||||
aspectRatio = '16:9',
|
||||
showMetadata = true
|
||||
} = options;
|
||||
|
||||
if (processedVideo.sources.length === 0) {
|
||||
return `
|
||||
<div class="video-container aspect-${aspectRatio}">
|
||||
<div class="video-error">
|
||||
<div class="error-icon">⚠️</div>
|
||||
<div>${processedVideo.fallbackText}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const primarySource = processedVideo.sources[0];
|
||||
const { metadata } = processedVideo;
|
||||
|
||||
// Simple video attributes - no crossorigin complications
|
||||
const videoAttributes = [
|
||||
controls ? 'controls' : '',
|
||||
autoplay ? 'autoplay' : '',
|
||||
muted ? 'muted' : '',
|
||||
loop ? 'loop' : '',
|
||||
`preload="${preload}"`,
|
||||
metadata.poster ? `poster="${this.escapeHtml(metadata.poster)}"` : '',
|
||||
`data-video-title="${this.escapeHtml(metadata.title || 'Embedded Video')}"`
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
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;
|
||||
}
|
||||
} = {}
|
||||
): string {
|
||||
const {
|
||||
title = 'Video',
|
||||
controls = true,
|
||||
autoplay = false,
|
||||
muted = false,
|
||||
loop = false,
|
||||
preload = 'metadata',
|
||||
aspectRatio = '16:9',
|
||||
showMetadata = true
|
||||
} = options;
|
||||
|
||||
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')}`;
|
||||
}
|
||||
const aspectClass = `aspect-${aspectRatio.replace(':', '-')}`;
|
||||
const videoAttributes = [
|
||||
controls ? 'controls' : '',
|
||||
autoplay ? 'autoplay' : '',
|
||||
muted ? 'muted' : '',
|
||||
loop ? 'loop' : '',
|
||||
`preload="${preload}"`
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
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);
|
||||
const metadataHTML = showMetadata && title !== 'Video' ? `
|
||||
<div class="video-metadata">
|
||||
<div class="video-title">${escapeHtml(title)}</div>
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
return `
|
||||
<div class="video-container ${aspectClass}">
|
||||
<video
|
||||
src="${escapeHtml(src)}"
|
||||
${videoAttributes}
|
||||
style="width: 100%; height: 100%;"
|
||||
data-video-title="${escapeHtml(title)}"
|
||||
>
|
||||
<p>Your browser does not support the video element.</p>
|
||||
</video>
|
||||
${metadataHTML}
|
||||
</div>
|
||||
`.trim();
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user