feat(hls): implement dynamic FFmpeg segmentation for merged .ts files
- Add tsSegmentationService to detect and re-segment merged .ts files using FFmpeg - Create HLSSessionManager to manage segmentation sessions with heartbeat and TTL - Enhance HLS playlist route to serve either virtual or FFmpeg-segmented playlists - Update segment route to serve FFmpeg-generated segments with proper caching headers - Add manual cleanup API for forced session cleanup and status inspection - Implement global HLS status endpoint with session and segmentation metrics - Introduce session lifecycle management with auto cleanup and error handling - Provide detailed logging for session creation, heartbeat, cleanup, and errors - Configure segment duration, temp directory, concurrency limits, and cleanup intervals - Ensure fallback to virtual segmentation on errors or unsupported cases - Document MPEG-TS protocol issues with naive merging and correct segmentation approach - Plan phased migration for scalable production readiness and monitoring integration
This commit is contained in:
parent
d54af0289f
commit
fef8f57e9b
|
|
@ -0,0 +1,304 @@
|
|||
## The Anatomy of a .ts File and Why Your Merged HLS Chunks are Failing
|
||||
|
||||
You've encountered a common and technically nuanced problem in the world of video streaming. Your intuition is correct: the simple act of merging individual HTTP Live Streaming (HLS) `.ts` (Transport Stream) files, as a download manager like IDM likely does, and then trying to play them back as a single HLS stream, is indeed breaking fundamental aspects of the `.ts` file protocol. This leads to the parsing errors you're seeing in hls.js.
|
||||
|
||||
Here is a detailed breakdown of the `.ts` file protocol, why your current approach is failing, and how to correctly handle this situation for your personal web video site.
|
||||
|
||||
### Understanding the MPEG Transport Stream (.ts) Protocol
|
||||
|
||||
At its core, a `.ts` file is a container format designed for transmitting and storing audio, video, and data in a way that is resilient to errors. It's the standard for broadcast systems like DVB and ATSC, and a cornerstone of HLS. The key to understanding your issue lies in the structure of these files: they are composed of a series of 188-byte packets.
|
||||
|
||||
Each of these small packets contains a header and a payload. The header is crucial as it contains vital information for the video player to correctly decode and synchronize the audio and video streams. The most important elements for your case are:
|
||||
|
||||
* **Sync Byte:** Every packet starts with a synchronization byte (0x47), which allows the player to find the beginning of a packet in the stream.
|
||||
* **Packet Identifier (PID):** This is a 13-bit number that identifies the type of data the packet's payload contains. For example, there will be a specific PID for the video stream, another for the audio stream, and others for metadata like the Program Map Table (PMT) which tells the player which PIDs belong to which streams.
|
||||
* **Continuity Counter:** This is a 4-bit counter that increments for each packet belonging to the same PID. If a player sees a jump in this counter (e.g., it goes from 2 to 4), it assumes a packet has been lost and will try to compensate, which can lead to glitches.
|
||||
* **Timestamps (PTS and DTS):** The payload of some packets contains Presentation Timestamps (PTS) and Decoding Timestamps (DTS). These are highly precise markers that tell the player exactly when to present a video frame or play an audio sample. In HLS, these timestamps are typically continuous across segments.
|
||||
|
||||
### Why Merging .ts Files with IDM Breaks HLS Playback
|
||||
|
||||
When a website serves a video via HLS, it provides a manifest file (usually with an `.m3u8` extension) that lists the individual `.ts` segments in the correct order. Each of these segments is a self-contained, playable piece of the video, but they are designed to be played sequentially by an HLS-aware player.
|
||||
|
||||
When a download manager like IDM downloads these `.ts` chunks and merges them, it often performs a simple file concatenation – essentially pasting the files together one after another. This crude merging process breaks the delicate structure of the Transport Stream in several critical ways from the perspective of an HLS player like hls.js:
|
||||
|
||||
1. **Discontinuous Timestamps:** Each `.ts` segment has its own set of PTS and DTS values that start from a specific point in the overall timeline of the video. When you concatenate these files, the timestamps will not be continuous. For example, the first segment might have timestamps from 0 to 6 seconds, and the second from 6.01 to 12 seconds. A simple concatenation will result in a single file where the timestamps jump from the end of one segment to the beginning of the next. This "non-monotonous DTS" is a significant issue that causes most players, including hls.js, to fail.
|
||||
|
||||
2. **Reset Continuity Counters:** At the beginning of each downloaded `.ts` chunk, the continuity counter for each PID will reset. When these chunks are concatenated, the player will see these resets as a massive loss of packets, leading to continuity counter errors and playback failure.
|
||||
|
||||
3. **Redundant Metadata:** Each `.ts` segment often contains its own metadata tables like the PAT and PMT at the beginning. In a merged file, this redundant information can confuse the player.
|
||||
|
||||
Your experience of the first chunk playing and then encountering an error is a classic symptom of this problem. Hls.js loads the initial part of the merged file, plays it, but as it continues to buffer and parse the data, it hits the point where the first segment was joined with the second. At this boundary, it encounters the timestamp and continuity counter discontinuities and throws a parsing error because the stream is no longer valid from its perspective.
|
||||
|
||||
### The Correct Approach: Re-segmenting with FFmpeg
|
||||
|
||||
Your idea to virtually split the large `.ts` file is on the right track, but a simple byte-range split won't work because it doesn't respect the packet structure and will likely create fragmented, unplayable chunks.
|
||||
|
||||
To properly serve this downloaded video on your personal HLS site, you need to re-process the merged `.ts` file to create new, valid HLS segments with a corresponding `.m3u8` playlist. The industry-standard tool for this is **FFmpeg**.
|
||||
|
||||
FFmpeg can take your single large `.ts` file as input and correctly segment it for HLS. It will re-mux the file, which means it will repackage the audio and video streams into new `.ts` segments, generating new, continuous timestamps and correct continuity counters in the process.
|
||||
|
||||
Here is a basic FFmpeg command to achieve this:
|
||||
|
||||
```bash
|
||||
ffmpeg -i your_single_large_file.ts -c:v copy -c:a copy -f hls -hls_time 10 -hls_list_size 0 -hls_segment_filename "segment%03d.ts" playlist.m3u8
|
||||
```
|
||||
|
||||
Let's break down this command:
|
||||
|
||||
* `-i your_single_large_file.ts`: Specifies your large, merged `.ts` file as the input.
|
||||
* `-c:v copy -c:a copy`: This tells FFmpeg to copy the video and audio streams without re-encoding them, which is fast and preserves the original quality.
|
||||
* `-f hls`: Specifies that the output format should be HLS.
|
||||
* `-hls_time 10`: This sets the target duration of each segment in seconds (Apple recommends around 6 seconds).
|
||||
* `-hls_list_size 0`: This creates a playlist that includes all the segments, making it a complete video-on-demand (VOD) playlist.
|
||||
* `-hls_segment_filename "segment%03d.ts"`: This defines the naming pattern for the output `.ts` files (e.g., `segment000.ts`, `segment001.ts`, etc.).
|
||||
* `playlist.m3u8`: This is the name of the master playlist file that FFmpeg will generate.
|
||||
|
||||
After running this command, you will have a set of correctly segmented `.ts` files and a `playlist.m3u8` file. You can then upload all of these files to your web server, and point your hls.js player to the `playlist.m3u8` file. This will provide hls.js with a valid HLS stream that it can parse and play correctly.
|
||||
|
||||
## Implementation Plan for NextAV
|
||||
|
||||
### Overview
|
||||
|
||||
Based on the technical analysis above, we need to implement a dynamic HLS segmentation system that:
|
||||
|
||||
1. **Detects merged .ts files** that need re-segmentation
|
||||
2. **Uses FFmpeg to create proper HLS segments** with continuous timestamps
|
||||
3. **Manages temporary files** with lifecycle management
|
||||
4. **Serves the generated segments** through existing HLS endpoints
|
||||
5. **Cleans up resources** when streaming session ends
|
||||
|
||||
### Architecture Components
|
||||
|
||||
#### 1. TS Segmentation Service (`/src/lib/ts-segmentation-service.ts`)
|
||||
|
||||
**Purpose**: Core service that handles FFmpeg-based segmentation of merged .ts files
|
||||
|
||||
**Key Features**:
|
||||
- Detects if a .ts file needs re-segmentation (check for timestamp discontinuities)
|
||||
- Creates temporary directory for each video session
|
||||
- Executes FFmpeg command to generate proper HLS segments
|
||||
- Returns metadata about generated segments
|
||||
|
||||
**API**:
|
||||
```typescript
|
||||
interface SegmentationSession {
|
||||
videoId: number;
|
||||
sessionId: string;
|
||||
tempDir: string;
|
||||
playlistPath: string;
|
||||
segmentCount: number;
|
||||
totalDuration: number;
|
||||
createdAt: Date;
|
||||
lastAccessed: Date;
|
||||
}
|
||||
|
||||
class TSSegmentationService {
|
||||
async createSegmentationSession(videoId: number, videoPath: string): Promise<SegmentationSession>
|
||||
async getSession(videoId: number): Promise<SegmentationSession | null>
|
||||
async getSegmentPath(videoId: number, segmentIndex: number): Promise<string | null>
|
||||
async cleanupSession(videoId: number): Promise<void>
|
||||
async cleanupExpiredSessions(): Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Session Management (`/src/lib/hls-session-manager.ts`)
|
||||
|
||||
**Purpose**: Manages lifecycle of HLS segmentation sessions
|
||||
|
||||
**Key Features**:
|
||||
- Tracks active segmentation sessions in memory
|
||||
- Implements TTL (Time To Live) for sessions
|
||||
- Handles cleanup of expired sessions
|
||||
- Provides session heartbeat mechanism
|
||||
|
||||
**Session Lifecycle**:
|
||||
1. **Creation**: When first HLS request is made for a .ts file
|
||||
2. **Active**: While segments are being requested
|
||||
3. **Idle**: After last segment request (with TTL timer)
|
||||
4. **Cleanup**: Remove temporary files and session data
|
||||
|
||||
#### 3. Enhanced HLS API Routes
|
||||
|
||||
**Modified Routes**:
|
||||
- `/api/stream/hls/[id]/playlist.m3u8/route.ts` - Check if segmentation needed, create session
|
||||
- `/api/stream/hls/[id]/segment/[segment]/route.ts` - Serve from temp directory or create session
|
||||
|
||||
**New Route**:
|
||||
- `/api/stream/hls/[id]/cleanup/route.ts` - Manual cleanup endpoint
|
||||
|
||||
#### 4. Background Cleanup Service (`/src/lib/cleanup-scheduler.ts`)
|
||||
|
||||
**Purpose**: Periodic cleanup of expired sessions
|
||||
|
||||
**Features**:
|
||||
- Runs every 5 minutes to check for expired sessions
|
||||
- Configurable TTL (default: 30 minutes of inactivity)
|
||||
- Graceful shutdown handling
|
||||
|
||||
### Implementation Details
|
||||
|
||||
#### FFmpeg Command Strategy
|
||||
|
||||
```bash
|
||||
ffmpeg -i input.ts \
|
||||
-c:v copy -c:a copy \
|
||||
-f hls \
|
||||
-hls_time 6 \
|
||||
-hls_list_size 0 \
|
||||
-hls_segment_filename "segment_%03d.ts" \
|
||||
-hls_flags delete_segments+append_list \
|
||||
-y playlist.m3u8
|
||||
```
|
||||
|
||||
**Parameters Explained**:
|
||||
- `-hls_time 6`: 6-second segments (Apple recommended)
|
||||
- `-hls_list_size 0`: Include all segments in playlist
|
||||
- `-hls_flags delete_segments+append_list`: Better resource management
|
||||
- `-y`: Overwrite existing files
|
||||
|
||||
#### Directory Structure
|
||||
|
||||
```
|
||||
/tmp/nextav-hls/
|
||||
├── video-{videoId}-{sessionId}/
|
||||
│ ├── playlist.m3u8
|
||||
│ ├── segment_000.ts
|
||||
│ ├── segment_001.ts
|
||||
│ └── ...
|
||||
└── cleanup.log
|
||||
```
|
||||
|
||||
#### Session TTL Strategy
|
||||
|
||||
1. **Creation TTL**: 5 minutes to complete initial segmentation
|
||||
2. **Active TTL**: 30 minutes of inactivity before cleanup
|
||||
3. **Heartbeat**: Each segment request extends TTL
|
||||
4. **Force Cleanup**: Manual cleanup API for immediate removal
|
||||
|
||||
### Error Handling Strategy
|
||||
|
||||
#### Segmentation Failures
|
||||
- **FFmpeg Error**: Log error, fall back to direct streaming
|
||||
- **Disk Space**: Check available space before segmentation
|
||||
- **Permission Error**: Use fallback temp directory
|
||||
|
||||
#### Session Management Failures
|
||||
- **Session Not Found**: Create new session on-demand
|
||||
- **Corrupted Temp Files**: Clean up and regenerate
|
||||
- **Concurrent Access**: Use file locking for session creation
|
||||
|
||||
### Performance Optimizations
|
||||
|
||||
#### Caching Strategy
|
||||
- **Session Cache**: Keep session metadata in memory
|
||||
- **Segment Cache**: HTTP cache headers for segments (1 hour)
|
||||
- **Playlist Cache**: Short cache for playlist (30 seconds)
|
||||
|
||||
#### Resource Management
|
||||
- **Concurrent Segmentation**: Limit to 2 simultaneous FFmpeg processes
|
||||
- **Disk Space Monitoring**: Prevent segmentation if disk space < 1GB
|
||||
- **Memory Management**: Use streams for large file operations
|
||||
|
||||
### API Integration Points
|
||||
|
||||
#### Video Format Detector
|
||||
```typescript
|
||||
// Enhanced detection for merged .ts files
|
||||
function detectTSSegmentationNeeded(videoPath: string): Promise<boolean> {
|
||||
// Use ffprobe to detect timestamp discontinuities
|
||||
// Return true if re-segmentation is needed
|
||||
}
|
||||
```
|
||||
|
||||
#### HLS Route Updates
|
||||
```typescript
|
||||
// In playlist.m3u8 route
|
||||
if (await detectTSSegmentationNeeded(videoPath)) {
|
||||
const session = await segmentationService.createSegmentationSession(videoId, videoPath);
|
||||
return serveGeneratedPlaylist(session.playlistPath);
|
||||
} else {
|
||||
return serveDirectHLS(videoPath); // Existing virtual segmentation
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
```typescript
|
||||
interface HLSSegmentationConfig {
|
||||
tempDir: string; // Default: '/tmp/nextav-hls'
|
||||
segmentDuration: number; // Default: 6 seconds
|
||||
sessionTTL: number; // Default: 30 minutes
|
||||
maxConcurrentJobs: number; // Default: 2
|
||||
minDiskSpace: number; // Default: 1GB
|
||||
cleanupInterval: number; // Default: 5 minutes
|
||||
enableAutoCleanup: boolean; // Default: true
|
||||
}
|
||||
```
|
||||
|
||||
### Monitoring and Logging
|
||||
|
||||
#### Metrics to Track
|
||||
- Number of active segmentation sessions
|
||||
- Segmentation success/failure rates
|
||||
- Average segmentation time
|
||||
- Disk space usage in temp directories
|
||||
- Session cleanup statistics
|
||||
|
||||
#### Log Events
|
||||
- Session creation/destruction
|
||||
- FFmpeg command execution
|
||||
- Cleanup operations
|
||||
- Error conditions
|
||||
|
||||
### Testing Strategy
|
||||
|
||||
#### Unit Tests
|
||||
- FFmpeg command generation
|
||||
- Session lifecycle management
|
||||
- Cleanup scheduling
|
||||
- Error handling scenarios
|
||||
|
||||
#### Integration Tests
|
||||
- End-to-end HLS playback
|
||||
- Concurrent session handling
|
||||
- Resource cleanup verification
|
||||
- Performance under load
|
||||
|
||||
### Deployment Considerations
|
||||
|
||||
#### Requirements
|
||||
- FFmpeg installed on server
|
||||
- Writable temporary directory
|
||||
- Sufficient disk space for temporary files
|
||||
- Background process for cleanup
|
||||
|
||||
#### Environment Variables
|
||||
```bash
|
||||
HLS_TEMP_DIR=/tmp/nextav-hls
|
||||
HLS_SEGMENT_DURATION=6
|
||||
HLS_SESSION_TTL=1800
|
||||
HLS_MAX_CONCURRENT_JOBS=2
|
||||
HLS_CLEANUP_INTERVAL=300
|
||||
```
|
||||
|
||||
### Migration Path
|
||||
|
||||
#### Phase 1: Core Implementation
|
||||
1. Implement TSSegmentationService
|
||||
2. Add session management
|
||||
3. Update HLS routes
|
||||
4. Basic error handling
|
||||
|
||||
#### Phase 2: Production Features
|
||||
1. Background cleanup service
|
||||
2. Performance optimizations
|
||||
3. Monitoring and logging
|
||||
4. Configuration management
|
||||
|
||||
#### Phase 3: Advanced Features
|
||||
1. Load balancing for multiple FFmpeg processes
|
||||
2. Distributed session management
|
||||
3. Advanced caching strategies
|
||||
4. Real-time monitoring dashboard
|
||||
|
||||
This implementation plan provides a robust, scalable solution for handling merged .ts files while maintaining good performance and resource management.
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { hlsSessionManager } from "@/lib/hls-session-manager";
|
||||
import { tsSegmentationService } from "@/lib/ts-segmentation-service";
|
||||
|
||||
/**
|
||||
* Manual cleanup endpoint for HLS segmentation sessions
|
||||
* POST /api/stream/hls/[id]/cleanup - Force cleanup specific session
|
||||
* DELETE /api/stream/hls/[id]/cleanup - Same as POST
|
||||
*/
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
try {
|
||||
const videoId = parseInt(id);
|
||||
|
||||
if (isNaN(videoId)) {
|
||||
return NextResponse.json({ error: "Invalid video ID" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Force cleanup the session
|
||||
await hlsSessionManager.forceCleanupSession(videoId);
|
||||
|
||||
console.log(`[HLS-Cleanup] Manual cleanup completed for video ${videoId}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Session for video ${videoId} cleaned up successfully`
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("[HLS-Cleanup] Error during manual cleanup:", error);
|
||||
return NextResponse.json({
|
||||
error: "Cleanup failed",
|
||||
details: error.message
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
// Delegate to POST handler
|
||||
return POST(request, { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session status and statistics
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
try {
|
||||
const videoId = parseInt(id);
|
||||
|
||||
if (isNaN(videoId)) {
|
||||
return NextResponse.json({ error: "Invalid video ID" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get session information
|
||||
const session = await tsSegmentationService.getSession(videoId);
|
||||
const heartbeat = hlsSessionManager.getHeartbeat(videoId);
|
||||
const sessionStats = hlsSessionManager.getSessionStats(videoId);
|
||||
const isActive = hlsSessionManager.isSessionActive(videoId);
|
||||
|
||||
// Validate session health
|
||||
const healthCheck = await hlsSessionManager.validateSessionHealth(videoId);
|
||||
|
||||
const response = {
|
||||
videoId,
|
||||
session: session ? {
|
||||
sessionId: session.sessionId,
|
||||
status: session.status,
|
||||
segmentCount: session.segmentCount,
|
||||
totalDuration: session.totalDuration,
|
||||
createdAt: session.createdAt,
|
||||
lastAccessed: session.lastAccessed,
|
||||
tempDir: session.tempDir,
|
||||
playlistPath: session.playlistPath,
|
||||
} : null,
|
||||
heartbeat,
|
||||
sessionStats,
|
||||
isActive,
|
||||
healthCheck,
|
||||
};
|
||||
|
||||
return NextResponse.json(response);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("[HLS-Cleanup] Error getting session status:", error);
|
||||
return NextResponse.json({
|
||||
error: "Failed to get session status",
|
||||
details: error.message
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from "next/server";
|
|||
import { getDatabase } from "@/db";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { hlsSessionManager } from "@/lib/hls-session-manager";
|
||||
import { tsSegmentationService } from "@/lib/ts-segmentation-service";
|
||||
|
||||
/**
|
||||
* Generate HLS playlist for a video file - .m3u8 extension handler
|
||||
|
|
@ -34,20 +36,91 @@ export async function GET(
|
|||
return NextResponse.json({ error: "Video file not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check if this is a .ts file (optimal HLS handling)
|
||||
// Check if this is a .ts file (enhanced HLS handling)
|
||||
const fileExtension = path.extname(videoPath).toLowerCase();
|
||||
|
||||
if (fileExtension === '.ts') {
|
||||
// .ts files are already HLS-compatible - serve as single segment
|
||||
return generateTSFilePlaylist(video, videoId, request);
|
||||
// Check if .ts file needs re-segmentation
|
||||
const needsSegmentation = await tsSegmentationService.needsSegmentation(videoPath);
|
||||
|
||||
if (needsSegmentation) {
|
||||
return generateSegmentedTSPlaylist(video, videoId, videoPath, request);
|
||||
} else {
|
||||
// Use existing virtual segmentation for proper .ts files
|
||||
return generateTSFilePlaylist(video, videoId, request);
|
||||
}
|
||||
} else {
|
||||
// Other formats need segmentation (not implemented yet)
|
||||
// Other formats use generic playlist generation
|
||||
return generateGenericPlaylist(video, videoId, request);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error("Error generating HLS playlist:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
return NextResponse.json({ error: "Internal server error", details: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate HLS playlist for merged .ts files that need FFmpeg re-segmentation
|
||||
*/
|
||||
async function generateSegmentedTSPlaylist(video: any, videoId: number, videoPath: string, request: NextRequest): Promise<Response> {
|
||||
try {
|
||||
console.log(`[HLS-Playlist] Creating segmented playlist for merged .ts file: ${videoPath}`);
|
||||
|
||||
// Get or create segmentation session
|
||||
const session = await hlsSessionManager.getOrCreateSession(videoId, videoPath);
|
||||
|
||||
if (session.status === 'processing') {
|
||||
// Return a simple response indicating processing
|
||||
return new Response('Processing...', {
|
||||
status: 202,
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
'Retry-After': '5',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (session.status === 'error') {
|
||||
console.error(`[HLS-Playlist] Segmentation failed for video ${videoId}: ${session.error}`);
|
||||
// Fall back to virtual segmentation
|
||||
return generateTSFilePlaylist(video, videoId, request);
|
||||
}
|
||||
|
||||
if (session.status === 'ready') {
|
||||
// Serve the generated playlist
|
||||
if (fs.existsSync(session.playlistPath)) {
|
||||
const playlistContent = fs.readFileSync(session.playlistPath, 'utf8');
|
||||
|
||||
// Update heartbeat for session management
|
||||
hlsSessionManager.updateHeartbeat(videoId);
|
||||
|
||||
console.log(`[HLS-Playlist] Serving generated playlist for video ${videoId} (${session.segmentCount} segments)`);
|
||||
|
||||
return new Response(playlistContent, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/vnd.apple.mpegurl',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Cache-Control': 'public, max-age=30', // Short cache for generated playlists
|
||||
},
|
||||
});
|
||||
} else {
|
||||
console.error(`[HLS-Playlist] Playlist file not found: ${session.playlistPath}`);
|
||||
// Fall back to virtual segmentation
|
||||
return generateTSFilePlaylist(video, videoId, request);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback case
|
||||
console.warn(`[HLS-Playlist] Unexpected session status ${session.status}, falling back to virtual segmentation`);
|
||||
return generateTSFilePlaylist(video, videoId, request);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(`[HLS-Playlist] Error creating segmented playlist for video ${videoId}:`, error);
|
||||
// Fall back to virtual segmentation on any error
|
||||
return generateTSFilePlaylist(video, videoId, request);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,55 @@ import { NextRequest, NextResponse } from "next/server";
|
|||
import { getDatabase } from "@/db";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { hlsSessionManager } from "@/lib/hls-session-manager";
|
||||
import { tsSegmentationService } from "@/lib/ts-segmentation-service";
|
||||
|
||||
/**
|
||||
* Serve a segment from FFmpeg-generated segmentation
|
||||
*/
|
||||
async function serveGeneratedSegment(
|
||||
session: any,
|
||||
segmentIndex: number,
|
||||
request: NextRequest
|
||||
): Promise<Response> {
|
||||
try {
|
||||
// Update heartbeat
|
||||
hlsSessionManager.updateHeartbeat(session.videoId, segmentIndex);
|
||||
|
||||
// Get segment file path
|
||||
const segmentPath = await tsSegmentationService.getSegmentPath(session.videoId, segmentIndex);
|
||||
|
||||
if (!segmentPath || !fs.existsSync(segmentPath)) {
|
||||
console.log(`[HLS-Segment] Generated segment ${segmentIndex} not found for video ${session.videoId}`);
|
||||
return new NextResponse(null, { status: 404 });
|
||||
}
|
||||
|
||||
const stat = fs.statSync(segmentPath);
|
||||
const segmentSize = stat.size;
|
||||
|
||||
console.log(`[HLS-Segment] Serving generated segment ${segmentIndex} for video ${session.videoId} (${segmentSize} bytes)`);
|
||||
|
||||
// Create read stream for the segment
|
||||
const stream = fs.createReadStream(segmentPath);
|
||||
|
||||
return new Response(stream as any, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'video/mp2t',
|
||||
'Content-Length': segmentSize.toString(),
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Cache-Control': 'public, max-age=3600', // Cache generated segments for 1 hour
|
||||
'ETag': `"generated-${session.videoId}-${segmentIndex}"`,
|
||||
},
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(`[HLS-Segment] Error serving generated segment ${segmentIndex}:`, error);
|
||||
return new NextResponse(null, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve a virtual segment from a large .ts file using byte ranges
|
||||
|
|
@ -93,7 +142,7 @@ export async function GET(
|
|||
const videoId = parseInt(id);
|
||||
const segmentIndex = parseInt(segment.replace('.ts', ''));
|
||||
|
||||
console.log(`[HLS-Segment] Parsed videoId: ${videoId}, segmentIndex: ${segmentIndex}`);
|
||||
console.log(`[HLS-Segment] Parsed videoId: ${videoId}, segmentIndex: ${segmentIndex} (original: ${segment})`);
|
||||
|
||||
if (isNaN(videoId)) {
|
||||
console.log(`[HLS-Segment] Invalid video ID: ${id}`);
|
||||
|
|
@ -128,9 +177,16 @@ export async function GET(
|
|||
console.log(`[HLS-Segment] File extension: ${fileExtension}`);
|
||||
|
||||
if (fileExtension === '.ts') {
|
||||
// For .ts files, implement virtual segmentation
|
||||
// Instead of serving the entire file, split it into manageable chunks
|
||||
return serveTSSegment(videoPath, segmentIndex, request);
|
||||
// Check if we have a segmentation session for this video
|
||||
const session = await tsSegmentationService.getSession(videoId);
|
||||
|
||||
if (session && session.status === 'ready') {
|
||||
// Serve from generated segments
|
||||
return serveGeneratedSegment(session, segmentIndex, request);
|
||||
} else {
|
||||
// Fall back to virtual segmentation for regular .ts files
|
||||
return serveTSSegment(videoPath, segmentIndex, request);
|
||||
}
|
||||
} else {
|
||||
// For non-.ts files, we need to either:
|
||||
// 1. Convert to .ts segments on-the-fly (resource intensive)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { hlsSessionManager } from "@/lib/hls-session-manager";
|
||||
import { tsSegmentationService } from "@/lib/ts-segmentation-service";
|
||||
|
||||
/**
|
||||
* Global HLS segmentation status and management endpoint
|
||||
* GET /api/stream/hls/status - Get overall statistics
|
||||
* POST /api/stream/hls/status - Trigger cleanup of expired sessions
|
||||
*/
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Get overall statistics
|
||||
const overallStats = hlsSessionManager.getOverallStats();
|
||||
const segmentationStats = tsSegmentationService.getStats();
|
||||
const debugInfo = hlsSessionManager.getDebugInfo();
|
||||
|
||||
// Get active and expired sessions
|
||||
const activeSessions = hlsSessionManager.getActiveSessions();
|
||||
const expiredSessions = hlsSessionManager.getExpiredSessions();
|
||||
|
||||
const response = {
|
||||
timestamp: new Date().toISOString(),
|
||||
overallStats,
|
||||
segmentationStats,
|
||||
activeSessions: activeSessions.length,
|
||||
expiredSessions: expiredSessions.length,
|
||||
activeSessionIds: activeSessions,
|
||||
expiredSessionIds: expiredSessions,
|
||||
debug: debugInfo,
|
||||
};
|
||||
|
||||
return NextResponse.json(response);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("[HLS-Status] Error getting status:", error);
|
||||
return NextResponse.json({
|
||||
error: "Failed to get HLS status",
|
||||
details: error.message
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger manual cleanup of expired sessions
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const maxIdleTime = body.maxIdleTime || 30 * 60 * 1000; // Default 30 minutes
|
||||
|
||||
console.log(`[HLS-Status] Triggering manual cleanup with maxIdleTime: ${maxIdleTime}ms`);
|
||||
|
||||
// Perform cleanup
|
||||
const cleanedUpCount = await hlsSessionManager.cleanupExpiredSessions(maxIdleTime);
|
||||
|
||||
// Get updated statistics
|
||||
const overallStats = hlsSessionManager.getOverallStats();
|
||||
const segmentationStats = tsSegmentationService.getStats();
|
||||
|
||||
const response = {
|
||||
timestamp: new Date().toISOString(),
|
||||
cleanupPerformed: true,
|
||||
cleanedUpCount,
|
||||
maxIdleTime,
|
||||
overallStats,
|
||||
segmentationStats,
|
||||
};
|
||||
|
||||
return NextResponse.json(response);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("[HLS-Status] Error during manual cleanup:", error);
|
||||
return NextResponse.json({
|
||||
error: "Manual cleanup failed",
|
||||
details: error.message
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,376 @@
|
|||
/**
|
||||
* HLS Session Manager
|
||||
* Manages lifecycle of HLS segmentation sessions with heartbeat and TTL
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
import { tsSegmentationService, SegmentationSession } from './ts-segmentation-service';
|
||||
|
||||
export interface SessionHeartbeat {
|
||||
videoId: number;
|
||||
timestamp: Date;
|
||||
segmentIndex?: number;
|
||||
clientInfo?: {
|
||||
userAgent?: string;
|
||||
ip?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SessionStats {
|
||||
totalSessions: number;
|
||||
activeSessions: number;
|
||||
expiredSessions: number;
|
||||
totalSegmentRequests: number;
|
||||
averageSessionDuration: number;
|
||||
}
|
||||
|
||||
class HLSSessionManager extends EventEmitter {
|
||||
private heartbeats = new Map<number, SessionHeartbeat>();
|
||||
private sessionStats = new Map<number, {
|
||||
requestCount: number;
|
||||
firstRequest: Date;
|
||||
lastRequest: Date;
|
||||
}>();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.setupEventHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or get existing session for video
|
||||
*/
|
||||
async getOrCreateSession(videoId: number, videoPath: string): Promise<SegmentationSession> {
|
||||
// Check if video needs segmentation
|
||||
const needsSegmentation = await tsSegmentationService.needsSegmentation(videoPath);
|
||||
|
||||
if (!needsSegmentation) {
|
||||
throw new Error('Video does not need segmentation');
|
||||
}
|
||||
|
||||
let session = await tsSegmentationService.getSession(videoId);
|
||||
|
||||
if (session) {
|
||||
// Add reference to existing session
|
||||
const updatedSession = await tsSegmentationService.addReference(videoId);
|
||||
if (updatedSession) {
|
||||
session = updatedSession;
|
||||
console.log(`[HLSSessionManager] Using existing session for video ${videoId} (refs: ${session.referenceCount})`);
|
||||
}
|
||||
} else {
|
||||
// Create new session
|
||||
console.log(`[HLSSessionManager] Creating new segmentation session for video ${videoId}`);
|
||||
session = await tsSegmentationService.createSegmentationSession(videoId, videoPath);
|
||||
this.emit('sessionCreated', { videoId, session });
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
throw new Error('Failed to create or get session');
|
||||
}
|
||||
|
||||
// Update heartbeat
|
||||
this.updateHeartbeat(videoId);
|
||||
|
||||
// Update stats
|
||||
this.updateSessionStats(videoId);
|
||||
|
||||
return session!
|
||||
}
|
||||
|
||||
/**
|
||||
* Update heartbeat for a session
|
||||
*/
|
||||
updateHeartbeat(videoId: number, segmentIndex?: number, clientInfo?: SessionHeartbeat['clientInfo']): void {
|
||||
const heartbeat: SessionHeartbeat = {
|
||||
videoId,
|
||||
timestamp: new Date(),
|
||||
segmentIndex,
|
||||
clientInfo,
|
||||
};
|
||||
|
||||
this.heartbeats.set(videoId, heartbeat);
|
||||
this.emit('heartbeat', heartbeat);
|
||||
|
||||
console.log(`[HLSSessionManager] Heartbeat updated for video ${videoId} ${segmentIndex !== undefined ? `(segment ${segmentIndex})` : ''}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session heartbeat
|
||||
*/
|
||||
getHeartbeat(videoId: number): SessionHeartbeat | null {
|
||||
return this.heartbeats.get(videoId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if session is active (recent heartbeat)
|
||||
*/
|
||||
isSessionActive(videoId: number, maxIdleTime: number = 30 * 60 * 1000): boolean {
|
||||
const heartbeat = this.heartbeats.get(videoId);
|
||||
if (!heartbeat) return false;
|
||||
|
||||
const timeSinceLastHeartbeat = Date.now() - heartbeat.timestamp.getTime();
|
||||
return timeSinceLastHeartbeat < maxIdleTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active sessions
|
||||
*/
|
||||
getActiveSessions(maxIdleTime: number = 30 * 60 * 1000): number[] {
|
||||
const activeSessions: number[] = [];
|
||||
|
||||
for (const videoId of this.heartbeats.keys()) {
|
||||
if (this.isSessionActive(videoId, maxIdleTime)) {
|
||||
activeSessions.push(videoId);
|
||||
}
|
||||
}
|
||||
|
||||
return activeSessions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get expired sessions
|
||||
*/
|
||||
getExpiredSessions(maxIdleTime: number = 30 * 60 * 1000): number[] {
|
||||
const expiredSessions: number[] = [];
|
||||
|
||||
for (const videoId of this.heartbeats.keys()) {
|
||||
if (!this.isSessionActive(videoId, maxIdleTime)) {
|
||||
expiredSessions.push(videoId);
|
||||
}
|
||||
}
|
||||
|
||||
return expiredSessions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup expired sessions
|
||||
*/
|
||||
async cleanupExpiredSessions(maxIdleTime: number = 30 * 60 * 1000): Promise<number> {
|
||||
const expiredSessions = this.getExpiredSessions(maxIdleTime);
|
||||
let cleanedUpCount = 0;
|
||||
|
||||
for (const videoId of expiredSessions) {
|
||||
try {
|
||||
await this.forceCleanupSession(videoId);
|
||||
cleanedUpCount++;
|
||||
} catch (error) {
|
||||
console.error(`[HLSSessionManager] Error cleaning up session ${videoId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (cleanedUpCount > 0) {
|
||||
console.log(`[HLSSessionManager] Cleaned up ${cleanedUpCount} expired sessions`);
|
||||
this.emit('sessionsCleanedUp', { count: cleanedUpCount, videoIds: expiredSessions });
|
||||
}
|
||||
|
||||
return cleanedUpCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force cleanup a specific session
|
||||
*/
|
||||
async forceCleanupSession(videoId: number): Promise<void> {
|
||||
try {
|
||||
// Remove reference from segmentation service
|
||||
await tsSegmentationService.removeReference(videoId);
|
||||
|
||||
// Remove heartbeat
|
||||
this.heartbeats.delete(videoId);
|
||||
|
||||
// Remove stats
|
||||
this.sessionStats.delete(videoId);
|
||||
|
||||
console.log(`[HLSSessionManager] Force cleaned up session for video ${videoId}`);
|
||||
this.emit('sessionCleanedUp', { videoId });
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[HLSSessionManager] Error force cleaning up session ${videoId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release reference to a session
|
||||
*/
|
||||
async releaseSession(videoId: number): Promise<void> {
|
||||
try {
|
||||
await tsSegmentationService.removeReference(videoId);
|
||||
console.log(`[HLSSessionManager] Released reference for video ${videoId}`);
|
||||
} catch (error) {
|
||||
console.error(`[HLSSessionManager] Error releasing session ${videoId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session statistics
|
||||
*/
|
||||
getSessionStats(videoId: number): { requestCount: number; firstRequest: Date; lastRequest: Date; } | null {
|
||||
return this.sessionStats.get(videoId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get overall statistics
|
||||
*/
|
||||
getOverallStats(): SessionStats {
|
||||
const totalSessions = this.sessionStats.size;
|
||||
const activeSessions = this.getActiveSessions().length;
|
||||
const expiredSessions = totalSessions - activeSessions;
|
||||
|
||||
let totalSegmentRequests = 0;
|
||||
let totalSessionDuration = 0;
|
||||
let validSessions = 0;
|
||||
|
||||
for (const stats of this.sessionStats.values()) {
|
||||
totalSegmentRequests += stats.requestCount;
|
||||
|
||||
const duration = stats.lastRequest.getTime() - stats.firstRequest.getTime();
|
||||
if (duration > 0) {
|
||||
totalSessionDuration += duration;
|
||||
validSessions++;
|
||||
}
|
||||
}
|
||||
|
||||
const averageSessionDuration = validSessions > 0 ? totalSessionDuration / validSessions : 0;
|
||||
|
||||
return {
|
||||
totalSessions,
|
||||
activeSessions,
|
||||
expiredSessions,
|
||||
totalSegmentRequests,
|
||||
averageSessionDuration: Math.round(averageSessionDuration / 1000), // Convert to seconds
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update session statistics
|
||||
*/
|
||||
private updateSessionStats(videoId: number): void {
|
||||
const now = new Date();
|
||||
const stats = this.sessionStats.get(videoId);
|
||||
|
||||
if (stats) {
|
||||
stats.requestCount++;
|
||||
stats.lastRequest = now;
|
||||
} else {
|
||||
this.sessionStats.set(videoId, {
|
||||
requestCount: 1,
|
||||
firstRequest: now,
|
||||
lastRequest: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event handlers
|
||||
*/
|
||||
private setupEventHandlers(): void {
|
||||
// Listen to segmentation service events
|
||||
this.on('sessionCreated', ({ videoId }) => {
|
||||
console.log(`[HLSSessionManager] Session created for video ${videoId}`);
|
||||
});
|
||||
|
||||
this.on('heartbeat', ({ videoId, segmentIndex }) => {
|
||||
// Optional: Log heartbeats in debug mode
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.debug(`[HLSSessionManager] Heartbeat: video ${videoId}, segment ${segmentIndex}`);
|
||||
}
|
||||
});
|
||||
|
||||
this.on('sessionCleanedUp', ({ videoId }) => {
|
||||
console.log(`[HLSSessionManager] Session cleaned up for video ${videoId}`);
|
||||
});
|
||||
|
||||
this.on('sessionsCleanedUp', ({ count }) => {
|
||||
console.log(`[HLSSessionManager] Batch cleanup completed: ${count} sessions`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule automatic cleanup
|
||||
*/
|
||||
startAutoCleanup(intervalMs: number = 5 * 60 * 1000, maxIdleTimeMs: number = 30 * 60 * 1000): NodeJS.Timeout {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
await this.cleanupExpiredSessions(maxIdleTimeMs);
|
||||
} catch (error) {
|
||||
console.error('[HLSSessionManager] Auto cleanup error:', error);
|
||||
}
|
||||
}, intervalMs);
|
||||
|
||||
console.log(`[HLSSessionManager] Auto cleanup started (interval: ${intervalMs}ms, maxIdle: ${maxIdleTimeMs}ms)`);
|
||||
return interval;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get debug information
|
||||
*/
|
||||
getDebugInfo(): {
|
||||
heartbeats: SessionHeartbeat[];
|
||||
stats: SessionStats;
|
||||
segmentationStats: any;
|
||||
} {
|
||||
return {
|
||||
heartbeats: Array.from(this.heartbeats.values()),
|
||||
stats: this.getOverallStats(),
|
||||
segmentationStats: tsSegmentationService.getStats(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate session health
|
||||
*/
|
||||
async validateSessionHealth(videoId: number): Promise<{
|
||||
isValid: boolean;
|
||||
issues: string[];
|
||||
session?: SegmentationSession;
|
||||
}> {
|
||||
const issues: string[] = [];
|
||||
|
||||
try {
|
||||
const session = await tsSegmentationService.getSession(videoId);
|
||||
|
||||
if (!session) {
|
||||
issues.push('Session not found');
|
||||
return { isValid: false, issues };
|
||||
}
|
||||
|
||||
if (session.status === 'error') {
|
||||
issues.push(`Session in error state: ${session.error}`);
|
||||
}
|
||||
|
||||
if (session.status === 'processing') {
|
||||
const age = Date.now() - session.createdAt.getTime();
|
||||
if (age > 5 * 60 * 1000) { // 5 minutes
|
||||
issues.push('Session stuck in processing state');
|
||||
}
|
||||
}
|
||||
|
||||
if (session.status === 'ready') {
|
||||
// Check if playlist file exists
|
||||
const fs = require('fs');
|
||||
if (!fs.existsSync(session.playlistPath)) {
|
||||
issues.push('Playlist file missing');
|
||||
}
|
||||
}
|
||||
|
||||
const isActive = this.isSessionActive(videoId);
|
||||
if (!isActive) {
|
||||
issues.push('Session inactive (no recent heartbeat)');
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: issues.length === 0,
|
||||
issues,
|
||||
session,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
issues.push(`Validation error: ${error}`);
|
||||
return { isValid: false, issues };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const hlsSessionManager = new HLSSessionManager();
|
||||
export default hlsSessionManager;
|
||||
|
|
@ -0,0 +1,772 @@
|
|||
/**
|
||||
* TS Segmentation Service
|
||||
* Handles FFmpeg-based segmentation of merged .ts files for proper HLS playback
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const mkdir = promisify(fs.mkdir);
|
||||
const access = promisify(fs.access);
|
||||
const stat = promisify(fs.stat);
|
||||
const readdir = promisify(fs.readdir);
|
||||
const unlink = promisify(fs.unlink);
|
||||
const rmdir = promisify(fs.rmdir);
|
||||
|
||||
export interface SegmentationSession {
|
||||
videoId: number;
|
||||
videoPath: string;
|
||||
tempDir: string;
|
||||
playlistPath: string;
|
||||
segmentCount: number;
|
||||
totalDuration: number;
|
||||
createdAt: Date;
|
||||
lastAccessed: Date;
|
||||
status: 'pending' | 'processing' | 'ready' | 'error';
|
||||
error?: string;
|
||||
referenceCount: number; // Track how many sessions are using this video
|
||||
}
|
||||
|
||||
export interface SegmentationConfig {
|
||||
tempDir: string;
|
||||
segmentDuration: number;
|
||||
sessionTTL: number;
|
||||
maxConcurrentJobs: number;
|
||||
minDiskSpace: number;
|
||||
cleanupInterval: number;
|
||||
enableAutoCleanup: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: SegmentationConfig = {
|
||||
tempDir: '/tmp/nextav-hls',
|
||||
segmentDuration: 6,
|
||||
sessionTTL: 30 * 60 * 1000, // 30 minutes
|
||||
maxConcurrentJobs: 2,
|
||||
minDiskSpace: 1024 * 1024 * 1024, // 1GB
|
||||
cleanupInterval: 5 * 60 * 1000, // 5 minutes
|
||||
enableAutoCleanup: true,
|
||||
};
|
||||
|
||||
class TSSegmentationService {
|
||||
private sessions = new Map<number, SegmentationSession>(); // Map videoId -> session
|
||||
private activeJobs = 0;
|
||||
private config: SegmentationConfig;
|
||||
private cleanupTimer?: NodeJS.Timeout;
|
||||
|
||||
constructor(config: Partial<SegmentationConfig> = {}) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
this.ensureTempDir();
|
||||
|
||||
// Restore existing sessions from temp directories
|
||||
this.restoreExistingSessions().catch(error => {
|
||||
console.warn('[TSSegmentation] Error during session restoration:', error);
|
||||
});
|
||||
|
||||
if (this.config.enableAutoCleanup) {
|
||||
this.startCleanupScheduler();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore existing sessions from temp directories
|
||||
*/
|
||||
private async restoreExistingSessions(): Promise<void> {
|
||||
try {
|
||||
if (!fs.existsSync(this.config.tempDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = await readdir(this.config.tempDir);
|
||||
let restoredCount = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
// Updated pattern: video-{videoId} (no session ID)
|
||||
const match = entry.match(/^video-(\d+)$/);
|
||||
if (match) {
|
||||
const videoId = parseInt(match[1]);
|
||||
const tempDir = path.join(this.config.tempDir, entry);
|
||||
const playlistPath = path.join(tempDir, 'playlist.m3u8');
|
||||
|
||||
// Check if playlist exists and is valid
|
||||
if (fs.existsSync(playlistPath)) {
|
||||
try {
|
||||
const playlistContent = fs.readFileSync(playlistPath, 'utf8');
|
||||
const segmentCount = (playlistContent.match(/\.ts/g) || []).length;
|
||||
|
||||
// Extract duration from playlist
|
||||
const durationMatch = playlistContent.match(/#EXTINF:([\d.]+)/g);
|
||||
let totalDuration = 0;
|
||||
if (durationMatch) {
|
||||
totalDuration = durationMatch.reduce((total, match) => {
|
||||
const duration = parseFloat(match.replace('#EXTINF:', ''));
|
||||
return total + duration;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// Get video path from database
|
||||
const db = require('@/db').getDatabase();
|
||||
const video = db.prepare("SELECT path FROM media WHERE id = ? AND type = 'video'").get(videoId) as { path: string } | undefined;
|
||||
|
||||
if (video) {
|
||||
const session: SegmentationSession = {
|
||||
videoId,
|
||||
videoPath: video.path,
|
||||
tempDir,
|
||||
playlistPath,
|
||||
segmentCount,
|
||||
totalDuration,
|
||||
createdAt: new Date(fs.statSync(tempDir).birthtime),
|
||||
lastAccessed: new Date(),
|
||||
status: 'ready',
|
||||
referenceCount: 0, // Start with 0 references
|
||||
};
|
||||
|
||||
this.sessions.set(videoId, session);
|
||||
restoredCount++;
|
||||
|
||||
console.log(`[TSSegmentation] Restored session for video ${videoId} (${segmentCount} segments)`);
|
||||
} else {
|
||||
console.warn(`[TSSegmentation] Video ${videoId} not found in database, cleaning up temp files`);
|
||||
await this.cleanupSessionFiles({ tempDir } as SegmentationSession);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.warn(`[TSSegmentation] Could not restore session from ${entry}:`, error);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Clean up old session-based directories that don't match new pattern
|
||||
const oldMatch = entry.match(/^video-(\d+)-.+$/);
|
||||
if (oldMatch) {
|
||||
console.log(`[TSSegmentation] Cleaning up old session directory: ${entry}`);
|
||||
const tempDir = path.join(this.config.tempDir, entry);
|
||||
await this.cleanupSessionFiles({ tempDir } as SegmentationSession);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (restoredCount > 0) {
|
||||
console.log(`[TSSegmentation] Restored ${restoredCount} existing sessions`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.warn(`[TSSegmentation] Error restoring existing sessions:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a .ts file needs re-segmentation by detecting timestamp discontinuities
|
||||
*/
|
||||
async needsSegmentation(videoPath: string): Promise<boolean> {
|
||||
try {
|
||||
const stat = await this.getFileStats(videoPath);
|
||||
const fileSizeMB = stat.size / (1024 * 1024);
|
||||
|
||||
console.log(`[TSSegmentation] Analyzing ${path.basename(videoPath)} (${fileSizeMB.toFixed(1)}MB)`);
|
||||
|
||||
// First heuristic: Files larger than 100MB are likely merged from segments
|
||||
if (fileSizeMB > 100) {
|
||||
console.log(`[TSSegmentation] Large file detected (${fileSizeMB.toFixed(1)}MB > 100MB), likely merged segments`);
|
||||
|
||||
// For large files, do more comprehensive analysis
|
||||
const hasDiscontinuities = await this.analyzeTimestampDiscontinuities(videoPath, 200); // Check more packets
|
||||
|
||||
if (hasDiscontinuities) {
|
||||
console.log(`[TSSegmentation] Timestamp discontinuities found in large file`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Additional check: analyze multiple points in the file
|
||||
const hasMultipleDiscontinuities = await this.analyzeMultipleFileSegments(videoPath);
|
||||
if (hasMultipleDiscontinuities) {
|
||||
console.log(`[TSSegmentation] Multiple discontinuities found throughout file`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// For large files without obvious discontinuities, still consider segmentation
|
||||
// This handles cases where segments were concatenated cleanly
|
||||
if (fileSizeMB > 200) {
|
||||
console.log(`[TSSegmentation] Very large file (${fileSizeMB.toFixed(1)}MB > 200MB), assuming merged segments`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// For smaller files, do standard analysis
|
||||
const hasDiscontinuities = await this.analyzeTimestampDiscontinuities(videoPath, 100);
|
||||
|
||||
const needsSegmentation = hasDiscontinuities;
|
||||
console.log(`[TSSegmentation] File ${path.basename(videoPath)} needs segmentation: ${needsSegmentation}`);
|
||||
|
||||
return needsSegmentation;
|
||||
} catch (error) {
|
||||
console.warn(`[TSSegmentation] Could not analyze ${videoPath}, assuming needs segmentation:`, error);
|
||||
return true; // Default to segmentation if analysis fails
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze timestamp discontinuities in the file
|
||||
*/
|
||||
private async analyzeTimestampDiscontinuities(videoPath: string, packetCount: number = 100): Promise<boolean> {
|
||||
try {
|
||||
// Use ffprobe to check for timestamp discontinuities
|
||||
const result = await this.executeFFprobe([
|
||||
'-v', 'quiet',
|
||||
'-show_entries', 'packet=pts_time,dts_time,flags',
|
||||
'-select_streams', 'v:0',
|
||||
'-of', 'csv=nk=1:p=0',
|
||||
'-read_intervals', `%+#${packetCount}`, // Check specified number of packets
|
||||
videoPath
|
||||
]);
|
||||
|
||||
const lines = result.split('\n').filter(line => line.trim());
|
||||
if (lines.length < 2) return false;
|
||||
|
||||
// Check for significant timestamp jumps (indication of merged segments)
|
||||
let prevPts = 0;
|
||||
let discontinuityCount = 0;
|
||||
let largeJumpCount = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
const [ptsStr] = line.split(',');
|
||||
const pts = parseFloat(ptsStr);
|
||||
|
||||
if (pts > 0 && prevPts > 0) {
|
||||
const diff = Math.abs(pts - prevPts);
|
||||
// If timestamp jump is > 0.5 seconds, likely a discontinuity
|
||||
if (diff > 0.5 && prevPts < pts) {
|
||||
discontinuityCount++;
|
||||
}
|
||||
// Very large jumps (> 2 seconds) are strong indicators
|
||||
if (diff > 2.0 && prevPts < pts) {
|
||||
largeJumpCount++;
|
||||
}
|
||||
}
|
||||
prevPts = pts;
|
||||
}
|
||||
|
||||
console.log(`[TSSegmentation] Timestamp analysis: ${discontinuityCount} discontinuities, ${largeJumpCount} large jumps in ${lines.length} packets`);
|
||||
|
||||
// If we found discontinuities or large jumps, likely needs segmentation
|
||||
return discontinuityCount > 0 || largeJumpCount > 0;
|
||||
} catch (error) {
|
||||
console.warn(`[TSSegmentation] Error analyzing timestamps:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze multiple segments of a large file to detect discontinuities
|
||||
*/
|
||||
private async analyzeMultipleFileSegments(videoPath: string): Promise<boolean> {
|
||||
try {
|
||||
const stat = await this.getFileStats(videoPath);
|
||||
const fileDuration = await this.getFileDuration(videoPath);
|
||||
|
||||
if (!fileDuration || fileDuration < 10) {
|
||||
return false; // Can't analyze very short files
|
||||
}
|
||||
|
||||
// Analyze 3 points: 25%, 50%, 75% through the file
|
||||
const checkPoints = [0.25, 0.5, 0.75];
|
||||
let totalDiscontinuities = 0;
|
||||
|
||||
for (const point of checkPoints) {
|
||||
const timeOffset = fileDuration * point;
|
||||
|
||||
try {
|
||||
const result = await this.executeFFprobe([
|
||||
'-v', 'quiet',
|
||||
'-ss', timeOffset.toString(),
|
||||
'-show_entries', 'packet=pts_time,dts_time',
|
||||
'-select_streams', 'v:0',
|
||||
'-of', 'csv=nk=1:p=0',
|
||||
'-read_intervals', '%+#50', // Check 50 packets at this point
|
||||
videoPath
|
||||
]);
|
||||
|
||||
const lines = result.split('\n').filter(line => line.trim());
|
||||
let discontinuities = 0;
|
||||
let prevPts = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
const [ptsStr] = line.split(',');
|
||||
const pts = parseFloat(ptsStr);
|
||||
|
||||
if (pts > 0 && prevPts > 0) {
|
||||
const diff = Math.abs(pts - prevPts);
|
||||
if (diff > 1.0) { // 1 second jump
|
||||
discontinuities++;
|
||||
}
|
||||
}
|
||||
prevPts = pts;
|
||||
}
|
||||
|
||||
totalDiscontinuities += discontinuities;
|
||||
console.log(`[TSSegmentation] Analysis at ${(point * 100).toFixed(0)}% (${timeOffset.toFixed(1)}s): ${discontinuities} discontinuities`);
|
||||
|
||||
} catch (error) {
|
||||
console.warn(`[TSSegmentation] Error analyzing at ${point * 100}%:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[TSSegmentation] Total discontinuities across file: ${totalDiscontinuities}`);
|
||||
return totalDiscontinuities > 0;
|
||||
|
||||
} catch (error) {
|
||||
console.warn(`[TSSegmentation] Error in multi-segment analysis:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file statistics
|
||||
*/
|
||||
private async getFileStats(videoPath: string): Promise<any> {
|
||||
return stat(videoPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file duration using FFprobe
|
||||
*/
|
||||
private async getFileDuration(videoPath: string): Promise<number | null> {
|
||||
try {
|
||||
const result = await this.executeFFprobe([
|
||||
'-v', 'quiet',
|
||||
'-show_entries', 'format=duration',
|
||||
'-of', 'csv=p=0',
|
||||
videoPath
|
||||
]);
|
||||
|
||||
const duration = parseFloat(result.trim());
|
||||
return isNaN(duration) ? null : duration;
|
||||
} catch (error) {
|
||||
console.warn(`[TSSegmentation] Could not get duration for ${videoPath}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new segmentation session or get existing one
|
||||
*/
|
||||
async createSegmentationSession(videoId: number, videoPath: string): Promise<SegmentationSession> {
|
||||
// Check if session already exists and is valid
|
||||
const existingSession = this.sessions.get(videoId);
|
||||
if (existingSession) {
|
||||
if (existingSession.status === 'ready') {
|
||||
existingSession.lastAccessed = new Date();
|
||||
existingSession.referenceCount++;
|
||||
console.log(`[TSSegmentation] Reusing existing session for video ${videoId} (refs: ${existingSession.referenceCount})`);
|
||||
return existingSession;
|
||||
} else if (existingSession.status === 'processing') {
|
||||
// Return the existing processing session
|
||||
existingSession.referenceCount++;
|
||||
return existingSession;
|
||||
}
|
||||
}
|
||||
|
||||
// Check available disk space
|
||||
await this.checkDiskSpace();
|
||||
|
||||
// Check concurrent job limit
|
||||
if (this.activeJobs >= this.config.maxConcurrentJobs) {
|
||||
throw new Error('Maximum concurrent segmentation jobs reached');
|
||||
}
|
||||
|
||||
// Use video ID as the directory name (no session ID needed)
|
||||
const tempDir = path.join(this.config.tempDir, `video-${videoId}`);
|
||||
const playlistPath = path.join(tempDir, 'playlist.m3u8');
|
||||
|
||||
const session: SegmentationSession = {
|
||||
videoId,
|
||||
videoPath,
|
||||
tempDir,
|
||||
playlistPath,
|
||||
segmentCount: 0,
|
||||
totalDuration: 0,
|
||||
createdAt: new Date(),
|
||||
lastAccessed: new Date(),
|
||||
status: 'pending',
|
||||
referenceCount: 1,
|
||||
};
|
||||
|
||||
this.sessions.set(videoId, session);
|
||||
|
||||
try {
|
||||
// Create temp directory
|
||||
await mkdir(tempDir, { recursive: true });
|
||||
|
||||
// Start segmentation process
|
||||
session.status = 'processing';
|
||||
this.activeJobs++;
|
||||
|
||||
await this.performSegmentation(session, videoPath);
|
||||
|
||||
session.status = 'ready';
|
||||
console.log(`[TSSegmentation] Session created successfully for video ${videoId}`);
|
||||
|
||||
} catch (error: any) {
|
||||
session.status = 'error';
|
||||
session.error = error.message;
|
||||
console.error(`[TSSegmentation] Failed to create session for video ${videoId}:`, error);
|
||||
|
||||
// Cleanup failed session
|
||||
await this.cleanupSessionFiles(session);
|
||||
this.sessions.delete(videoId);
|
||||
throw error;
|
||||
} finally {
|
||||
this.activeJobs--;
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a reference to an existing session
|
||||
*/
|
||||
async addReference(videoId: number): Promise<SegmentationSession | null> {
|
||||
const session = this.sessions.get(videoId);
|
||||
if (session && session.status === 'ready') {
|
||||
// Validate that files still exist before adding reference
|
||||
if (!fs.existsSync(session.playlistPath)) {
|
||||
console.warn(`[TSSegmentation] Session ${videoId} files missing, removing invalid session`);
|
||||
this.sessions.delete(videoId);
|
||||
return null;
|
||||
}
|
||||
|
||||
session.lastAccessed = new Date();
|
||||
session.referenceCount++;
|
||||
console.log(`[TSSegmentation] Added reference to video ${videoId} (refs: ${session.referenceCount})`);
|
||||
return session;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a reference from a session
|
||||
*/
|
||||
async removeReference(videoId: number): Promise<void> {
|
||||
const session = this.sessions.get(videoId);
|
||||
if (session) {
|
||||
session.referenceCount = Math.max(0, session.referenceCount - 1);
|
||||
console.log(`[TSSegmentation] Removed reference from video ${videoId} (refs: ${session.referenceCount})`);
|
||||
|
||||
// If no more references and session is old enough, mark for cleanup
|
||||
if (session.referenceCount === 0) {
|
||||
const timeSinceLastAccess = Date.now() - session.lastAccessed.getTime();
|
||||
if (timeSinceLastAccess > 300000) { // 5 minute grace period
|
||||
console.log(`[TSSegmentation] Cleaning up unreferenced session for video ${videoId} after ${Math.round(timeSinceLastAccess/1000)}s`);
|
||||
await this.cleanupSession(videoId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an existing session
|
||||
*/
|
||||
async getSession(videoId: number): Promise<SegmentationSession | null> {
|
||||
const session = this.sessions.get(videoId);
|
||||
if (session) {
|
||||
session.lastAccessed = new Date();
|
||||
|
||||
// Validate that the session files still exist
|
||||
if (session.status === 'ready' && !fs.existsSync(session.playlistPath)) {
|
||||
console.warn(`[TSSegmentation] Session ${videoId} exists but files are missing, cleaning up`);
|
||||
this.sessions.delete(videoId);
|
||||
return null;
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the file path for a specific segment
|
||||
*/
|
||||
async getSegmentPath(videoId: number, segmentIndex: number): Promise<string | null> {
|
||||
const session = await this.getSession(videoId);
|
||||
if (!session || session.status !== 'ready') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const segmentPath = path.join(session.tempDir, `segment_${segmentIndex.toString().padStart(3, '0')}.ts`);
|
||||
|
||||
try {
|
||||
await access(segmentPath);
|
||||
return segmentPath;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup a specific session
|
||||
*/
|
||||
async cleanupSession(videoId: number): Promise<void> {
|
||||
const session = this.sessions.get(videoId);
|
||||
if (session) {
|
||||
await this.cleanupSessionFiles(session);
|
||||
this.sessions.delete(videoId);
|
||||
console.log(`[TSSegmentation] Cleaned up session for video ${videoId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup expired sessions (now based on reference count and age)
|
||||
*/
|
||||
async cleanupExpiredSessions(): Promise<void> {
|
||||
const now = new Date();
|
||||
const expiredSessions: number[] = [];
|
||||
|
||||
for (const [videoId, session] of this.sessions.entries()) {
|
||||
const timeSinceLastAccess = now.getTime() - session.lastAccessed.getTime();
|
||||
|
||||
// Only cleanup sessions with no references and that haven't been accessed recently
|
||||
if (session.referenceCount === 0 && timeSinceLastAccess > this.config.sessionTTL) {
|
||||
expiredSessions.push(videoId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const videoId of expiredSessions) {
|
||||
await this.cleanupSession(videoId);
|
||||
}
|
||||
|
||||
if (expiredSessions.length > 0) {
|
||||
console.log(`[TSSegmentation] Cleaned up ${expiredSessions.length} expired sessions`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform FFmpeg segmentation
|
||||
*/
|
||||
private async performSegmentation(session: SegmentationSession, videoPath: string): Promise<void> {
|
||||
const args = [
|
||||
'-i', videoPath,
|
||||
'-c:v', 'copy',
|
||||
'-c:a', 'copy',
|
||||
'-f', 'hls',
|
||||
'-hls_time', this.config.segmentDuration.toString(),
|
||||
'-hls_list_size', '0',
|
||||
'-hls_segment_filename', path.join(session.tempDir, 'segment_%03d.ts'),
|
||||
'-hls_flags', 'append_list',
|
||||
'-y',
|
||||
session.playlistPath
|
||||
];
|
||||
|
||||
console.log(`[TSSegmentation] Starting FFmpeg segmentation: ffmpeg ${args.join(' ')}`);
|
||||
|
||||
const result = await this.executeFFmpeg(args);
|
||||
|
||||
// Parse the generated playlist to get segment info
|
||||
if (fs.existsSync(session.playlistPath)) {
|
||||
const playlistContent = fs.readFileSync(session.playlistPath, 'utf8');
|
||||
session.segmentCount = (playlistContent.match(/\.ts/g) || []).length;
|
||||
|
||||
// Extract total duration from playlist
|
||||
const durationMatch = playlistContent.match(/#EXTINF:([\d.]+)/g);
|
||||
if (durationMatch) {
|
||||
session.totalDuration = durationMatch.reduce((total, match) => {
|
||||
const duration = parseFloat(match.replace('#EXTINF:', ''));
|
||||
return total + duration;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// Update the playlist to use proper API URLs
|
||||
await this.updatePlaylistUrls(session);
|
||||
|
||||
console.log(`[TSSegmentation] Segmentation complete: ${session.segmentCount} segments, ${session.totalDuration.toFixed(2)}s total`);
|
||||
} else {
|
||||
throw new Error('Playlist file was not created');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update playlist URLs to use proper API endpoints
|
||||
*/
|
||||
private async updatePlaylistUrls(session: SegmentationSession): Promise<void> {
|
||||
try {
|
||||
const playlistContent = fs.readFileSync(session.playlistPath, 'utf8');
|
||||
|
||||
// Replace relative segment filenames with API URLs
|
||||
// Keep the original padded format (000, 001, etc.) to match the API route expectations
|
||||
const updatedContent = playlistContent.replace(
|
||||
/segment_(\d+)\.ts/g,
|
||||
(match, segmentNum) => {
|
||||
return `segment/${segmentNum}.ts`;
|
||||
}
|
||||
);
|
||||
|
||||
// Write the updated playlist back
|
||||
fs.writeFileSync(session.playlistPath, updatedContent);
|
||||
|
||||
console.log(`[TSSegmentation] Updated playlist URLs for video ${session.videoId}`);
|
||||
console.log(`[TSSegmentation] Sample URLs: segment_000.ts -> segment/000.ts`);
|
||||
} catch (error) {
|
||||
console.error(`[TSSegmentation] Error updating playlist URLs:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute FFmpeg command
|
||||
*/
|
||||
private executeFFmpeg(args: string[]): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ffmpeg = spawn('ffmpeg', args);
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
ffmpeg.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
ffmpeg.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
ffmpeg.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve(stdout);
|
||||
} else {
|
||||
reject(new Error(`FFmpeg failed with code ${code}: ${stderr}`));
|
||||
}
|
||||
});
|
||||
|
||||
ffmpeg.on('error', (error) => {
|
||||
reject(new Error(`FFmpeg process error: ${error.message}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute FFprobe command
|
||||
*/
|
||||
private executeFFprobe(args: string[]): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ffprobe = spawn('ffprobe', args);
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
ffprobe.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
ffprobe.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
ffprobe.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve(stdout);
|
||||
} else {
|
||||
reject(new Error(`FFprobe failed with code ${code}: ${stderr}`));
|
||||
}
|
||||
});
|
||||
|
||||
ffprobe.on('error', (error) => {
|
||||
reject(new Error(`FFprobe process error: ${error.message}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup session files
|
||||
*/
|
||||
private async cleanupSessionFiles(session: SegmentationSession): Promise<void> {
|
||||
try {
|
||||
if (fs.existsSync(session.tempDir)) {
|
||||
const files = await readdir(session.tempDir);
|
||||
for (const file of files) {
|
||||
await unlink(path.join(session.tempDir, file));
|
||||
}
|
||||
await rmdir(session.tempDir);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[TSSegmentation] Error cleaning up session files:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure temp directory exists
|
||||
*/
|
||||
private async ensureTempDir(): Promise<void> {
|
||||
try {
|
||||
await mkdir(this.config.tempDir, { recursive: true });
|
||||
} catch (error) {
|
||||
console.error(`[TSSegmentation] Failed to create temp directory:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check available disk space
|
||||
*/
|
||||
private async checkDiskSpace(): Promise<void> {
|
||||
try {
|
||||
const stats = await stat(this.config.tempDir);
|
||||
// This is a simplified check - in production, you'd want to check actual available space
|
||||
console.log(`[TSSegmentation] Temp directory exists, assuming sufficient space`);
|
||||
} catch (error) {
|
||||
throw new Error('Insufficient disk space for segmentation');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique session ID
|
||||
*/
|
||||
private generateSessionId(): string {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substr(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start cleanup scheduler
|
||||
*/
|
||||
private startCleanupScheduler(): void {
|
||||
this.cleanupTimer = setInterval(() => {
|
||||
this.cleanupExpiredSessions().catch(error => {
|
||||
console.error('[TSSegmentation] Cleanup scheduler error:', error);
|
||||
});
|
||||
}, this.config.cleanupInterval);
|
||||
|
||||
console.log(`[TSSegmentation] Cleanup scheduler started (interval: ${this.config.cleanupInterval}ms)`);
|
||||
console.log(`[TSSegmentation] Service initialized with restoration capability`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop cleanup scheduler
|
||||
*/
|
||||
public stopCleanupScheduler(): void {
|
||||
if (this.cleanupTimer) {
|
||||
clearInterval(this.cleanupTimer);
|
||||
this.cleanupTimer = undefined;
|
||||
console.log('[TSSegmentation] Cleanup scheduler stopped');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current statistics
|
||||
*/
|
||||
public getStats() {
|
||||
return {
|
||||
activeSessions: this.sessions.size,
|
||||
activeJobs: this.activeJobs,
|
||||
config: this.config,
|
||||
sessions: Array.from(this.sessions.values()).map(session => ({
|
||||
videoId: session.videoId,
|
||||
status: session.status,
|
||||
segmentCount: session.segmentCount,
|
||||
totalDuration: session.totalDuration,
|
||||
referenceCount: session.referenceCount,
|
||||
createdAt: session.createdAt,
|
||||
lastAccessed: session.lastAccessed,
|
||||
}))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const tsSegmentationService = new TSSegmentationService();
|
||||
export default tsSegmentationService;
|
||||
Loading…
Reference in New Issue