feat: implement transcoding fixes and heartbeat management
- Added documentation for transcoding fixes addressing FFmpeg process management and progress bar accuracy. - Introduced a heartbeat mechanism to track active video players and ensure proper cleanup of FFmpeg processes. - Created a global ProcessManager class for managing FFmpeg processes and automatic cleanup of stale processes. - Enhanced video player components to support heartbeat notifications and display transcoding status. - Updated API routes for managing heartbeats and processes, improving resource efficiency and user experience.
This commit is contained in:
parent
280a718a63
commit
25f230e598
|
|
@ -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
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { processManager } from '@/lib/process-manager';
|
||||
|
||||
// Track active heartbeats
|
||||
const activeHeartbeats = new Map<string, { lastPing: number; videoId: string }>();
|
||||
|
||||
// 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
@ -39,6 +40,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<any>((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)) {
|
||||
return NextResponse.json({ error: 'File not found' }, { status: 404 });
|
||||
|
|
@ -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,11 +161,14 @@ 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);
|
||||
return NextResponse.json(
|
||||
|
|
@ -125,3 +177,21 @@ export async function GET(
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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<HTMLVideoElement>(null);
|
||||
|
||||
// Heartbeat mechanism
|
||||
const playerId = useRef(`player_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`);
|
||||
const heartbeatInterval = useRef<NodeJS.Timeout | null>(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<HTMLDivElement>) => {
|
||||
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}
|
||||
</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Transcoding indicator */}
|
||||
{isTranscoding && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-yellow-500/20 text-yellow-600 rounded-full">
|
||||
<div className="w-2 h-2 bg-yellow-500 rounded-full animate-pulse"></div>
|
||||
<span className="text-sm">Transcoding</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bookmark Button */}
|
||||
<button
|
||||
onClick={toggleBookmark}
|
||||
|
|
@ -391,6 +509,11 @@ export default function InlineVideoPlayer({ video, isOpen, onClose, scrollPositi
|
|||
<p className="text-muted-foreground text-sm">
|
||||
File size: {Math.round(video.size / 1024 / 1024)} MB
|
||||
</p>
|
||||
{duration > 0 && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Duration: {formatTime(duration)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -58,8 +58,59 @@ 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<HTMLVideoElement>(null);
|
||||
|
||||
// Heartbeat mechanism
|
||||
const playerId = useRef(`player_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`);
|
||||
const heartbeatInterval = useRef<NodeJS.Timeout | null>(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(() => {
|
||||
if (video && 'bookmark_count' in video) {
|
||||
|
|
@ -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({
|
|||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
{/* Transcoding indicator */}
|
||||
{isTranscoding && (
|
||||
<div className="absolute top-4 left-4 z-10 bg-yellow-500/20 text-yellow-600 rounded-full px-3 py-1.5 flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-yellow-500 rounded-full animate-pulse"></div>
|
||||
<span className="text-sm">Transcoding</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Video container */}
|
||||
<div
|
||||
className="relative w-full h-full bg-black rounded-lg overflow-hidden"
|
||||
|
|
@ -337,6 +449,9 @@ export default function VideoViewer({
|
|||
<div>
|
||||
<h3 className="text-white font-medium">{getVideoTitle()}</h3>
|
||||
<p className="text-gray-300 text-sm">{getVideoSize()}</p>
|
||||
{duration > 0 && (
|
||||
<p className="text-gray-300 text-sm">Duration: {formatTime(duration)}</p>
|
||||
)}
|
||||
</div>
|
||||
{(showBookmarks || showRatings) && (
|
||||
<div className="flex items-center gap-4">
|
||||
|
|
|
|||
|
|
@ -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<string, ProcessInfo>();
|
||||
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);
|
||||
});
|
||||
Loading…
Reference in New Issue