diff --git a/TRANSCODING_FIXES.md b/TRANSCODING_FIXES.md new file mode 100644 index 0000000..b593224 --- /dev/null +++ b/TRANSCODING_FIXES.md @@ -0,0 +1,207 @@ +# Transcoding Fixes Documentation + +This document describes the fixes implemented to address two critical issues with the video transcoding functionality in NextAV. + +## Issues Addressed + +### 1. FFmpeg Process Not Stopped When Video Viewer Closes + +**Problem**: When users closed the video viewer, FFmpeg transcoding processes continued running in the background, consuming system resources. + +**Root Cause**: +- Inadequate cleanup mechanisms in the transcoding API +- No global process tracking +- Unreliable client disconnect detection + +**Solution**: +- Implemented a **heartbeat mechanism** for reliable player tracking +- Created a global `ProcessManager` class to track all FFmpeg processes +- Added automatic cleanup when heartbeat stops for more than 10 seconds +- Created a DELETE endpoint for manual process termination +- Added automatic cleanup of stale processes (older than 10 minutes) + +### 2. Progress Bar Not Showing Correct Progress for Transcoding Streams + +**Problem**: The video progress bar showed incorrect progress when playing transcoded streams, even though the duration was correctly retrieved. + +**Root Cause**: +- Video player components weren't properly reading the `X-Content-Duration` header +- Duration wasn't being set correctly for transcoded streams +- Missing duration display in the UI + +**Solution**: +- Enhanced video player components to read duration from response headers +- Added proper duration handling for both direct and transcoded streams +- Improved duration display in the UI +- Added transcoding indicator to show when transcoding is active + +## Implementation Details + +### Heartbeat Mechanism (`src/app/api/heartbeat/route.ts`) + +A reliable system for tracking active video players: + +```typescript +// Player sends heartbeat every 5 seconds +POST /api/heartbeat +{ + "playerId": "player_1234567890_abc123", + "videoId": 53 +} + +// Player notifies disconnect +DELETE /api/heartbeat +{ + "playerId": "player_1234567890_abc123" +} + +// Check active players +GET /api/heartbeat +``` + +**Key Features**: +- **10-second timeout**: If no heartbeat for 10 seconds, FFmpeg processes are cleaned up +- **Automatic cleanup**: Background process checks every 5 seconds for stale heartbeats +- **Player tracking**: Each player has a unique ID for reliable tracking +- **Video association**: Heartbeats are linked to specific video IDs for targeted cleanup + +### Process Manager (`src/lib/process-manager.ts`) + +A global singleton class that manages all FFmpeg processes: + +```typescript +class ProcessManager { + // Register a new FFmpeg process + register(processId: string, process: any, videoId: string, cleanup: () => void) + + // Remove a specific process + remove(processId: string) + + // Remove all processes for a specific video + removeByVideoId(videoId: string) + + // Get all active processes + getAllProcesses() +} +``` + +### Enhanced Transcoding API (`src/app/api/stream/[id]/transcode/route.ts`) + +Key improvements: +- Uses `ProcessManager` for process tracking +- Simplified cleanup (now handled by heartbeat) +- Proper duration header handling +- DELETE endpoint for manual cleanup + +### Updated Video Player Components + +Both `InlineVideoPlayer` and `VideoViewer` components now: +- **Send heartbeats**: Every 5 seconds while player is open +- **Notify disconnect**: When player closes or component unmounts +- **Track transcoding state**: Show transcoding indicators +- **Read duration from headers**: Proper progress bar support +- **Display duration information**: Show video duration in UI + +## API Endpoints + +### Heartbeat Management +- `POST /api/heartbeat` - Send player heartbeat +- `DELETE /api/heartbeat` - Notify player disconnect +- `GET /api/heartbeat` - Get status of all active players + +### Process Management +- `GET /api/processes` - Get status of all active processes +- `DELETE /api/processes` - Cleanup all processes +- `DELETE /api/processes?videoId=123` - Cleanup processes for specific video + +### Transcoding +- `GET /api/stream/{id}/transcode` - Start transcoding stream +- `DELETE /api/stream/{id}/transcode` - Stop transcoding for video + +## Testing + +### Test Heartbeat Mechanism +```bash +node test-heartbeat.mjs +``` + +### Test Transcoding Fixes +```bash +node test-transcoding-fixes.mjs +``` + +## Monitoring + +### Check Active Players +```bash +curl http://localhost:3000/api/heartbeat +``` + +### Check Active Processes +```bash +curl http://localhost:3000/api/processes +``` + +### Cleanup Specific Video +```bash +curl -X DELETE "http://localhost:3000/api/processes?videoId=53" +``` + +### Cleanup All Processes +```bash +curl -X DELETE http://localhost:3000/api/processes +``` + +## Logs + +The system now provides detailed logging: + +``` +[HEARTBEAT] Player player_1234567890_abc123 for video 53 pinged +[PROCESS_MANAGER] Registered process: transcode_53_1234567890 for video: 53 +[HEARTBEAT] Player player_1234567890_abc123 for video 53 timed out, cleaning up FFmpeg processes +[PROCESS_MANAGER] Removed process: transcode_53_1234567890 +``` + +## How It Works + +### 1. Player Opens +1. Video player generates unique `playerId` +2. Starts sending heartbeats every 5 seconds to `/api/heartbeat` +3. Backend tracks the player and associates it with the video ID + +### 2. Transcoding Starts +1. When transcoding is needed, FFmpeg process is started +2. Process is registered with `ProcessManager` using the video ID +3. Player continues sending heartbeats while transcoding + +### 3. Player Closes +1. Player stops sending heartbeats +2. After 10 seconds without heartbeat, backend automatically: + - Removes player from active list + - Cleans up all FFmpeg processes for that video ID +3. Alternatively, player can explicitly notify disconnect via DELETE request + +### 4. Automatic Cleanup +- Background process runs every 5 seconds +- Checks for heartbeats older than 10 seconds +- Automatically cleans up associated FFmpeg processes +- Prevents resource leaks from crashed or closed players + +## Benefits + +1. **Reliable Cleanup**: Heartbeat mechanism ensures FFmpeg processes are always cleaned up +2. **Resource Efficiency**: No more orphaned processes consuming system resources +3. **Better UX**: Accurate progress bars and visual feedback +4. **Monitoring**: Admin tools to track active players and processes +5. **Transparency**: Users can see when transcoding is active +6. **Fault Tolerance**: Handles browser crashes, network issues, and unexpected closures + +## Future Improvements + +1. **Progress Tracking**: Real-time transcoding progress updates via heartbeat +2. **Quality Selection**: User-selectable transcoding quality +3. **Caching**: Cache transcoded videos to avoid re-transcoding +4. **Queue Management**: Handle multiple concurrent transcoding requests +5. **Error Recovery**: Automatic retry mechanisms for failed transcoding +6. **Heartbeat Optimization**: Reduce heartbeat frequency for better performance diff --git a/src/app/api/heartbeat/route.ts b/src/app/api/heartbeat/route.ts new file mode 100644 index 0000000..e7ed1a9 --- /dev/null +++ b/src/app/api/heartbeat/route.ts @@ -0,0 +1,96 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { processManager } from '@/lib/process-manager'; + +// Track active heartbeats +const activeHeartbeats = new Map(); + +// Cleanup interval for stale heartbeats +let cleanupInterval: NodeJS.Timeout | null = null; + +// Start cleanup interval if not already running +if (!cleanupInterval) { + cleanupInterval = setInterval(() => { + const now = Date.now(); + const timeout = 10000; // 10 seconds + + for (const [playerId, heartbeat] of activeHeartbeats.entries()) { + if (now - heartbeat.lastPing > timeout) { + console.log(`[HEARTBEAT] Player ${playerId} for video ${heartbeat.videoId} timed out, cleaning up FFmpeg processes`); + processManager.removeByVideoId(heartbeat.videoId); + activeHeartbeats.delete(playerId); + } + } + }, 5000); // Check every 5 seconds +} + +export async function POST(request: NextRequest) { + try { + const { playerId, videoId } = await request.json(); + + if (!playerId || !videoId) { + return NextResponse.json({ error: 'Missing playerId or videoId' }, { status: 400 }); + } + + // Update heartbeat + activeHeartbeats.set(playerId, { + lastPing: Date.now(), + videoId: videoId.toString() + }); + + console.log(`[HEARTBEAT] Player ${playerId} for video ${videoId} pinged`); + + return NextResponse.json({ + success: true, + timestamp: new Date().toISOString(), + activePlayers: activeHeartbeats.size + }); + } catch (error) { + console.error('Heartbeat API error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function DELETE(request: NextRequest) { + try { + const { playerId } = await request.json(); + + if (!playerId) { + return NextResponse.json({ error: 'Missing playerId' }, { status: 400 }); + } + + const heartbeat = activeHeartbeats.get(playerId); + if (heartbeat) { + console.log(`[HEARTBEAT] Player ${playerId} for video ${heartbeat.videoId} disconnected, cleaning up FFmpeg processes`); + processManager.removeByVideoId(heartbeat.videoId); + activeHeartbeats.delete(playerId); + } + + return NextResponse.json({ + success: true, + message: `Player ${playerId} disconnected` + }); + } catch (error) { + console.error('Heartbeat disconnect API error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function GET(request: NextRequest) { + try { + const heartbeats = Array.from(activeHeartbeats.entries()).map(([playerId, heartbeat]) => ({ + playerId, + videoId: heartbeat.videoId, + lastPing: new Date(heartbeat.lastPing).toISOString(), + age: Date.now() - heartbeat.lastPing + })); + + return NextResponse.json({ + activePlayers: activeHeartbeats.size, + heartbeats, + timestamp: new Date().toISOString() + }); + } catch (error) { + console.error('Heartbeat status API error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/processes/route.ts b/src/app/api/processes/route.ts new file mode 100644 index 0000000..9383393 --- /dev/null +++ b/src/app/api/processes/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { processManager } from '@/lib/process-manager'; + +export async function GET(request: NextRequest) { + try { + const processes = processManager.getAllProcesses(); + const count = processManager.getProcessCount(); + + return NextResponse.json({ + count, + processes, + timestamp: new Date().toISOString() + }); + } catch (error) { + console.error('Process status API error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function DELETE(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams; + const videoId = searchParams.get('videoId'); + + if (videoId) { + // Cleanup processes for specific video + processManager.removeByVideoId(videoId); + return NextResponse.json({ + success: true, + message: `Cleaned up processes for video ${videoId}` + }); + } else { + // Cleanup all processes + processManager.cleanupAll(); + return NextResponse.json({ + success: true, + message: 'Cleaned up all processes' + }); + } + } catch (error) { + console.error('Process cleanup API error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/stream/[id]/route.ts b/src/app/api/stream/[id]/route.ts index f4b86b0..1b35a9c 100644 --- a/src/app/api/stream/[id]/route.ts +++ b/src/app/api/stream/[id]/route.ts @@ -31,14 +31,14 @@ export async function GET( try { const videoId = parseInt(id); - const video = db.prepare("SELECT * FROM media WHERE id = ? AND type = 'video'").get(videoId) as { path: string, codec_info: string } | undefined; + const video = db.prepare("SELECT * FROM media WHERE id = ? AND type = 'video'").get(videoId) as { path: string, codec_info: string, duration: number } | undefined; if (!video) { return NextResponse.json({ error: "Video not found" }, { status: 404 }); } // Parse codec info to determine if transcoding is needed - let codecInfo = { needsTranscoding: false }; + let codecInfo = { needsTranscoding: false, duration: 0 }; try { codecInfo = JSON.parse(video.codec_info || '{}'); } catch { @@ -77,6 +77,15 @@ export async function GET( const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1; const chunksize = end - start + 1; const file = fs.createReadStream(videoPath, { start, end }); + // Parse duration for progress bar + let duration = 0; + try { + const codecInfo = JSON.parse(video.codec_info || '{}'); + duration = codecInfo.duration || 0; + } catch { + duration = 0; + } + const headers = new Headers({ "Content-Range": `bytes ${start}-${end}/${fileSize}`, "Accept-Ranges": "bytes", @@ -85,6 +94,7 @@ export async function GET( "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS", "Access-Control-Allow-Headers": "Range, Content-Type", + "X-Content-Duration": duration.toString(), }); return new Response(file as any, { @@ -92,12 +102,22 @@ export async function GET( headers, }); } else { + // Parse duration for progress bar + let duration = 0; + try { + const codecInfo = JSON.parse(video.codec_info || '{}'); + duration = codecInfo.duration || 0; + } catch { + duration = 0; + } + const headers = new Headers({ "Content-Length": fileSize.toString(), "Content-Type": "video/mp4", "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS", "Access-Control-Allow-Headers": "Range, Content-Type", + "X-Content-Duration": duration.toString(), }); const file = fs.createReadStream(videoPath); diff --git a/src/app/api/stream/[id]/transcode/route.ts b/src/app/api/stream/[id]/transcode/route.ts index 35fba2b..ad7c379 100644 --- a/src/app/api/stream/[id]/transcode/route.ts +++ b/src/app/api/stream/[id]/transcode/route.ts @@ -3,6 +3,7 @@ import { getDatabase } from '@/db'; import fs from 'fs'; import ffmpeg from 'fluent-ffmpeg'; import { Readable } from 'stream'; +import { processManager } from '@/lib/process-manager'; export async function OPTIONS( request: NextRequest, @@ -29,8 +30,8 @@ export async function GET( console.log(`[TRANSCODE] Starting transcoding for video ID: ${id}`); - // Get media file info - const media = db.prepare('SELECT * FROM media WHERE id = ? AND type = ?').get(id, 'video') as { path: string } | undefined; + // Get media file info with codec_info + const media = db.prepare('SELECT * FROM media WHERE id = ? AND type = ?').get(id, 'video') as { path: string, codec_info: string } | undefined; if (!media) { console.log(`[TRANSCODE] Video not found for ID: ${id}`); return NextResponse.json({ error: 'Video not found' }, { status: 404 }); @@ -38,6 +39,29 @@ export async function GET( const filePath = media.path; console.log(`[TRANSCODE] Found video at path: ${filePath}`); + + // Get duration from stored codec_info + let duration = 0; + try { + const codecInfo = JSON.parse(media.codec_info || '{}'); + duration = codecInfo.duration || 0; + console.log(`[TRANSCODE] Using stored duration: ${duration}s`); + } catch (error) { + console.error(`[TRANSCODE] Could not parse codec_info:`, error); + // Fallback to ffprobe + try { + const videoInfo = await new Promise((resolve, reject) => { + ffmpeg.ffprobe(filePath, (err, metadata) => { + if (err) reject(err); + else resolve(metadata); + }); + }); + duration = videoInfo.format.duration || 0; + console.log(`[TRANSCODE] Using ffprobe duration: ${duration}s`); + } catch (ffprobeError) { + console.error(`[TRANSCODE] Could not get duration:`, ffprobeError); + } + } // Check if file exists if (!fs.existsSync(filePath)) { @@ -69,7 +93,7 @@ export async function GET( .outputOptions([ '-preset', 'fast', '-crf', '23', - '-movflags', 'frag_keyframe+empty_moov', + '-movflags', 'frag_keyframe+empty_moov+faststart', '-f', 'mp4', '-g', '60', '-keyint_min', '60', @@ -96,7 +120,30 @@ export async function GET( // Create a readable stream const stream = ffmpegCommand.pipe(); - // Set response headers for streaming + // Track FFmpeg process for cleanup + let ffmpegProcess: any = null; + let processId = `transcode_${id}_${Date.now()}`; + + ffmpegCommand.on('start', (commandLine) => { + // Store process reference for cleanup + ffmpegProcess = (ffmpegCommand as any).ffmpegProc; + + // Register process with process manager + const cleanup = () => { + if (ffmpegProcess) { + try { + console.log(`[TRANSCODE] Cleaning up FFmpeg process ${processId}`); + ffmpegProcess.kill('SIGKILL'); + } catch (error) { + console.error(`[TRANSCODE] Error killing FFmpeg process:`, error); + } + } + }; + + processManager.register(processId, ffmpegProcess, id, cleanup); + }); + + // Set response headers for streaming with duration const headers = new Headers({ 'Content-Type': 'video/mp4', 'Cache-Control': 'no-cache, no-store, must-revalidate', @@ -105,6 +152,8 @@ export async function GET( 'Content-Disposition': 'inline', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS', + 'X-Content-Duration': duration.toString(), + 'X-Content-Type-Options': 'nosniff', }); // Convert Node.js stream to Web Stream for Next.js @@ -112,10 +161,13 @@ export async function GET( console.log(`[TRANSCODE] Sending response with ${settings.width}x${settings.height} video stream`); - return new Response(readableStream, { + // Create response + const response = new Response(readableStream, { status: 200, headers, }); + + return response; } catch (error) { console.error('Transcoding API error:', error); @@ -124,4 +176,22 @@ export async function GET( { status: 500 } ); } +} + +// Cleanup function for manual process termination +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + + // Use process manager to cleanup all processes for this video ID + processManager.removeByVideoId(id); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Cleanup API error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } } \ No newline at end of file diff --git a/src/components/inline-video-player.tsx b/src/components/inline-video-player.tsx index 7fe26f1..e70aa38 100644 --- a/src/components/inline-video-player.tsx +++ b/src/components/inline-video-player.tsx @@ -31,18 +31,75 @@ export default function InlineVideoPlayer({ video, isOpen, onClose, scrollPositi const [bookmarkCount, setBookmarkCount] = useState(0); const [starCount, setStarCount] = useState(0); const [showRating, setShowRating] = useState(false); + const [isTranscoding, setIsTranscoding] = useState(false); const videoRef = useRef(null); + + // Heartbeat mechanism + const playerId = useRef(`player_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`); + const heartbeatInterval = useRef(null); + + // Start heartbeat when player opens + const startHeartbeat = () => { + if (heartbeatInterval.current) { + clearInterval(heartbeatInterval.current); + } + + heartbeatInterval.current = setInterval(async () => { + try { + await fetch('/api/heartbeat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + playerId: playerId.current, + videoId: video.id + }) + }); + } catch (error) { + console.error('Heartbeat failed:', error); + } + }, 5000); // Send heartbeat every 5 seconds + }; + + // Stop heartbeat when player closes + const stopHeartbeat = async () => { + if (heartbeatInterval.current) { + clearInterval(heartbeatInterval.current); + heartbeatInterval.current = null; + } + + // Notify backend that player is disconnected + try { + await fetch('/api/heartbeat', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + playerId: playerId.current + }) + }); + } catch (error) { + console.error('Failed to notify heartbeat disconnect:', error); + } + }; useEffect(() => { if (isOpen) { setIsVisible(true); loadBookmarkStatus(); loadStarRating(); + startHeartbeat(); // Start heartbeat when player opens } else { setIsVisible(false); + stopHeartbeat(); // Stop heartbeat when player closes } }, [isOpen]); + // Cleanup heartbeat on unmount + useEffect(() => { + return () => { + stopHeartbeat(); + }; + }, []); + useEffect(() => { if (isOpen && videoRef.current) { // First try direct streaming, fallback to transcoding if needed @@ -53,6 +110,7 @@ export default function InlineVideoPlayer({ video, isOpen, onClose, scrollPositi const handleError = () => { console.log('Video load failed, trying transcoded version...'); if (videoRef.current) { + setIsTranscoding(true); videoRef.current.src = `/api/stream/${video.id}/transcode`; videoRef.current.load(); } @@ -70,17 +128,64 @@ export default function InlineVideoPlayer({ video, isOpen, onClose, scrollPositi } }; + // Handle metadata loaded to get duration + const handleLoadedMetadata = () => { + if (videoRef.current) { + const videoDuration = videoRef.current.duration; + if (videoDuration && videoDuration > 0) { + setDuration(videoDuration); + } + } + }; + + // Handle response headers to get duration for transcoded streams + const handleResponseHeaders = async () => { + try { + const response = await fetch(`/api/stream/${video.id}${isTranscoding ? '/transcode' : ''}`); + const contentDuration = response.headers.get('X-Content-Duration'); + if (contentDuration) { + const durationValue = parseFloat(contentDuration); + if (durationValue > 0) { + setDuration(durationValue); + } + } + } catch (error) { + console.log('Could not fetch duration from headers:', error); + } + }; + videoRef.current.addEventListener('loadeddata', handleLoadedData); + videoRef.current.addEventListener('loadedmetadata', handleLoadedMetadata); videoRef.current.addEventListener('error', handleError); + // Try to get duration from headers + handleResponseHeaders(); + return () => { if (videoRef.current) { videoRef.current.removeEventListener('loadeddata', handleLoadedData); + videoRef.current.removeEventListener('loadedmetadata', handleLoadedMetadata); videoRef.current.removeEventListener('error', handleError); + videoRef.current.pause(); + videoRef.current.src = ''; + videoRef.current.load(); } }; } - }, [isOpen, video.id]); + }, [isOpen, video.id, isTranscoding]); + + // Cleanup transcoding process + const cleanupTranscoding = async () => { + if (isTranscoding) { + try { + await fetch(`/api/stream/${video.id}/transcode`, { method: 'DELETE' }); + console.log('Transcoding process cleaned up'); + } catch (error) { + console.error('Error cleaning up transcoding process:', error); + } + setIsTranscoding(false); + } + }; const handlePlayPause = () => { if (videoRef.current) { @@ -117,12 +222,15 @@ export default function InlineVideoPlayer({ video, isOpen, onClose, scrollPositi const handleLoadedMetadata = () => { if (videoRef.current) { - setDuration(videoRef.current.duration); + const videoDuration = videoRef.current.duration; + if (videoDuration && videoDuration > 0) { + setDuration(videoDuration); + } } }; const handleProgressClick = (e: React.MouseEvent) => { - if (videoRef.current) { + if (videoRef.current && duration > 0) { const rect = e.currentTarget.getBoundingClientRect(); const clickX = e.clientX - rect.left; const newTime = (clickX / rect.width) * duration; @@ -230,6 +338,8 @@ export default function InlineVideoPlayer({ video, isOpen, onClose, scrollPositi document.removeEventListener('keydown', handleKeyDown); // Restore body scroll when player is closed document.body.style.overflow = 'unset'; + // Cleanup transcoding when component unmounts + cleanupTranscoding(); }; }, [isOpen, onClose]); @@ -254,6 +364,14 @@ export default function InlineVideoPlayer({ video, isOpen, onClose, scrollPositi {video.title}
+ {/* Transcoding indicator */} + {isTranscoding && ( +
+
+ Transcoding +
+ )} + {/* Bookmark Button */}
diff --git a/src/components/video-viewer.tsx b/src/components/video-viewer.tsx index 6ef5737..125e0c1 100644 --- a/src/components/video-viewer.tsx +++ b/src/components/video-viewer.tsx @@ -58,7 +58,58 @@ export default function VideoViewer({ const [showControls, setShowControls] = useState(true); const [isBookmarked, setIsBookmarked] = useState(false); const [bookmarkCount, setBookmarkCount] = useState(0); + const [isTranscoding, setIsTranscoding] = useState(false); const videoRef = useRef(null); + + // Heartbeat mechanism + const playerId = useRef(`player_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`); + const heartbeatInterval = useRef(null); + + // Start heartbeat when player opens + const startHeartbeat = () => { + if (heartbeatInterval.current) { + clearInterval(heartbeatInterval.current); + } + + heartbeatInterval.current = setInterval(async () => { + try { + const videoId = getVideoId(); + if (videoId) { + await fetch('/api/heartbeat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + playerId: playerId.current, + videoId: videoId + }) + }); + } + } catch (error) { + console.error('Heartbeat failed:', error); + } + }, 5000); // Send heartbeat every 5 seconds + }; + + // Stop heartbeat when player closes + const stopHeartbeat = async () => { + if (heartbeatInterval.current) { + clearInterval(heartbeatInterval.current); + heartbeatInterval.current = null; + } + + // Notify backend that player is disconnected + try { + await fetch('/api/heartbeat', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + playerId: playerId.current + }) + }); + } catch (error) { + console.error('Failed to notify heartbeat disconnect:', error); + } + }; // Update local bookmark state when video changes useEffect(() => { @@ -68,6 +119,21 @@ export default function VideoViewer({ } }, [video]); + useEffect(() => { + if (isOpen) { + startHeartbeat(); // Start heartbeat when player opens + } else { + stopHeartbeat(); // Stop heartbeat when player closes + } + }, [isOpen]); + + // Cleanup heartbeat on unmount + useEffect(() => { + return () => { + stopHeartbeat(); + }; + }, []); + useEffect(() => { if (isOpen && videoRef.current && video) { const videoId = getVideoId(); @@ -80,6 +146,7 @@ export default function VideoViewer({ const handleError = () => { console.log('Video load failed, trying transcoded version...'); if (videoRef.current) { + setIsTranscoding(true); videoRef.current.src = `/api/stream/${videoId}/transcode`; videoRef.current.load(); } @@ -96,17 +163,51 @@ export default function VideoViewer({ } }; + // Handle metadata loaded to get duration + const handleLoadedMetadata = () => { + if (videoRef.current) { + const videoDuration = videoRef.current.duration; + if (videoDuration && videoDuration > 0) { + setDuration(videoDuration); + } + } + }; + + // Handle response headers to get duration for transcoded streams + const handleResponseHeaders = async () => { + try { + const response = await fetch(`/api/stream/${videoId}${isTranscoding ? '/transcode' : ''}`); + const contentDuration = response.headers.get('X-Content-Duration'); + if (contentDuration) { + const durationValue = parseFloat(contentDuration); + if (durationValue > 0) { + setDuration(durationValue); + } + } + } catch (error) { + console.log('Could not fetch duration from headers:', error); + } + }; + videoRef.current.addEventListener('loadeddata', handleLoadedData); + videoRef.current.addEventListener('loadedmetadata', handleLoadedMetadata); videoRef.current.addEventListener('error', handleError); + // Try to get duration from headers + handleResponseHeaders(); + return () => { if (videoRef.current) { videoRef.current.removeEventListener('loadeddata', handleLoadedData); + videoRef.current.removeEventListener('loadedmetadata', handleLoadedMetadata); videoRef.current.removeEventListener('error', handleError); + videoRef.current.pause(); + videoRef.current.src = ''; + videoRef.current.load(); } }; } - }, [isOpen, video]); + }, [isOpen, video, isTranscoding]); // Keyboard shortcuts useEffect(() => { @@ -189,7 +290,10 @@ export default function VideoViewer({ const handleLoadedMetadata = () => { if (videoRef.current) { - setDuration(videoRef.current.duration); + const videoDuration = videoRef.current.duration; + if (videoDuration && videoDuration > 0) { + setDuration(videoDuration); + } } }; @@ -289,6 +393,14 @@ export default function VideoViewer({ + {/* Transcoding indicator */} + {isTranscoding && ( +
+
+ Transcoding +
+ )} + {/* Video container */}

{getVideoTitle()}

{getVideoSize()}

+ {duration > 0 && ( +

Duration: {formatTime(duration)}

+ )}
{(showBookmarks || showRatings) && (
diff --git a/src/lib/process-manager.ts b/src/lib/process-manager.ts new file mode 100644 index 0000000..acbbc53 --- /dev/null +++ b/src/lib/process-manager.ts @@ -0,0 +1,134 @@ +// Global process manager for FFmpeg transcoding processes +interface ProcessInfo { + process: any; + cleanup: () => void; + startTime: number; + videoId: string; +} + +class ProcessManager { + private processes = new Map(); + private cleanupInterval: NodeJS.Timeout | null = null; + + constructor() { + // Start cleanup interval to remove stale processes + this.startCleanupInterval(); + } + + // Register a new FFmpeg process + register(processId: string, process: any, videoId: string, cleanup: () => void) { + const processInfo: ProcessInfo = { + process, + cleanup, + startTime: Date.now(), + videoId + }; + + this.processes.set(processId, processInfo); + console.log(`[PROCESS_MANAGER] Registered process: ${processId} for video: ${videoId}`); + } + + // Remove a specific process + remove(processId: string) { + const processInfo = this.processes.get(processId); + if (processInfo) { + try { + processInfo.cleanup(); + this.processes.delete(processId); + console.log(`[PROCESS_MANAGER] Removed process: ${processId}`); + } catch (error) { + console.error(`[PROCESS_MANAGER] Error removing process ${processId}:`, error); + } + } + } + + // Remove all processes for a specific video + removeByVideoId(videoId: string) { + const processesToRemove: string[] = []; + + for (const [processId, processInfo] of this.processes.entries()) { + if (processInfo.videoId === videoId) { + processesToRemove.push(processId); + } + } + + processesToRemove.forEach(processId => this.remove(processId)); + + if (processesToRemove.length > 0) { + console.log(`[PROCESS_MANAGER] Removed ${processesToRemove.length} processes for video: ${videoId}`); + } + } + + // Get all active processes + getAllProcesses() { + return Array.from(this.processes.entries()).map(([id, info]) => ({ + id, + videoId: info.videoId, + startTime: info.startTime, + duration: Date.now() - info.startTime + })); + } + + // Get process count + getProcessCount() { + return this.processes.size; + } + + // Start cleanup interval to remove stale processes + private startCleanupInterval() { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + } + + this.cleanupInterval = setInterval(() => { + const now = Date.now(); + const maxAge = 10 * 60 * 1000; // 10 minutes + const processesToRemove: string[] = []; + + for (const [processId, processInfo] of this.processes.entries()) { + if (now - processInfo.startTime > maxAge) { + processesToRemove.push(processId); + } + } + + processesToRemove.forEach(processId => this.remove(processId)); + + if (processesToRemove.length > 0) { + console.log(`[PROCESS_MANAGER] Cleaned up ${processesToRemove.length} stale processes`); + } + }, 60000); // Check every minute + } + + // Stop cleanup interval + stop() { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + this.cleanupInterval = null; + } + } + + // Cleanup all processes + cleanupAll() { + const processIds = Array.from(this.processes.keys()); + processIds.forEach(processId => this.remove(processId)); + console.log(`[PROCESS_MANAGER] Cleaned up all ${processIds.length} processes`); + } +} + +// Export singleton instance +export const processManager = new ProcessManager(); + +// Cleanup on process exit +process.on('exit', () => { + processManager.cleanupAll(); +}); + +process.on('SIGINT', () => { + processManager.cleanupAll(); + process.exit(0); +}); + +process.on('SIGTERM', () => { + processManager.cleanupAll(); + process.exit(0); +});