887 lines
21 KiB
Markdown
887 lines
21 KiB
Markdown
# Next.js Adaptation Guide - Complete Implementation
|
|
|
|
## Overview
|
|
|
|
This guide provides comprehensive strategies for implementing Stash's media streaming architecture in a Next.js application, covering process management, progress tracking, and anti-jitter mechanisms.
|
|
|
|
## Key Architecture Differences
|
|
|
|
| Stash (Go) | Next.js (Node.js) |
|
|
|------------|-------------------|
|
|
| `context.WithCancel` | `AbortController` |
|
|
| `http.Hijacker` | Request events (`close`, `aborted`) |
|
|
| `exec.Command` | `child_process.spawn` |
|
|
| `sync.WaitGroup` | `Promise.all` + process tracking |
|
|
| File locks | Process pool + cleanup |
|
|
| Graceful shutdown | `SIGTERM` handlers |
|
|
|
|
## 1. Core Process Management
|
|
|
|
### AbortController Pattern
|
|
```typescript
|
|
// lib/transcode/abort-controller.ts
|
|
import { spawn, ChildProcess } from 'child_process';
|
|
|
|
export class TranscodeController {
|
|
private controller: AbortController;
|
|
private process?: ChildProcess;
|
|
private startTime: Date;
|
|
private filePath: string;
|
|
|
|
constructor(filePath: string) {
|
|
this.controller = new AbortController();
|
|
this.startTime = new Date();
|
|
this.filePath = filePath;
|
|
}
|
|
|
|
async startTranscode(args: string[]): Promise<ChildProcess> {
|
|
const { signal } = this.controller;
|
|
|
|
this.process = spawn('ffmpeg', args, {
|
|
signal,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
detached: false
|
|
});
|
|
|
|
// Handle cleanup on abort
|
|
signal.addEventListener('abort', () => {
|
|
this.cleanup();
|
|
});
|
|
|
|
return this.process;
|
|
}
|
|
|
|
private cleanup() {
|
|
if (this.process && !this.process.killed) {
|
|
this.process.kill('SIGKILL');
|
|
this.process.unref();
|
|
}
|
|
}
|
|
|
|
abort() {
|
|
this.controller.abort();
|
|
}
|
|
|
|
getUptime(): number {
|
|
return Date.now() - this.startTime.getTime();
|
|
}
|
|
|
|
isRunning(): boolean {
|
|
return this.process ? !this.process.killed : false;
|
|
}
|
|
}
|
|
```
|
|
|
|
### Process Pool Management
|
|
```typescript
|
|
// lib/transcode/process-pool.ts
|
|
import { ChildProcess } from 'child_process';
|
|
import { EventEmitter } from 'events';
|
|
|
|
interface ProcessEntry {
|
|
process: ChildProcess;
|
|
controller: TranscodeController;
|
|
startTime: Date;
|
|
filePath: string;
|
|
quality: string;
|
|
}
|
|
|
|
export class TranscodeProcessPool extends EventEmitter {
|
|
private processes = new Map<string, ProcessEntry>();
|
|
private maxProcesses = parseInt(process.env.MAX_TRANSCODE_PROCESSES || '4');
|
|
|
|
add(
|
|
key: string,
|
|
process: ChildProcess,
|
|
controller: TranscodeController,
|
|
filePath: string,
|
|
quality: string
|
|
): boolean {
|
|
if (this.processes.size >= this.maxProcesses) {
|
|
return false;
|
|
}
|
|
|
|
this.processes.set(key, {
|
|
process,
|
|
controller,
|
|
startTime: new Date(),
|
|
filePath,
|
|
quality
|
|
});
|
|
|
|
// Auto cleanup
|
|
process.on('exit', () => {
|
|
this.processes.delete(key);
|
|
this.emit('process-exit', key);
|
|
});
|
|
|
|
this.emit('process-added', key);
|
|
return true;
|
|
}
|
|
|
|
kill(key: string): boolean {
|
|
const entry = this.processes.get(key);
|
|
if (entry) {
|
|
entry.controller.abort();
|
|
this.processes.delete(key);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
killAll(): void {
|
|
for (const [key, entry] of this.processes) {
|
|
entry.controller.abort();
|
|
}
|
|
this.processes.clear();
|
|
}
|
|
|
|
getActiveProcesses(): ProcessEntry[] {
|
|
return Array.from(this.processes.values());
|
|
}
|
|
|
|
getStats() {
|
|
return {
|
|
active: this.processes.size,
|
|
max: this.maxProcesses,
|
|
uptime: Array.from(this.processes.values()).map(p => ({
|
|
filePath: p.filePath,
|
|
quality: p.quality,
|
|
uptime: Date.now() - p.startTime.getTime()
|
|
}))
|
|
};
|
|
}
|
|
}
|
|
|
|
export const transcodePool = new TranscodeProcessPool();
|
|
```
|
|
|
|
## 2. API Route Implementation
|
|
|
|
### Transcode API Route
|
|
```typescript
|
|
// pages/api/transcode/[...slug].ts
|
|
import { NextApiRequest, NextApiResponse } from 'next';
|
|
import { spawn } from 'child_process';
|
|
import { TranscodeController } from '../../../lib/transcode/abort-controller';
|
|
import { transcodePool } from '../../../lib/transcode/process-pool';
|
|
|
|
export const config = {
|
|
runtime: 'nodejs',
|
|
api: {
|
|
responseLimit: '500mb',
|
|
},
|
|
};
|
|
|
|
export default async function handler(
|
|
req: NextApiRequest,
|
|
res: NextApiResponse
|
|
) {
|
|
if (req.method !== 'GET') {
|
|
return res.status(405).json({ error: 'Method not allowed' });
|
|
}
|
|
|
|
const { file, quality = '720p', start = '0' } = req.query;
|
|
|
|
if (!file || typeof file !== 'string') {
|
|
return res.status(400).json({ error: 'File parameter required' });
|
|
}
|
|
|
|
const startTime = parseFloat(start as string);
|
|
const key = `${file}:${quality}:${startTime}`;
|
|
|
|
try {
|
|
// Create transcode controller
|
|
const controller = new TranscodeController(file);
|
|
|
|
// Build ffmpeg arguments
|
|
const args = [
|
|
'-i', file,
|
|
'-ss', startTime.toString(),
|
|
'-c:v', 'libx264',
|
|
'-preset', 'fast',
|
|
'-crf', '23',
|
|
'-c:a', 'aac',
|
|
'-b:a', '128k',
|
|
'-f', 'mp4',
|
|
'-movflags', 'frag_keyframe+empty_moov',
|
|
'-max_muxing_queue_size', '9999',
|
|
'pipe:1'
|
|
];
|
|
|
|
// Handle client disconnect
|
|
req.on('close', () => {
|
|
controller.abort();
|
|
});
|
|
|
|
req.on('aborted', () => {
|
|
controller.abort();
|
|
});
|
|
|
|
// Start transcoding
|
|
const process = await controller.startTranscode(args);
|
|
|
|
// Add to process pool
|
|
if (!transcodePool.add(key, process, controller, file, quality as string)) {
|
|
controller.abort();
|
|
return res.status(503).json({ error: 'Too many concurrent transcodes' });
|
|
}
|
|
|
|
// Set response headers
|
|
res.setHeader('Content-Type', 'video/mp4');
|
|
res.setHeader('Cache-Control', 'no-cache');
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
|
|
// Handle range requests
|
|
const range = req.headers.range;
|
|
if (range) {
|
|
const parts = range.replace(/bytes=/, '').split('-');
|
|
const start = parseInt(parts[0], 10);
|
|
const end = parts[1] ? parseInt(parts[1], 10) : undefined;
|
|
|
|
res.status(206);
|
|
res.setHeader('Content-Range', `bytes ${start}-${end || '*'}/${'*'}`);
|
|
}
|
|
|
|
// Stream output
|
|
process.stdout.pipe(res);
|
|
|
|
// Handle errors
|
|
process.stderr.on('data', (data) => {
|
|
console.error(`FFmpeg error: ${data}`);
|
|
});
|
|
|
|
process.on('error', (error) => {
|
|
console.error('Transcoding process error:', error);
|
|
if (!res.headersSent) {
|
|
res.status(500).json({ error: 'Transcoding failed' });
|
|
}
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('Transcode error:', error);
|
|
if (!res.headersSent) {
|
|
res.status(500).json({ error: 'Internal server error' });
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### HLS Manifest API
|
|
```typescript
|
|
// pages/api/hls/[id].ts
|
|
import { NextApiRequest, NextApiResponse } from 'next';
|
|
import { extractVideoMetadata } from '../../../lib/video/metadata';
|
|
|
|
export default async function handler(
|
|
req: NextApiRequest,
|
|
res: NextApiResponse
|
|
) {
|
|
const { id, quality = '720p' } = req.query;
|
|
|
|
try {
|
|
const metadata = await extractVideoMetadata(id as string);
|
|
const segmentDuration = 2; // 2-second segments
|
|
const totalSegments = Math.ceil(metadata.duration / segmentDuration);
|
|
|
|
const manifest = [
|
|
'#EXTM3U',
|
|
'#EXT-X-VERSION:3',
|
|
'#EXT-X-TARGETDURATION:2',
|
|
'#EXT-X-MEDIA-SEQUENCE:0',
|
|
...Array.from({ length: totalSegments }, (_, i) => [
|
|
`#EXTINF:${Math.min(segmentDuration, metadata.duration - i * segmentDuration).toFixed(3)},`,
|
|
`/api/hls/${id}/segment/${i}?quality=${quality}`
|
|
]).flat(),
|
|
'#EXT-X-ENDLIST'
|
|
].join('\n');
|
|
|
|
res.setHeader('Content-Type', 'application/vnd.apple.mpegurl');
|
|
res.setHeader('Cache-Control', 'no-cache');
|
|
res.send(manifest);
|
|
|
|
} catch (error) {
|
|
res.status(500).json({ error: 'Failed to generate manifest' });
|
|
}
|
|
}
|
|
```
|
|
|
|
## 3. Progress Tracking with Anti-Jitter
|
|
|
|
### React Hook Implementation
|
|
```typescript
|
|
// hooks/useTranscodeProgress.ts
|
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
|
|
interface ProgressState {
|
|
progress: number;
|
|
buffered: number;
|
|
isTranscoding: boolean;
|
|
currentTime: number;
|
|
duration: number;
|
|
}
|
|
|
|
interface TranscodeProgressOptions {
|
|
debounceMs?: number;
|
|
enableSmoothing?: boolean;
|
|
maxJitter?: number;
|
|
}
|
|
|
|
export const useTranscodeProgress = (
|
|
videoUrl: string,
|
|
totalDuration: number,
|
|
options: TranscodeProgressOptions = {}
|
|
) => {
|
|
const {
|
|
debounceMs = 100,
|
|
enableSmoothing = true,
|
|
maxJitter = 0.01
|
|
} = options;
|
|
|
|
const [state, setState] = useState<ProgressState>({
|
|
progress: 0,
|
|
buffered: 0,
|
|
isTranscoding: true,
|
|
currentTime: 0,
|
|
duration: totalDuration
|
|
});
|
|
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
const lastProgressRef = useRef(0);
|
|
const lastUpdateRef = useRef(Date.now());
|
|
const abortControllerRef = useRef<AbortController | null>(null);
|
|
|
|
const debounce = useCallback((func: Function, wait: number) => {
|
|
let timeout: NodeJS.Timeout;
|
|
return (...args: any[]) => {
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(() => func(...args), wait);
|
|
};
|
|
}, []);
|
|
|
|
const updateProgress = useCallback(debounce((newProgress: number) => {
|
|
const now = Date.now();
|
|
const timeSinceLastUpdate = now - lastUpdateRef.current;
|
|
|
|
// Anti-jitter: only update if significant change or time elapsed
|
|
const progressDiff = Math.abs(newProgress - lastProgressRef.current);
|
|
const shouldUpdate = progressDiff > maxJitter || timeSinceLastUpdate > 500;
|
|
|
|
if (shouldUpdate) {
|
|
lastProgressRef.current = newProgress;
|
|
lastUpdateRef.current = now;
|
|
|
|
setState(prev => ({
|
|
...prev,
|
|
progress: Math.min(newProgress, 1),
|
|
currentTime: newProgress * totalDuration
|
|
}));
|
|
}
|
|
}, debounceMs), [debounceMs, maxJitter, totalDuration]);
|
|
|
|
const updateBuffer = useCallback(debounce(() => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
|
|
const buffered = video.buffered;
|
|
if (buffered.length > 0) {
|
|
const bufferedEnd = buffered.end(buffered.length - 1);
|
|
const bufferedRatio = bufferedEnd / totalDuration;
|
|
|
|
setState(prev => ({
|
|
...prev,
|
|
buffered: Math.min(bufferedRatio, 1)
|
|
}));
|
|
}
|
|
}, 250), [totalDuration]);
|
|
|
|
const checkTranscodingStatus = useCallback(async () => {
|
|
if (abortControllerRef.current) {
|
|
abortControllerRef.current.abort();
|
|
}
|
|
|
|
abortControllerRef.current = new AbortController();
|
|
|
|
try {
|
|
const response = await fetch(`/api/transcode/status?url=${encodeURIComponent(videoUrl)}`, {
|
|
signal: abortControllerRef.current.signal
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setState(prev => ({
|
|
...prev,
|
|
isTranscoding: data.transcoding,
|
|
duration: data.duration || prev.duration
|
|
}));
|
|
}
|
|
} catch (error) {
|
|
if (error.name !== 'AbortError') {
|
|
console.error('Failed to check transcoding status:', error);
|
|
}
|
|
}
|
|
}, [videoUrl]);
|
|
|
|
useEffect(() => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
|
|
const handleTimeUpdate = () => {
|
|
updateProgress(video.currentTime / totalDuration);
|
|
};
|
|
|
|
const handleProgress = () => {
|
|
updateBuffer();
|
|
};
|
|
|
|
const handleLoadedMetadata = () => {
|
|
setState(prev => ({
|
|
...prev,
|
|
duration: video.duration || totalDuration
|
|
}));
|
|
};
|
|
|
|
video.addEventListener('timeupdate', handleTimeUpdate);
|
|
video.addEventListener('progress', handleProgress);
|
|
video.addEventListener('loadedmetadata', handleLoadedMetadata);
|
|
|
|
const interval = setInterval(checkTranscodingStatus, 2000);
|
|
|
|
return () => {
|
|
video.removeEventListener('timeupdate', handleTimeUpdate);
|
|
video.removeEventListener('progress', handleProgress);
|
|
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
|
clearInterval(interval);
|
|
|
|
if (abortControllerRef.current) {
|
|
abortControllerRef.current.abort();
|
|
}
|
|
};
|
|
}, [videoRef, totalDuration, updateProgress, updateBuffer, checkTranscodingStatus]);
|
|
|
|
return {
|
|
...state,
|
|
videoRef,
|
|
retry: checkTranscodingStatus
|
|
};
|
|
};
|
|
```
|
|
|
|
## 4. Video Player Component
|
|
|
|
### Complete Player Implementation
|
|
```typescript
|
|
// components/VideoPlayer.tsx
|
|
import React, { useRef, useEffect } from 'react';
|
|
import { useTranscodeProgress } from '../hooks/useTranscodeProgress';
|
|
|
|
interface VideoPlayerProps {
|
|
src: string;
|
|
poster?: string;
|
|
autoPlay?: boolean;
|
|
className?: string;
|
|
}
|
|
|
|
export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
|
src,
|
|
poster,
|
|
autoPlay = false,
|
|
className
|
|
}) => {
|
|
const {
|
|
videoRef,
|
|
progress,
|
|
buffered,
|
|
isTranscoding,
|
|
currentTime,
|
|
duration,
|
|
retry
|
|
} = useTranscodeProgress(src, 0);
|
|
|
|
const handleSeek = (time: number) => {
|
|
if (videoRef.current) {
|
|
videoRef.current.currentTime = time;
|
|
}
|
|
};
|
|
|
|
const formatTime = (seconds: number) => {
|
|
const mins = Math.floor(seconds / 60);
|
|
const secs = Math.floor(seconds % 60);
|
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
};
|
|
|
|
return (
|
|
<div className={`video-player ${className || ''}`}>
|
|
<video
|
|
ref={videoRef}
|
|
src={src}
|
|
poster={poster}
|
|
autoPlay={autoPlay}
|
|
controls
|
|
className="video-element"
|
|
/>
|
|
|
|
{isTranscoding && (
|
|
<div className="transcoding-overlay">
|
|
<div className="transcoding-indicator">
|
|
<div className="spinner" />
|
|
<span>Transcoding...</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="progress-bar">
|
|
<div
|
|
className="progress-fill"
|
|
style={{ width: `${progress * 100}%` }}
|
|
/>
|
|
<div
|
|
className="buffer-fill"
|
|
style={{ width: `${buffered * 100}%` }}
|
|
/>
|
|
</div>
|
|
|
|
<div className="time-display">
|
|
<span>{formatTime(currentTime)} / {formatTime(duration)}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
```
|
|
|
|
## 5. Health Monitoring
|
|
|
|
### Health Check Endpoint
|
|
```typescript
|
|
// pages/api/health.ts
|
|
import { NextApiRequest, NextApiResponse } from 'next';
|
|
import { transcodePool } from '../../lib/transcode/process-pool';
|
|
import { healthMonitor } from '../../lib/transcode/health-monitor';
|
|
|
|
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
|
const stats = transcodePool.getStats();
|
|
const memoryUsage = process.memoryUsage();
|
|
|
|
res.json({
|
|
status: 'healthy',
|
|
timestamp: new Date().toISOString(),
|
|
processes: stats,
|
|
memory: {
|
|
used: memoryUsage.heapUsed,
|
|
total: memoryUsage.heapTotal,
|
|
external: memoryUsage.external
|
|
},
|
|
uptime: process.uptime()
|
|
});
|
|
}
|
|
```
|
|
|
|
### Health Monitor Service
|
|
```typescript
|
|
// lib/transcode/health-monitor.ts
|
|
import { transcodePool } from './process-pool';
|
|
|
|
export class HealthMonitor {
|
|
private interval?: NodeJS.Timeout;
|
|
private maxMemoryUsage = 0.9; // 90%
|
|
private maxProcessUptime = 300000; // 5 minutes
|
|
|
|
start() {
|
|
this.interval = setInterval(() => {
|
|
this.checkMemoryUsage();
|
|
this.checkProcessTimeouts();
|
|
this.logStats();
|
|
}, 30000);
|
|
}
|
|
|
|
stop() {
|
|
if (this.interval) {
|
|
clearInterval(this.interval);
|
|
}
|
|
}
|
|
|
|
private checkMemoryUsage() {
|
|
const memUsage = process.memoryUsage();
|
|
const ratio = memUsage.heapUsed / memUsage.heapTotal;
|
|
|
|
if (ratio > this.maxMemoryUsage) {
|
|
console.warn('High memory usage detected, cleaning up old processes');
|
|
this.cleanupOldProcesses();
|
|
}
|
|
}
|
|
|
|
private checkProcessTimeouts() {
|
|
const processes = transcodePool.getActiveProcesses();
|
|
|
|
processes.forEach(p => {
|
|
if (p.uptime > this.maxProcessUptime) {
|
|
console.warn(`Killing hanging process: ${p.filePath}`);
|
|
transcodePool.kill(`${p.filePath}:${p.quality}`);
|
|
}
|
|
});
|
|
}
|
|
|
|
private logStats() {
|
|
const stats = transcodePool.getStats();
|
|
console.log('Transcode stats:', {
|
|
active: stats.active,
|
|
uptime: stats.uptime
|
|
});
|
|
}
|
|
|
|
private cleanupOldProcesses() {
|
|
const processes = transcodePool.getActiveProcesses();
|
|
const sorted = processes.sort((a, b) => a.uptime - b.uptime);
|
|
|
|
// Keep newest half, kill oldest half
|
|
const toKeep = Math.floor(sorted.length / 2);
|
|
sorted.slice(0, toKeep).forEach(p => {
|
|
transcodePool.kill(`${p.filePath}:${p.quality}`);
|
|
});
|
|
}
|
|
}
|
|
|
|
export const healthMonitor = new HealthMonitor();
|
|
```
|
|
|
|
## 6. Production Deployment
|
|
|
|
### PM2 Configuration
|
|
```javascript
|
|
// ecosystem.config.js
|
|
module.exports = {
|
|
apps: [{
|
|
name: 'nextjs-transcode',
|
|
script: 'node_modules/.bin/next',
|
|
args: 'start',
|
|
instances: 'max',
|
|
exec_mode: 'cluster',
|
|
max_memory_restart: '1G',
|
|
env: {
|
|
NODE_ENV: 'production',
|
|
PORT: 3000,
|
|
MAX_TRANSCODE_PROCESSES: '4',
|
|
TRANSCODE_TIMEOUT: '300000'
|
|
},
|
|
kill_timeout: 10000,
|
|
shutdown_with_message: true
|
|
}]
|
|
};
|
|
```
|
|
|
|
### Docker Configuration
|
|
```dockerfile
|
|
FROM node:18-alpine
|
|
|
|
# Install FFmpeg
|
|
RUN apk add --no-cache ffmpeg
|
|
|
|
WORKDIR /app
|
|
|
|
# Copy package files
|
|
COPY package*.json ./
|
|
RUN npm ci --only=production
|
|
|
|
# Copy application code
|
|
COPY . .
|
|
|
|
# Build the application
|
|
RUN npm run build
|
|
|
|
# Set resource limits
|
|
ENV NODE_OPTIONS="--max-old-space-size=1024"
|
|
ENV MAX_TRANSCODE_PROCESSES="4"
|
|
|
|
EXPOSE 3000
|
|
|
|
CMD ["npm", "start"]
|
|
```
|
|
|
|
### Environment Variables
|
|
```bash
|
|
# .env.production
|
|
NODE_ENV=production
|
|
MAX_TRANSCODE_PROCESSES=4
|
|
TRANSCODE_TIMEOUT=300000
|
|
FFMPEG_PATH=/usr/bin/ffmpeg
|
|
MAX_MEMORY_USAGE=0.9
|
|
HEALTH_CHECK_INTERVAL=30000
|
|
```
|
|
|
|
### Kubernetes Deployment
|
|
```yaml
|
|
apiVersion: apps/v1
|
|
kind: Deployment
|
|
metadata:
|
|
name: nextjs-transcode
|
|
spec:
|
|
replicas: 3
|
|
selector:
|
|
matchLabels:
|
|
app: nextjs-transcode
|
|
template:
|
|
metadata:
|
|
labels:
|
|
app: nextjs-transcode
|
|
spec:
|
|
containers:
|
|
- name: nextjs
|
|
image: nextjs-transcode:latest
|
|
ports:
|
|
- containerPort: 3000
|
|
env:
|
|
- name: NODE_ENV
|
|
value: "production"
|
|
- name: MAX_TRANSCODE_PROCESSES
|
|
value: "2"
|
|
resources:
|
|
requests:
|
|
memory: "512Mi"
|
|
cpu: "500m"
|
|
limits:
|
|
memory: "2Gi"
|
|
cpu: "1000m"
|
|
livenessProbe:
|
|
httpGet:
|
|
path: /api/health
|
|
port: 3000
|
|
initialDelaySeconds: 30
|
|
periodSeconds: 10
|
|
readinessProbe:
|
|
httpGet:
|
|
path: /api/health
|
|
port: 3000
|
|
initialDelaySeconds: 5
|
|
periodSeconds: 5
|
|
```
|
|
|
|
## 7. Testing Strategy
|
|
|
|
### Unit Tests
|
|
```typescript
|
|
// __tests__/transcode-controller.test.ts
|
|
import { TranscodeController } from '../lib/transcode/abort-controller';
|
|
|
|
describe('TranscodeController', () => {
|
|
it('should start and abort transcoding', async () => {
|
|
const controller = new TranscodeController('test.mp4');
|
|
const process = await controller.startTranscode(['-i', 'test.mp4', '-f', 'null', '-']);
|
|
|
|
expect(controller.isRunning()).toBe(true);
|
|
|
|
controller.abort();
|
|
|
|
// Wait for process to exit
|
|
await new Promise(resolve => process.on('exit', resolve));
|
|
|
|
expect(controller.isRunning()).toBe(false);
|
|
});
|
|
|
|
it('should handle process timeout', async () => {
|
|
const controller = new TranscodeController('test.mp4');
|
|
|
|
// Test with invalid file to force quick exit
|
|
const process = await controller.startTranscode(['-i', 'nonexistent.mp4', '-f', 'null', '-']);
|
|
|
|
const exitCode = await new Promise(resolve => process.on('exit', resolve));
|
|
expect(exitCode).toBe(1);
|
|
});
|
|
});
|
|
```
|
|
|
|
### Integration Tests
|
|
```typescript
|
|
// __tests__/api-transcode.test.ts
|
|
import { createServer } from 'http';
|
|
import request from 'supertest';
|
|
import { parse } from 'url';
|
|
import next from 'next';
|
|
|
|
describe('/api/transcode', () => {
|
|
let server: any;
|
|
|
|
beforeAll(async () => {
|
|
const app = next({ dev: false });
|
|
await app.prepare();
|
|
|
|
server = createServer((req, res) => {
|
|
const parsedUrl = parse(req.url, true);
|
|
app.getRequestHandler()(req, res, parsedUrl);
|
|
});
|
|
});
|
|
|
|
afterAll(() => {
|
|
server.close();
|
|
});
|
|
|
|
it('should handle transcode requests', async () => {
|
|
const response = await request(server)
|
|
.get('/api/transcode/test.mp4')
|
|
.expect(200);
|
|
|
|
expect(response.headers['content-type']).toBe('video/mp4');
|
|
});
|
|
|
|
it('should handle client disconnect', async () => {
|
|
const response = await request(server)
|
|
.get('/api/transcode/test.mp4')
|
|
.timeout(1000); // Force timeout
|
|
|
|
expect(response.status).toBe(408);
|
|
});
|
|
});
|
|
```
|
|
|
|
## 8. Performance Optimization
|
|
|
|
### Caching Strategy
|
|
```typescript
|
|
// lib/cache/transcode-cache.ts
|
|
import NodeCache from 'node-cache';
|
|
|
|
export class TranscodeCache {
|
|
private cache = new NodeCache({
|
|
stdTTL: 3600, // 1 hour
|
|
checkperiod: 600, // 10 minutes
|
|
maxKeys: 1000
|
|
});
|
|
|
|
get(key: string): Buffer | undefined {
|
|
return this.cache.get(key);
|
|
}
|
|
|
|
set(key: string, data: Buffer, ttl?: number): boolean {
|
|
return this.cache.set(key, data, ttl);
|
|
}
|
|
|
|
has(key: string): boolean {
|
|
return this.cache.has(key);
|
|
}
|
|
|
|
delete(key: string): number {
|
|
return this.cache.del(key);
|
|
}
|
|
|
|
getStats() {
|
|
return this.cache.getStats();
|
|
}
|
|
}
|
|
|
|
export const transcodeCache = new TranscodeCache();
|
|
```
|
|
|
|
### Rate Limiting
|
|
```typescript
|
|
// lib/middleware/rate-limit.ts
|
|
import rateLimit from 'express-rate-limit';
|
|
|
|
export const transcodeRateLimit = rateLimit({
|
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
max: 100, // Limit each IP to 100 requests per windowMs
|
|
message: 'Too many transcode requests',
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
});
|
|
```
|
|
|
|
This comprehensive Next.js adaptation provides all the components needed to implement Stash's reliable transcoding system with modern Node.js patterns and Next.js optimizations. |