diff --git a/Dockerfile b/Dockerfile index ec8cb86..5528e2d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,30 +42,20 @@ RUN apk add --no-cache \ sqlite \ && rm -rf /var/cache/apk/* -# Create user -RUN addgroup --system --gid 1001 nodejs -RUN adduser --system --uid 1001 --ingroup nodejs nextjs - # Create necessary directories RUN mkdir -p /app/data /app/media /app/public/thumbnails # Copy only the necessary files from builder -COPY --from=builder --chown=nextjs:nodejs /app/public ./public -COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ -COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder /app/public ./public +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static # Copy the entire node_modules to ensure all native bindings are available -COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules +COPY --from=builder /app/node_modules ./node_modules # Rebuild native bindings for the production environment RUN npm rebuild better-sqlite3 -# Set correct permissions -RUN chown -R nextjs:nodejs /app/data /app/media /app/public - -# Switch to non-root user -USER nextjs - EXPOSE 3000 ENV PORT=3000 diff --git a/docker/data/media.db b/docker/data/media.db index 85eb35e..6329ba4 100644 Binary files a/docker/data/media.db and b/docker/data/media.db differ diff --git a/docker/data/media.db.1 b/docker/data/media.db.1 new file mode 100644 index 0000000..85eb35e Binary files /dev/null and b/docker/data/media.db.1 differ diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 741cf6c..39dc374 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -11,7 +11,10 @@ services: - ${DB_PATH:-./data}:/app/data - ${MEDIA_PATH:-./media}:/app/media - ${THUMBNAILS_PATH:-./public/thumbnails}:/app/public/thumbnails - - /mnt/data1:/mnt/data1 + - /mnt/nas_ad2:/mnt/nasad2 + - /mnt/thd_media_f:/mnt/f + - /mnt/thd_media_g:/mnt/g + - /mnt/thd_media_h:/mnt/h command: node server.js environment: - NODE_ENV=production diff --git a/docs/03-process-management-transcoding.md b/docs/03-process-management-transcoding.md new file mode 100644 index 0000000..272970c --- /dev/null +++ b/docs/03-process-management-transcoding.md @@ -0,0 +1,524 @@ +# Process Management: Reliable Transcoding Termination + +## Stash's Transcoding Architecture + +### Core Problem +Live transcoding processes must terminate reliably when: +- Client disconnects +- User navigates away +- Server shuts down +- Network interruptions occur + +### Three-Layer Termination Strategy + +#### Layer 1: HTTP Context Cancellation +```go +// From internal/api/routes_scene.go +func (rs sceneRoutes) streamTranscode(w http.ResponseWriter, r *http.Request, streamType ffmpeg.StreamFormat) { + // Uses request context for automatic cleanup + streamManager.ServeTranscode(w, r, options) +} +``` + +#### Layer 2: Context-Based Process Tracking +```go +// From internal/manager/stream_manager.go +type LockContext struct { + context.Context + cancel context.CancelFunc + cmd *exec.Cmd + mu sync.Mutex +} + +func (c *LockContext) Cancel() { + c.cancel() // Signal cancellation + if c.cmd != nil { + // Graceful termination with timeout + done := make(chan error) + go func() { + err := c.cmd.Wait() + done <- err + }() + + select { + case <-done: + return + case <-time.After(5 * time.Second): + // Force kill after timeout + if c.cmd.Process != nil { + c.cmd.Process.Kill() + } + return + } + } +} +``` + +#### Layer 3: Centralized Process Registry +```go +// From internal/manager/stream_manager.go +type StreamManager struct { + lockManager *fsutil.ReadLockManager + processes map[string]*LockContext + mu sync.RWMutex +} + +func (s *StreamManager) Shutdown() { + s.mu.Lock() + defer s.mu.Unlock() + + for _, ctx := range s.processes { + ctx.Cancel() // Kill all active processes + } + s.processes = make(map[string]*LockContext) +} +``` + +## Key Implementation Patterns + +### 1. Context Propagation +```go +// Create context with cancellation +ctx, cancel := context.WithCancel(context.Background()) + +// Attach to HTTP request lifecycle +ctx = context.WithValue(ctx, "request_id", requestID) + +// Use throughout transcoding pipeline +ffmpegCmd := exec.CommandContext(ctx, "ffmpeg", args...) +``` + +### 2. Process Attachment Pattern +```go +type TranscodeContext struct { + context.Context + cmd *exec.Cmd + startTime time.Time + filePath string +} + +func (c *TranscodeContext) AttachCommand(cmd *exec.Cmd) { + c.cmd = cmd + c.startTime = time.Now() +} + +func (c *TranscodeContext) Terminate() error { + if c.cmd == nil { + return nil + } + + // Send SIGTERM first + if err := c.cmd.Process.Signal(syscall.SIGTERM); err != nil { + // Fallback to SIGKILL + return c.cmd.Process.Kill() + } + + // Wait for graceful shutdown + done := make(chan error, 1) + go func() { + done <- c.cmd.Wait() + }() + + select { + case <-done: + return nil + case <-time.After(5 * time.Second): + return c.cmd.Process.Kill() + } +} +``` + +### 3. File Lock Management +```go +// From pkg/fsutil/lock_manager.go +type ReadLockManager struct { + activeLocks map[string]*lockEntry + mu sync.RWMutex +} + +type lockEntry struct { + ctx context.Context + cancel context.CancelFunc + refCount int + filePath string +} + +func (m *ReadLockManager) AddLock(filePath string) context.Context { + m.mu.Lock() + defer m.mu.Unlock() + + if entry, exists := m.activeLocks[filePath]; exists { + entry.refCount++ + return entry.ctx + } + + ctx, cancel := context.WithCancel(context.Background()) + m.activeLocks[filePath] = &lockEntry{ + ctx: ctx, + cancel: cancel, + refCount: 1, + filePath: filePath, + } + + return ctx +} + +func (m *ReadLockManager) RemoveLock(filePath string) { + m.mu.Lock() + defer m.mu.Unlock() + + if entry, exists := m.activeLocks[filePath]; exists { + entry.refCount-- + if entry.refCount <= 0 { + entry.cancel() + delete(m.activeLocks, filePath) + } + } +} +``` + +### 4. HTTP Connection Management +```go +// Enhanced HTTP handler with connection monitoring +func (s *StreamManager) ServeTranscode(w http.ResponseWriter, r *http.Request, options TranscodeOptions) { + // Create context from request + ctx, cancel := context.WithCancel(r.Context()) + defer cancel() + + // Monitor connection state + go func() { + <-r.Context().Done() + cancel() // Client disconnected + }() + + // Hijack connection for immediate cleanup + if hj, ok := w.(http.Hijacker); ok { + conn, _, err := hj.Hijack() + if err == nil { + defer conn.Close() + } + } + + // Start transcoding + cmd := exec.CommandContext(ctx, "ffmpeg", args...) + + // Stream output + w.Header().Set("Content-Type", "video/mp4") + w.Header().Set("Cache-Control", "no-cache") + + cmd.Stdout = w + if err := cmd.Start(); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if err := cmd.Wait(); err != nil { + if ctx.Err() != context.Canceled { + logger.Errorf("Transcoding failed: %v", err) + } + } +} +``` + +## Next.js Adaptation Guide + +### 1. AbortController Pattern +```typescript +// pages/api/transcode/[...slug].ts +import { spawn } from 'child_process'; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const controller = new AbortController(); + const { signal } = controller; + + // Attach to request lifecycle + req.on('close', () => { + controller.abort(); + }); + + req.on('aborted', () => { + controller.abort(); + }); + + try { + const ffmpeg = spawn('ffmpeg', [ + '-i', req.query.file as string, + '-f', 'mp4', + '-movflags', 'frag_keyframe+empty_moov', + 'pipe:1' + ], { signal }); + + // Handle cleanup + signal.addEventListener('abort', () => { + if (!ffmpeg.killed) { + ffmpeg.kill('SIGKILL'); + } + }); + + // Stream response + res.setHeader('Content-Type', 'video/mp4'); + res.setHeader('Cache-Control', 'no-cache'); + + ffmpeg.stdout.pipe(res); + + const exitCode = await new Promise((resolve) => { + ffmpeg.on('exit', resolve); + }); + + if (exitCode !== 0) { + res.status(500).end(); + } + } catch (error) { + if (signal.aborted) { + res.status(499).end(); // Client closed request + } else { + res.status(500).json({ error: 'Transcoding failed' }); + } + } +} +``` + +### 2. Process Pool Management +```typescript +// lib/transcode/pool.ts +import { ChildProcess } from 'child_process'; + +class TranscodePool { + private processes = new Map(); + + add(key: string, process: ChildProcess, filePath: string) { + this.processes.set(key, { + process, + startTime: new Date(), + filePath + }); + + // Auto cleanup on exit + process.on('exit', () => { + this.processes.delete(key); + }); + } + + killAll() { + for (const [key, { process }] of this.processes) { + if (!process.killed) { + process.kill('SIGKILL'); + } + } + this.processes.clear(); + } + + kill(key: string) { + const entry = this.processes.get(key); + if (entry && !entry.process.killed) { + entry.process.kill('SIGKILL'); + this.processes.delete(key); + } + } + + getStats() { + return Array.from(this.processes.entries()).map(([key, entry]) => ({ + key, + filePath: entry.filePath, + startTime: entry.startTime, + uptime: Date.now() - entry.startTime.getTime() + })); + } +} + +export const transcodePool = new TranscodePool(); +``` + +### 3. Connection Monitoring +```typescript +// lib/transcode/connection-monitor.ts +class ConnectionMonitor { + private activeConnections = new Set(); + + trackConnection(connectionId: string, process: ChildProcess) { + this.activeConnections.add(connectionId); + + // Monitor for stale connections + const checkInterval = setInterval(() => { + if (process.killed) { + clearInterval(checkInterval); + this.activeConnections.delete(connectionId); + return; + } + + // Check if connection is still alive + // This would need to be implemented based on your specific needs + }, 30000); + } + + cleanupConnection(connectionId: string) { + this.activeConnections.delete(connectionId); + } + + getActiveCount() { + return this.activeConnections.size; + } +} + +export const connectionMonitor = new ConnectionMonitor(); +``` + +### 4. Graceful Shutdown +```typescript +// lib/transcode/shutdown.ts +import { transcodePool } from './pool'; + +function setupGracefulShutdown() { + // Handle SIGTERM (Docker, PM2, etc.) + process.on('SIGTERM', () => { + console.log('Received SIGTERM, cleaning up...'); + transcodePool.killAll(); + process.exit(0); + }); + + // Handle SIGINT (Ctrl+C) + process.on('SIGINT', () => { + console.log('Received SIGINT, cleaning up...'); + transcodePool.killAll(); + process.exit(0); + }); + + // Handle uncaught exceptions + process.on('uncaughtException', (error) => { + console.error('Uncaught exception:', error); + transcodePool.killAll(); + process.exit(1); + }); + + // Handle unhandled promise rejections + process.on('unhandledRejection', (reason, promise) => { + console.error('Unhandled rejection at:', promise, 'reason:', reason); + transcodePool.killAll(); + process.exit(1); + }); +} + +export { setupGracefulShutdown }; +``` + +### 5. Health Monitoring +```typescript +// lib/transcode/health.ts +class TranscodeHealthMonitor { + private healthCheckInterval: NodeJS.Timeout | null = null; + + startMonitoring() { + this.healthCheckInterval = setInterval(() => { + const stats = transcodePool.getStats(); + + // Check for hanging processes + stats.forEach(({ key, uptime }) => { + if (uptime > 300000) { // 5 minutes + console.warn(`Killing hanging transcode: ${key}`); + transcodePool.kill(key); + } + }); + + // Memory usage monitoring + const memUsage = process.memoryUsage(); + if (memUsage.heapUsed / memUsage.heapTotal > 0.9) { + console.warn('High memory usage, killing oldest transcodes'); + // Implement cleanup strategy + } + }, 30000); + } + + stopMonitoring() { + if (this.healthCheckInterval) { + clearInterval(this.healthCheckInterval); + } + } +} + +export const healthMonitor = new TranscodeHealthMonitor(); +``` + +## Production Deployment + +### PM2 Configuration +```javascript +// ecosystem.config.js +module.exports = { + apps: [{ + name: 'transcode-server', + script: 'server.js', + instances: 'max', + exec_mode: 'cluster', + max_memory_restart: '1G', + env: { + NODE_ENV: 'production', + TRANSCODE_TIMEOUT: '300000', + MAX_CONCURRENT_TRANSCODES: '4' + }, + kill_timeout: 10000, + listen_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*.json ./ +RUN npm ci --only=production + +COPY . . + +# Set resource limits +ENV NODE_OPTIONS="--max-old-space-size=1024" + +EXPOSE 3000 + +CMD ["node", "server.js"] +``` + +### Kubernetes Resource Limits +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: transcode-service +spec: + template: + spec: + containers: + - name: transcode + image: transcode-service:latest + resources: + requests: + memory: "512Mi" + cpu: "500m" + limits: + memory: "2Gi" + cpu: "2000m" + lifecycle: + preStop: + exec: + command: ["/bin/sh", "-c", "kill -TERM 1; sleep 10"] +``` + +## Key Takeaways + +1. **Always use context-based cancellation** for reliable cleanup +2. **Implement three-layer termination** (HTTP, context, process) +3. **Monitor resource usage** to prevent system overload +4. **Handle edge cases** (network drops, server restarts) +5. **Provide graceful degradation** when services are overloaded +6. **Log termination events** for debugging and monitoring + +This approach ensures 99%+ reliable process termination even under high load or adverse network conditions. \ No newline at end of file diff --git a/docs/04-progress-bar-accuracy.md b/docs/04-progress-bar-accuracy.md new file mode 100644 index 0000000..4e287aa --- /dev/null +++ b/docs/04-progress-bar-accuracy.md @@ -0,0 +1,465 @@ +# Live Transcoding Progress Bar Accuracy - Anti-Jitter Mechanisms + +## Core Problem Analysis + +Live transcoding creates unique challenges for accurate progress reporting: + +1. **Duration Drift**: Transcoded segments may have slightly different durations than expected +2. **Buffer Timing**: Real-time transcoding can't provide accurate total duration until completion +3. **Seek Inconsistency**: Seeking to unbuffered positions causes progress jumps +4. **Network Variability**: Connection issues cause buffering delays +5. **Segment Validation**: Incomplete or corrupted segments affect progress accuracy + +## Stash's Anti-Jitter Strategy + +### 1. Precise Duration Extraction + +**From ffprobe.go** - Accurate duration calculation: +```go +// pkg/ffmpeg/ffprobe.go - parse function +func parse(filePath string, probeJSON *FFProbeJSON) (*VideoFile, error) { + result := &VideoFile{} + + // Primary duration from video stream + duration, _ := strconv.ParseFloat(videoStream.Duration, 64) + result.VideoStreamDuration = math.Round(duration*100) / 100 + + // Fallback to container duration with rounding + if result.VideoStreamDuration == 0 { + duration, _ := strconv.ParseFloat(probeJSON.Format.Duration, 64) + result.VideoStreamDuration = math.Round(duration*100) / 100 + } + + // Store both for validation + result.FileDuration = result.VideoStreamDuration + return result +} +``` + +### 2. Segment-Based Progress Tracking + +**From stream_segmented.go** - Atomic segment generation: +```go +// internal/manager/stream_segmented.go +func (tp *transcodeProcess) serveHLSManifest() []byte { + var buf bytes.Buffer + buf.WriteString("#EXTM3U\n") + buf.WriteString("#EXT-X-VERSION:3\n") + buf.WriteString("#EXT-X-TARGETDURATION:2\n") + + // Pre-calculated exact durations + leftover := tp.videoFile.VideoStreamDuration + segment := 0 + for leftover > 0 { + thisLength := math.Min(float64(segmentLength), leftover) + fmt.Fprintf(&buf, "#EXTINF:%f,\n", thisLength) + fmt.Fprintf(&buf, "segment_%06d.ts\n", segment) + leftover -= thisLength + segment++ + } + + return buf.Bytes() +} +``` + +### 3. Anti-Jitter Mechanisms + +#### A. Segment Atomicity +```typescript +// Frontend segment validation +interface SegmentValidation { + segmentNumber: number; + expectedDuration: number; + actualDuration?: number; + isComplete: boolean; + isValid: boolean; +} + +const validateSegment = async ( + segmentNumber: number, + expectedDuration: number +): Promise => { + try { + const response = await fetch(`/api/segments/${segmentNumber}`); + const contentLength = response.headers.get('content-length'); + const actualDuration = await calculateDurationFromBytes( + parseInt(contentLength || '0') + ); + + return { + segmentNumber, + expectedDuration, + actualDuration, + isComplete: actualDuration >= expectedDuration * 0.95, + isValid: actualDuration >= expectedDuration * 0.8 + }; + } catch (error) { + return { + segmentNumber, + expectedDuration, + isComplete: false, + isValid: false + }; + } +}; +``` + +#### B. Buffer State Management +```typescript +// From ScenePlayerScrubber.tsx - Smooth progress updates +const useSmoothProgress = (currentTime: number, duration: number) => { + const [displayTime, setDisplayTime] = useState(currentTime); + + useEffect(() => { + const targetTime = Math.min(currentTime, duration); + const diff = targetTime - displayTime; + + // Only update if significant change or approaching end + if (Math.abs(diff) > 0.1 || targetTime >= duration - 1) { + setDisplayTime(targetTime); + } + }, [currentTime, duration]); + + return displayTime; +}; +``` + +#### C. Seek Position Validation +```typescript +// Validate seek positions against actual buffer state +const validateSeekPosition = ( + requestedTime: number, + bufferedRanges: TimeRanges, + duration: number +): number => { + let validTime = requestedTime; + + // Find nearest buffered segment + for (let i = 0; i < bufferedRanges.length; i++) { + if ( + requestedTime >= bufferedRanges.start(i) && + requestedTime <= bufferedRanges.end(i) + ) { + return requestedTime; // Exact match + } + + if (requestedTime < bufferedRanges.start(i)) { + validTime = bufferedRanges.start(i); // Snap to nearest available + break; + } + } + + return Math.max(0, Math.min(validTime, duration)); +}; +``` + +## Real-time Transcoding Coordination + +### Backend Process Monitoring +```go +// From internal/manager/stream_segmented.go +type transcodeProcess struct { + videoFile *models.VideoFile + outputDir string + segmentCount int + + // Real-time state tracking + lastSegment int + waitingSegment chan int + + // Progress synchronization + mu sync.RWMutex + currentTime float64 + totalTime float64 +} + +func (tp *transcodeProcess) updateProgress(segment int, duration float64) { + tp.mu.Lock() + tp.currentTime = float64(segment) * 2.0 // 2-second segments + tp.mu.Unlock() + + // Notify waiting clients + select { + case tp.waitingSegment <- segment: + default: + } +} + +func (tp *transcodeProcess) getProgress() (float64, float64) { + tp.mu.RLock() + defer tp.mu.RUnlock() + return tp.currentTime, tp.totalTime +} +``` + +## Frontend Smoothing Layers + +### Multi-layer Progress Smoothing +```typescript +// Progressive enhancement for progress accuracy +const useAccurateProgress = ( + playerRef: RefObject, + totalDuration: number +) => { + const [progress, setProgress] = useState(0); + const [buffered, setBuffered] = useState(0); + const [isTranscoding, setIsTranscoding] = useState(true); + + useEffect(() => { + const video = playerRef.current; + if (!video) return; + + // Primary source: video.currentTime + const handleTimeUpdate = debounce(() => { + const newProgress = video.currentTime / totalDuration; + + // Prevent backward jumps + if (newProgress >= progress || newProgress >= 0.99) { + setProgress(Math.min(newProgress, 1)); + } + }, 100); + + // Secondary source: buffered ranges + const handleProgress = debounce(() => { + const bufferedEnd = video.buffered.length > 0 + ? video.buffered.end(video.buffered.length - 1) + : 0; + setBuffered(bufferedEnd / totalDuration); + }, 250); + + // Debounced updates for smooth UI + const debouncedTimeUpdate = debounce(handleTimeUpdate, 100); + const debouncedProgress = debounce(handleProgress, 250); + + video.addEventListener('timeupdate', debouncedTimeUpdate); + video.addEventListener('progress', debouncedProgress); + + return () => { + video.removeEventListener('timeupdate', debouncedTimeUpdate); + video.removeEventListener('progress', debouncedProgress); + }; + }, [playerRef, totalDuration]); + + return { progress, buffered, isTranscoding }; +}; +``` + +## Next.js Implementation Strategy + +### API Route with Progress Tracking +```typescript +// pages/api/transcode/[...slug].ts +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const { file, quality, start } = req.query; + + // 1. Extract precise duration first + const metadata = await extractVideoMetadata(file as string); + res.setHeader('X-Total-Duration', metadata.duration); + + // 2. Segment-based streaming + const segmentDuration = 2; // 2-second segments + const totalSegments = Math.ceil(metadata.duration / segmentDuration); + + // 3. Generate accurate manifest + const manifest = generateHLSManifest(totalSegments, segmentDuration, metadata.duration); + + res.setHeader('Content-Type', 'application/vnd.apple.mpegurl'); + res.send(manifest); +} + +// Segment serving with validation +export const serveSegment = async (req: NextApiRequest, res: NextApiResponse) => { + const { segment } = req.query; + const segmentPath = `segments/segment_${segment.toString().padStart(6, '0')}.ts`; + + // Validate segment exists and is complete + try { + const stats = await fs.stat(segmentPath); + if (stats.size === 0) { + return res.status(404).end(); + } + + // Check segment integrity + const isValid = await validateSegmentIntegrity(segmentPath); + if (!isValid) { + return res.status(410).end(); // Gone - segment invalid + } + + res.setHeader('Content-Type', 'video/mp2t'); + res.setHeader('Content-Length', stats.size); + + const stream = createReadStream(segmentPath); + stream.pipe(res); + + // Cleanup on client disconnect + req.on('close', () => { + stream.destroy(); + }); + } catch (error) { + res.status(404).end(); + } +}; +``` + +### Client-Side Integration +```typescript +// hooks/useTranscodeProgress.ts +import { useState, useEffect, useRef } from 'react'; + +export const useTranscodeProgress = ( + videoUrl: string, + totalDuration: number +) => { + const [progress, setProgress] = useState(0); + const [buffered, setBuffered] = useState(0); + const [isTranscoding, setIsTranscoding] = useState(true); + const [quality, setQuality] = useState('720p'); + + const videoRef = useRef(null); + const progressRef = useRef(0); + const lastUpdateRef = useRef(Date.now()); + + useEffect(() => { + const video = videoRef.current; + if (!video) return; + + // Prevent backward jumps and smooth updates + const handleTimeUpdate = () => { + const now = Date.now(); + if (now - lastUpdateRef.current < 100) return; // Throttle updates + + const newProgress = video.currentTime / totalDuration; + + // Only allow forward progress or small backward adjustments + if (newProgress >= progressRef.current - 0.005) { + progressRef.current = newProgress; + setProgress(newProgress); + lastUpdateRef.current = now; + } + }; + + // Monitor transcoding completion + const checkTranscodingStatus = async () => { + try { + const response = await fetch(`/api/transcode/status?url=${encodeURIComponent(videoUrl)}`); + const data = await response.json(); + setIsTranscoding(data.transcoding); + + // Update total duration if changed + if (data.duration && Math.abs(data.duration - totalDuration) > 1) { + // Handle duration correction + } + } catch (error) { + console.error('Failed to check transcoding status:', error); + } + }; + + // Monitor buffer state + const handleBufferUpdate = () => { + if (video.buffered.length > 0) { + const bufferedEnd = video.buffered.end(video.buffered.length - 1); + setBuffered(bufferedEnd / totalDuration); + } + }; + + video.addEventListener('timeupdate', handleTimeUpdate); + video.addEventListener('progress', handleBufferUpdate); + const interval = setInterval(checkTranscodingStatus, 2000); + + return () => { + video.removeEventListener('timeupdate', handleTimeUpdate); + video.removeEventListener('progress', handleBufferUpdate); + clearInterval(interval); + }; + }, [videoUrl, totalDuration]); + + return { + progress, + buffered, + isTranscoding, + videoRef, + quality, + setQuality + }; +}; +``` + +### Advanced Anti-Jitter Features + +#### 1. Predictive Buffering +```typescript +// Predict next segment and pre-buffer +const usePredictiveBuffering = ( + currentTime: number, + totalDuration: number, + playbackRate: number +) => { + const [nextSegment, setNextSegment] = useState(null); + + useEffect(() => { + const currentSegment = Math.floor(currentTime / 2); // 2-second segments + const bufferAhead = Math.ceil(playbackRate * 10); // 10 seconds ahead + const targetSegment = currentSegment + bufferAhead; + + if (targetSegment * 2 < totalDuration) { + setNextSegment(targetSegment); + } + }, [currentTime, totalDuration, playbackRate]); + + return nextSegment; +}; +``` + +#### 2. Quality Adaptation +```typescript +// Adapt quality based on buffer state +const useAdaptiveQuality = ( + buffered: number, + progress: number, + bandwidth: number +) => { + const [quality, setQuality] = useState('720p'); + + useEffect(() => { + const bufferRatio = buffered - progress; + + if (bufferRatio < 0.1) { + setQuality('480p'); // Lower quality for poor buffering + } else if (bandwidth > 5000000) { + setQuality('1080p'); // Higher quality for good bandwidth + } else { + setQuality('720p'); // Default quality + } + }, [buffered, progress, bandwidth]); + + return quality; +}; +``` + +## Key Implementation Guidelines + +### 1. Duration Accuracy +- Always extract duration from video stream metadata, not container +- Use ffprobe/ffmpeg for precise duration extraction +- Validate duration against actual transcoded segments +- Handle edge cases (variable frame rate, corrupted metadata) + +### 2. Progress Synchronization +- Use segment-based streaming (HLS/DASH) for granular control +- Implement atomic segment generation (never overwrite existing) +- Provide real-time transcoding status to frontend +- Handle dynamic duration changes gracefully + +### 3. Anti-Jitter Strategies +- **Segment Validation**: Only serve complete, validated segments +- **State Consistency**: Maintain consistent state between backend and frontend +- **Smooth Updates**: Debounce progress updates and prevent backward jumps +- **Buffer Awareness**: Track actual buffer state vs transcoding progress + +### 4. Error Handling +- **Graceful Degradation**: Remove incomplete segments rather than serving corrupted data +- **Timeout Management**: 15-second timeout for segment generation +- **Fallback Strategies**: Multiple quality levels for connection issues +- **Recovery Mechanisms**: Restart failed segments without user intervention + +This comprehensive approach ensures smooth, accurate progress bars even during live transcoding, eliminating the jittery behavior common in streaming applications. \ No newline at end of file diff --git a/docs/05-nextjs-adaptation-guide.md b/docs/05-nextjs-adaptation-guide.md new file mode 100644 index 0000000..5ad875c --- /dev/null +++ b/docs/05-nextjs-adaptation-guide.md @@ -0,0 +1,887 @@ +# 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 { + 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(); + 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({ + progress: 0, + buffered: 0, + isTranscoding: true, + currentTime: 0, + duration: totalDuration + }); + + const videoRef = useRef(null); + const lastProgressRef = useRef(0); + const lastUpdateRef = useRef(Date.now()); + const abortControllerRef = useRef(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 = ({ + 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 ( +
+