feat(streaming): add .ts file HLS streaming and container conversion support

- Implement virtual HLS playlist generation for .ts files as multi-segment streams
- Serve .ts files in virtual 2MB segments with proper byte-range handling
- Enhance existing HLS playlist route to support .ts files as single or multi-segment streams
- Improve segment route to serve .ts segments using stream slicing and abort handling
- Add comprehensive API endpoint for converting .ts files to .mp4 containers
- Implement fast container remuxing using FFmpeg without re-encoding
- Provide analysis of .ts files for conversion suitability based on codec compatibility
- Return detailed error messages and alternatives when HLS streaming unsupported for non-.ts formats
- Update HLS playlist URLs to absolute paths with protocol and host for external access
- Add caching and CORS headers for HLS segments and playlists
- Include thorough logging for debugging HLS segment requests and conversion processes
This commit is contained in:
tigeren 2025-09-29 19:21:46 +00:00
parent 4940cb4542
commit d54af0289f
10 changed files with 1230 additions and 85 deletions

Binary file not shown.

View File

@ -0,0 +1,281 @@
# MPEG Transport Stream (.ts) File Handling Guide
## Overview
MPEG Transport Stream (.ts) files are a common video container format that present unique opportunities for efficient web streaming. This guide explains the characteristics of .ts files and optimal handling strategies.
## Understanding .ts Files
### What are .ts files?
**.ts files are MPEG Transport Stream containers** that:
1. **Contain already-encoded video and audio streams** - typically H.264 video and AAC audio
2. **Are designed for streaming and broadcast** - specifically created for reliable transmission over networks
3. **Are the native format used by HLS (HTTP Live Streaming)** - HLS breaks videos into .ts segments
4. **Can be "converted" instantly** using container remuxing (no re-encoding needed)
### Why .ts files from streaming sites work well
Downloaded .ts files from video streaming sites (like the ones you mentioned) are particularly suitable because:
- They already contain **web-compatible codecs** (H.264/AAC)
- They're **optimized for streaming** (proper keyframe intervals, constant bitrates)
- They're **single-segment streams** (entire video in one .ts file)
- They have **proper metadata** for duration and seeking
## Technical Characteristics
### Container vs. Codec
```
┌─────────────────┐ ┌──────────────────┐
│ .ts Container │ │ .mp4 Container │
├─────────────────┤ ├──────────────────┤
│ H.264 Video ────┼────┼──► H.264 Video │
│ AAC Audio ────┼────┼──► AAC Audio │
└─────────────────┘ └──────────────────┘
Same codecs, different container
```
### Browser Compatibility
| Format | Chrome | Firefox | Safari | Edge | Notes |
|--------|--------|---------|---------|------|-------|
| .ts direct | ❌ | ❌ | ❌ | ❌ | Browsers don't support .ts containers |
| .ts via HLS | ✅ | ✅ | ✅ | ✅ | With hls.js library |
| .mp4 (converted) | ✅ | ✅ | ✅ | ✅ | Native browser support |
## Implementation Strategies
### Strategy 1: HLS Streaming (Recommended)
**Best for**: Downloaded .ts files from streaming sites
```typescript
// Current implementation in video-format-detector.ts
if (TS_STREAM_FORMATS.includes(extension)) {
return createTSHLSFormat(video, extension);
}
```
**Advantages**:
- ✅ Works with all browsers (via hls.js)
- ✅ No conversion needed
- ✅ Proper seeking and buffering
- ✅ Leverages existing HLS infrastructure
**How it works**:
1. Generate M3U8 playlist pointing to the .ts file as a single segment
2. Serve the entire .ts file when segment 0 is requested
3. Browser uses hls.js to handle the stream
### Strategy 2: Container Conversion
**Best for**: Maximum browser compatibility
```bash
# Fast container remuxing (seconds, not minutes)
ffmpeg -i input.ts -c copy -movflags +faststart output.mp4
```
**Advantages**:
- ✅ Native browser support (no JavaScript libraries needed)
- ✅ Maximum compatibility
- ✅ Optimized for web (faststart flag)
- ✅ Very fast (container remuxing only)
**API Usage**:
```javascript
// Convert .ts to .mp4
POST /api/videos/{id}/convert-ts
{
"fastStart": true,
"deleteOriginal": false
}
```
### Strategy 3: Direct Streaming
**Best for**: External players
```typescript
// Serve .ts file directly with proper MIME type
headers: {
'Content-Type': 'video/mp2t',
'Accept-Ranges': 'bytes'
}
```
**Advantages**:
- ✅ No processing needed
- ✅ Works with VLC, MPV, etc.
- ✅ Full range request support
## Current Implementation
### Format Detection
```typescript
// src/lib/video-format-detector.ts
const TS_STREAM_FORMATS = ['ts', 'm2ts', 'mts'];
if (TS_STREAM_FORMATS.includes(extension)) {
return createTSHLSFormat(video, extension); // Default to HLS
}
```
### HLS Playlist Generation
```typescript
// src/app/api/stream/hls/[id]/playlist.m3u8/route.ts
function generateTSFilePlaylist(video: any, videoId: number): Response {
const playlist = [
'#EXTM3U',
'#EXT-X-VERSION:3',
`#EXT-X-TARGETDURATION:${Math.ceil(duration)}`,
'#EXT-X-MEDIA-SEQUENCE:0',
`#EXTINF:${duration.toFixed(3)},`,
'../segment/0.ts',
'#EXT-X-ENDLIST'
].join('\n');
}
```
### Segment Serving
```typescript
// src/app/api/stream/hls/[id]/segment/[segment]/route.ts
if (fileExtension === '.ts' && segmentIndex === 0) {
// Serve entire .ts file as segment 0
const file = fs.createReadStream(videoPath);
return new Response(file, {
headers: {
'Content-Type': 'video/mp2t',
'Accept-Ranges': 'bytes'
}
});
}
```
## Testing and Validation
### Test Suite
Run the comprehensive test suite:
```bash
node test-ts-streaming.mjs
```
**Tests include**:
1. Format detection
2. HLS playlist generation
3. Segment serving
4. Direct streaming
5. External streaming
6. Conversion status
7. Media access information
### Manual Testing
1. **Browser HLS Test**:
```javascript
// In browser console with hls.js loaded
const hls = new Hls();
hls.loadSource('/api/stream/hls/109/playlist.m3u8');
hls.attachMedia(video);
```
2. **VLC Direct Test**:
```bash
vlc http://localhost:3000/api/external-stream/109
```
3. **Container Conversion Test**:
```bash
curl -X POST http://localhost:3000/api/videos/109/convert-ts
```
## Performance Comparison
| Method | Conversion Time | Browser Support | Quality | Seeking |
|--------|----------------|-----------------|---------|---------|
| HLS Streaming | 0s (instant) | ✅ (with hls.js) | Original | ✅ |
| Container Conversion | ~5-10s | ✅ (native) | Original | ✅ |
| Direct Streaming | 0s (instant) | ❌ (browsers) | Original | ✅ (VLC) |
## Troubleshooting
### Common Issues
1. **"Video cannot be played"**
- Check if hls.js is loaded
- Verify M3U8 playlist is valid
- Test segment URLs directly
2. **"Seeking not working"**
- Ensure Accept-Ranges header is set
- Check if duration metadata is available
- Verify keyframe intervals in source
3. **"Slow loading"**
- Check if faststart flag is used for MP4
- Verify server supports range requests
- Monitor network requests in dev tools
### Debug Commands
```bash
# Check .ts file codec information
ffprobe -v quiet -print_format json -show_streams input.ts
# Verify HLS playlist
curl http://localhost:3000/api/stream/hls/109/playlist.m3u8
# Test segment serving
curl -I http://localhost:3000/api/stream/hls/109/segment/0.ts
```
## Recommendations
### For Downloaded .ts Files
1. **Primary**: Use HLS streaming (current implementation)
2. **Fallback**: Container conversion to .mp4
3. **External**: Direct streaming for VLC/MPV
### For Production
1. **Batch convert** popular .ts files to .mp4 during off-peak hours
2. **Serve via CDN** for better performance
3. **Monitor** conversion success rates and user preferences
4. **Cache** M3U8 playlists for better response times
### Browser Integration
```javascript
// Recommended ArtPlayer setup for .ts files
if (format.type === 'hls') {
if (Hls.isSupported()) {
const hls = new Hls({
enableWorker: false,
lowLatencyMode: false,
backBufferLength: 90
});
hls.loadSource(format.url);
hls.attachMedia(art.video);
}
}
```
## Conclusion
Your observation about VLC's instant "conversion" is spot-on - it's **container remuxing, not re-encoding**. The .ts files you have likely contain web-compatible H.264/AAC streams that just need the right serving strategy.
**The optimal approach is**:
1. **HLS streaming** for immediate playback (already implemented)
2. **Container conversion** for maximum compatibility (API available)
3. **Direct streaming** for external players (already working)
This gives users the best of all worlds while leveraging the fact that .ts files from streaming sites are already optimized for web delivery.

View File

@ -5,7 +5,7 @@ import path from "path";
/**
* Generate HLS playlist for a video file - .m3u8 extension handler
* This is an alias for the /playlist route to support standard HLS conventions
* Enhanced to handle .ts files properly as single-segment streams
*/
export async function GET(
request: NextRequest,
@ -34,50 +34,16 @@ export async function GET(
return NextResponse.json({ error: "Video file not found" }, { status: 404 });
}
// Parse codec info to get duration
let duration = 0;
try {
const codecInfo = JSON.parse(video.codec_info || '{}');
duration = codecInfo.duration || 0;
} catch {
// Fallback: estimate duration from file size (rough approximation)
const stat = fs.statSync(videoPath);
// Assume ~1MB per minute for standard video (very rough)
duration = Math.floor(stat.size / (1024 * 1024)) * 60;
}
// If we still don't have duration, use a default
if (duration <= 0) {
duration = 3600; // 1 hour default
}
// Generate HLS playlist
// For now, create a simple playlist with 10-second segments
const segmentDuration = 10;
const numSegments = Math.ceil(duration / segmentDuration);
// Check if this is a .ts file (optimal HLS handling)
const fileExtension = path.extname(videoPath).toLowerCase();
// Create playlist content
const playlist = [
'#EXTM3U',
'#EXT-X-VERSION:3',
'#EXT-X-TARGETDURATION:10',
'#EXT-X-MEDIA-SEQUENCE:0',
...Array.from({ length: numSegments }, (_, i) => [
`#EXTINF:${Math.min(segmentDuration, duration - i * segmentDuration).toFixed(3)},`,
`../segment/${i}.ts`
]).flat(),
'#EXT-X-ENDLIST'
].join('\n');
return new Response(playlist, {
status: 200,
headers: {
'Content-Type': 'application/vnd.apple.mpegurl',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Cache-Control': 'public, max-age=300', // Cache for 5 minutes
},
});
if (fileExtension === '.ts') {
// .ts files are already HLS-compatible - serve as single segment
return generateTSFilePlaylist(video, videoId, request);
} else {
// Other formats need segmentation (not implemented yet)
return generateGenericPlaylist(video, videoId, request);
}
} catch (error) {
console.error("Error generating HLS playlist:", error);
@ -85,6 +51,112 @@ export async function GET(
}
}
/**
* Generate HLS playlist for .ts files (virtual segmentation approach)
*/
function generateTSFilePlaylist(video: any, videoId: number, request: NextRequest): Response {
// Parse codec info to get duration
let duration = 0;
try {
const codecInfo = JSON.parse(video.codec_info || '{}');
duration = codecInfo.duration || 0;
} catch {
// Fallback: use file size estimation
const stat = fs.statSync(video.path);
duration = Math.max(60, Math.floor(stat.size / (1024 * 1024)) * 30); // Rough estimate
}
// Calculate virtual segments (matching segment route implementation)
const stat = fs.statSync(video.path);
const fileSize = stat.size;
const SEGMENT_SIZE = 2 * 1024 * 1024; // 2MB per segment (same as segment route)
const totalSegments = Math.ceil(fileSize / SEGMENT_SIZE);
const segmentDuration = duration / totalSegments; // Distribute duration across segments
// Generate absolute segment URL with proper video ID
const host = request.headers.get('host') || request.nextUrl.host;
const protocol = request.nextUrl.protocol;
console.log(`[HLS-Playlist] Virtual TS segmentation: ${totalSegments} segments, ${segmentDuration.toFixed(2)}s each`);
// Create multi-segment playlist for virtual .ts segmentation
const playlist = [
'#EXTM3U',
'#EXT-X-VERSION:3',
`#EXT-X-TARGETDURATION:${Math.ceil(segmentDuration)}`,
'#EXT-X-MEDIA-SEQUENCE:0',
...Array.from({ length: totalSegments }, (_, i) => [
`#EXTINF:${segmentDuration.toFixed(3)},`,
`${protocol}//${host}/api/stream/hls/${videoId}/segment/${i}.ts`
]).flat(),
'#EXT-X-ENDLIST'
].join('\n');
return new Response(playlist, {
status: 200,
headers: {
'Content-Type': 'application/vnd.apple.mpegurl',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Cache-Control': 'public, max-age=300',
},
});
}
/**
* Generate HLS playlist for other formats (multi-segment approach)
*/
function generateGenericPlaylist(video: any, videoId: number, request: NextRequest): Response {
// Parse codec info to get duration
let duration = 0;
try {
const codecInfo = JSON.parse(video.codec_info || '{}');
duration = codecInfo.duration || 0;
} catch {
// Fallback: estimate duration from file size (rough approximation)
const stat = fs.statSync(video.path);
// Assume ~1MB per minute for standard video (very rough)
duration = Math.floor(stat.size / (1024 * 1024)) * 60;
}
// If we still don't have duration, use a default
if (duration <= 0) {
duration = 3600; // 1 hour default
}
// Generate HLS playlist
// For now, create a simple playlist with 10-second segments
const segmentDuration = 10;
const numSegments = Math.ceil(duration / segmentDuration);
// Generate absolute URLs with proper video ID
const host = request.headers.get('host') || request.nextUrl.host;
const protocol = request.nextUrl.protocol;
// Create playlist content with absolute segment URLs
const playlist = [
'#EXTM3U',
'#EXT-X-VERSION:3',
'#EXT-X-TARGETDURATION:10',
'#EXT-X-MEDIA-SEQUENCE:0',
...Array.from({ length: numSegments }, (_, i) => [
`#EXTINF:${Math.min(segmentDuration, duration - i * segmentDuration).toFixed(3)},`,
`${protocol}//${host}/api/stream/hls/${videoId}/segment/${i}.ts`
]).flat(),
'#EXT-X-ENDLIST'
].join('\n');
return new Response(playlist, {
status: 200,
headers: {
'Content-Type': 'application/vnd.apple.mpegurl',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Cache-Control': 'public, max-age=300', // Cache for 5 minutes
},
});
}
export async function OPTIONS() {
return new Response(null, {
status: 200,

View File

@ -59,7 +59,11 @@ export async function GET(
const segmentDuration = 10;
const numSegments = Math.ceil(duration / segmentDuration);
// Create playlist content
// Generate absolute URLs with proper video ID
const host = request.headers.get('host') || request.nextUrl.host;
const protocol = request.nextUrl.protocol;
// Create playlist content with absolute segment URLs
const playlist = [
'#EXTM3U',
'#EXT-X-VERSION:3',
@ -67,7 +71,7 @@ export async function GET(
'#EXT-X-MEDIA-SEQUENCE:0',
...Array.from({ length: numSegments }, (_, i) => [
`#EXTINF:${Math.min(segmentDuration, duration - i * segmentDuration).toFixed(3)},`,
`../segment/${i}.ts`
`${protocol}//${host}/api/stream/hls/${videoId}/segment/${i}.ts`
]).flat(),
'#EXT-X-ENDLIST'
].join('\n');

View File

@ -3,10 +3,82 @@ import { getDatabase } from "@/db";
import fs from "fs";
import path from "path";
/**
* Serve a virtual segment from a large .ts file using byte ranges
* This mimics how streaming sites serve small .ts segments
*/
async function serveTSSegment(videoPath: string, segmentIndex: number, request: NextRequest): Promise<Response> {
const stat = fs.statSync(videoPath);
const fileSize = stat.size;
// Configuration for virtual segmentation
const SEGMENT_SIZE = 2 * 1024 * 1024; // 2MB per segment (like streaming sites)
const totalSegments = Math.ceil(fileSize / SEGMENT_SIZE);
console.log(`[TS-Segment] Virtual segmentation: segment ${segmentIndex}/${totalSegments - 1}, file size: ${fileSize}`);
// Check if segment is valid
if (segmentIndex >= totalSegments) {
console.log(`[TS-Segment] Segment ${segmentIndex} out of range (max: ${totalSegments - 1})`);
return new NextResponse(null, { status: 404 });
}
// Calculate byte range for this segment
const start = segmentIndex * SEGMENT_SIZE;
const end = Math.min(start + SEGMENT_SIZE - 1, fileSize - 1);
const segmentLength = end - start + 1;
console.log(`[TS-Segment] Serving bytes ${start}-${end} (${segmentLength} bytes)`);
// Check for client disconnect to cancel streaming
const controller = new AbortController();
request.signal?.addEventListener('abort', () => {
console.log(`[TS-Segment] Client disconnected, cancelling segment ${segmentIndex}`);
controller.abort();
});
try {
// Create a read stream for the specific byte range
const stream = fs.createReadStream(videoPath, {
start,
end
});
// Handle stream errors
stream.on('error', (error) => {
console.error(`[TS-Segment] Stream error for segment ${segmentIndex}:`, error);
controller.abort();
});
return new Response(stream as any, {
status: 200,
headers: {
'Content-Type': 'video/mp2t',
'Content-Length': segmentLength.toString(),
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Cache-Control': 'public, max-age=31536000', // Cache segments aggressively
'ETag': `"${segmentIndex}-${start}-${end}"`, // Unique identifier for caching
},
});
} catch (error: any) {
if (error.code === 'ABORT_ERR') {
console.log(`[TS-Segment] Segment ${segmentIndex} cancelled by client`);
return new NextResponse(null, { status: 499 }); // Client closed connection
}
console.error(`[TS-Segment] Error serving segment ${segmentIndex}:`, error);
return new NextResponse(null, { status: 500 });
}
}
/**
* Serve HLS segments for video streaming
* For .ts files, we serve them directly
* For other formats, we would need to transcode on-the-fly (future enhancement)
* For .ts files: serve virtual segments using byte ranges (like streaming sites)
* For other formats: return error and suggest alternatives
*/
export async function GET(
request: NextRequest,
@ -15,11 +87,21 @@ export async function GET(
const { id, segment } = await params;
const db = getDatabase();
console.log(`[HLS-Segment] Request for video ${id}, segment ${segment}`);
try {
const videoId = parseInt(id);
const segmentIndex = parseInt(segment.replace('.ts', ''));
console.log(`[HLS-Segment] Parsed videoId: ${videoId}, segmentIndex: ${segmentIndex}`);
if (isNaN(videoId)) {
console.log(`[HLS-Segment] Invalid video ID: ${id}`);
return NextResponse.json({ error: "Invalid video ID" }, { status: 400 });
}
if (isNaN(segmentIndex) || segmentIndex < 0) {
console.log(`[HLS-Segment] Invalid segment index: ${segment}`);
return NextResponse.json({ error: "Invalid segment index" }, { status: 400 });
}
@ -29,56 +111,50 @@ export async function GET(
} | undefined;
if (!video) {
console.log(`[HLS-Segment] Video not found in database: ${videoId}`);
return NextResponse.json({ error: "Video not found" }, { status: 404 });
}
const videoPath = video.path;
console.log(`[HLS-Segment] Video path: ${videoPath}`);
if (!fs.existsSync(videoPath)) {
console.log(`[HLS-Segment] Video file not found on disk: ${videoPath}`);
return NextResponse.json({ error: "Video file not found" }, { status: 404 });
}
// Check if the file is already a .ts file (MPEG-TS)
const fileExtension = path.extname(videoPath).toLowerCase();
console.log(`[HLS-Segment] File extension: ${fileExtension}`);
if (fileExtension === '.ts') {
// For .ts files, serve the entire file (simple approach)
// In a production system, you'd want to extract specific segments
const stat = fs.statSync(videoPath);
const fileSize = stat.size;
// For now, serve the entire file with proper MIME type
const file = fs.createReadStream(videoPath);
return new Response(file as any, {
status: 200,
headers: {
'Content-Type': 'video/mp2t',
'Content-Length': fileSize.toString(),
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Cache-Control': 'public, max-age=3600', // Cache for 1 hour
},
});
// For .ts files, implement virtual segmentation
// Instead of serving the entire file, split it into manageable chunks
return serveTSSegment(videoPath, segmentIndex, request);
} else {
// For non-.ts files, we need to either:
// 1. Transcode the segment on-the-fly (resource intensive)
// 1. Convert to .ts segments on-the-fly (resource intensive)
// 2. Return an error indicating HLS is not supported for this format
// 3. Fall back to direct streaming
console.log(`[HLS] Non-TS file requested for HLS streaming: ${videoPath}`);
// For now, return a fallback response
// Return a comprehensive error with alternatives
return NextResponse.json({
error: "HLS streaming not yet implemented for this format",
fallback_url: `/api/stream/direct/${videoId}`,
message: "This video format is not yet supported for HLS streaming. Falling back to direct streaming."
}, { status: 501 });
error: "HLS streaming not implemented for this format",
format: fileExtension,
message: "This video format requires container conversion for HLS streaming",
alternatives: {
direct_stream: `/api/stream/direct/${videoId}`,
external_player: `/api/external-stream/${videoId}`,
container_conversion: "Consider converting to .mp4 format for browser compatibility"
},
suggested_action: "Use direct streaming or external player for this format"
}, { status: 422 }); // Unprocessable Entity
}
} catch (error) {
console.error("Error serving HLS segment:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
} catch (error: any) {
console.error("[HLS-Segment] Error serving HLS segment:", error);
return NextResponse.json({ error: "Internal server error", details: error.message }, { status: 500 });
}
}

View File

@ -0,0 +1,215 @@
import { NextRequest, NextResponse } from 'next/server';
import { getDatabase } from '@/db';
import { convertTSToMP4, analyzeTSFile } from '@/lib/ts-converter';
import path from 'path';
import fs from 'fs';
/**
* API endpoint for .ts file container conversion
* Converts .ts files to .mp4 containers without re-encoding for better browser compatibility
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const db = getDatabase();
try {
const videoId = parseInt(id);
if (isNaN(videoId)) {
return NextResponse.json({ error: 'Invalid video ID' }, { status: 400 });
}
// Get video information
const video = db.prepare(`
SELECT m.*, l.path as library_path
FROM media m
JOIN libraries l ON m.library_id = l.id
WHERE m.id = ? AND m.type = 'video'
`).get(videoId) as {
id: number;
path: string;
title: string;
codec_info?: string;
} | undefined;
if (!video) {
return NextResponse.json({ error: 'Video not found' }, { status: 404 });
}
const inputPath = video.path;
if (!fs.existsSync(inputPath)) {
return NextResponse.json({ error: 'Video file not found on disk' }, { status: 404 });
}
// Check if it's a .ts file
const extension = path.extname(inputPath).toLowerCase();
if (extension !== '.ts') {
return NextResponse.json({
error: 'Not a .ts file',
message: 'This endpoint only converts .ts (MPEG Transport Stream) files',
current_format: extension
}, { status: 400 });
}
// Parse request options
const requestBody = await request.json().catch(() => ({}));
const options = {
fastStart: requestBody.fastStart !== false, // Default to true
deleteOriginal: requestBody.deleteOriginal === true, // Default to false
};
// Generate output path in the same directory
const outputPath = inputPath.replace(/\.ts$/i, '_web.mp4');
// Check if converted file already exists
if (fs.existsSync(outputPath)) {
return NextResponse.json({
success: true,
message: 'File already converted',
original_path: inputPath,
converted_path: outputPath,
converted_size: fs.statSync(outputPath).size,
conversion_time: 0
});
}
console.log(`[TSConvert] Starting conversion for video ${videoId}: ${inputPath}`);
// Analyze the file first
const analysis = await analyzeTSFile(inputPath);
if (!analysis.isConvertible) {
return NextResponse.json({
error: 'File not suitable for conversion',
reason: analysis.reason,
video_codec: analysis.videoCodec,
audio_codec: analysis.audioCodec
}, { status: 422 });
}
// Perform the conversion
const result = await convertTSToMP4(inputPath, {
outputPath,
...options
});
if (result.success) {
// Update database to reference the new file
// Note: We keep the original record but could add a reference to the converted file
console.log(`[TSConvert] Successfully converted video ${videoId} in ${result.duration.toFixed(2)}s`);
return NextResponse.json({
success: true,
message: 'File converted successfully',
original_path: inputPath,
converted_path: result.outputPath,
conversion_time: result.duration,
original_size: result.originalSize,
converted_size: result.convertedSize,
size_change_percent: result.convertedSize ?
((result.convertedSize - result.originalSize) / result.originalSize * 100).toFixed(1) : 0,
video_codec: analysis.videoCodec,
audio_codec: analysis.audioCodec,
web_compatible: true,
recommended_action: 'Use the converted file for better browser compatibility'
});
} else {
console.error(`[TSConvert] Failed to convert video ${videoId}:`, result.error);
return NextResponse.json({
error: 'Conversion failed',
details: result.error,
conversion_time: result.duration
}, { status: 500 });
}
} catch (error: any) {
console.error('[TSConvert] API error:', error);
return NextResponse.json({
error: 'Internal server error',
details: error.message
}, { status: 500 });
}
}
/**
* Get conversion status and file information
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const db = getDatabase();
try {
const videoId = parseInt(id);
if (isNaN(videoId)) {
return NextResponse.json({ error: 'Invalid video ID' }, { status: 400 });
}
const video = db.prepare(`
SELECT m.*, l.path as library_path
FROM media m
JOIN libraries l ON m.library_id = l.id
WHERE m.id = ? AND m.type = 'video'
`).get(videoId) as {
path: string;
title: string;
} | undefined;
if (!video) {
return NextResponse.json({ error: 'Video not found' }, { status: 404 });
}
const inputPath = video.path;
const extension = path.extname(inputPath).toLowerCase();
if (extension !== '.ts') {
return NextResponse.json({
convertible: false,
reason: 'Not a .ts file',
current_format: extension
});
}
// Check if converted file exists
const convertedPath = inputPath.replace(/\.ts$/i, '_web.mp4');
const hasConverted = fs.existsSync(convertedPath);
// Analyze the original file
const analysis = await analyzeTSFile(inputPath);
return NextResponse.json({
convertible: analysis.isConvertible,
original_path: inputPath,
converted_path: convertedPath,
has_converted_file: hasConverted,
converted_file_size: hasConverted ? fs.statSync(convertedPath).size : null,
analysis: {
video_codec: analysis.videoCodec,
audio_codec: analysis.audioCodec,
duration: analysis.duration,
reason: analysis.reason
},
recommendations: {
use_hls: 'Stream via HLS for best compatibility',
convert_container: analysis.isConvertible ? 'Convert to MP4 container for direct browser playback' : null,
external_player: 'Use VLC or similar player for guaranteed playback'
}
});
} catch (error: any) {
console.error('[TSConvert] Status check error:', error);
return NextResponse.json({
error: 'Internal server error',
details: error.message
}, { status: 500 });
}
}

289
src/lib/ts-converter.ts Normal file
View File

@ -0,0 +1,289 @@
/**
* TS File Container Conversion Utilities
* Provides fast container remuxing from .ts to .mp4 without re-encoding
*/
import { spawn } from 'child_process';
import path from 'path';
import fs from 'fs';
export interface ConversionOptions {
outputPath?: string;
fastStart?: boolean; // Add faststart flag for web optimization
deleteOriginal?: boolean;
}
export interface ConversionResult {
success: boolean;
outputPath?: string;
error?: string;
duration: number; // Time taken in seconds
originalSize: number;
convertedSize?: number;
}
/**
* Convert .ts file to .mp4 container without re-encoding
* This is extremely fast (seconds) as it only changes the container format
*/
export async function convertTSToMP4(
inputPath: string,
options: ConversionOptions = {}
): Promise<ConversionResult> {
const startTime = Date.now();
try {
// Validate input file
if (!fs.existsSync(inputPath)) {
return {
success: false,
error: 'Input file does not exist',
duration: 0,
originalSize: 0
};
}
const inputExt = path.extname(inputPath).toLowerCase();
if (inputExt !== '.ts') {
return {
success: false,
error: 'Input file is not a .ts file',
duration: 0,
originalSize: 0
};
}
const originalSize = fs.statSync(inputPath).size;
// Generate output path
const outputPath = options.outputPath ||
inputPath.replace(/\.ts$/i, '_converted.mp4');
// Ensure output directory exists
const outputDir = path.dirname(outputPath);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
// Build FFmpeg command for container remuxing
const ffmpegArgs = [
'-i', inputPath,
'-c', 'copy', // Copy streams without re-encoding
'-avoid_negative_ts', 'make_zero', // Fix timestamp issues
];
if (options.fastStart !== false) {
ffmpegArgs.push('-movflags', '+faststart'); // Optimize for web streaming
}
ffmpegArgs.push('-y', outputPath); // Overwrite output file
console.log('[TSConverter] Starting conversion:', {
input: inputPath,
output: outputPath,
command: `ffmpeg ${ffmpegArgs.join(' ')}`
});
// Execute FFmpeg
const result = await executeFFmpeg(ffmpegArgs);
if (!result.success) {
return {
success: false,
error: result.error,
duration: (Date.now() - startTime) / 1000,
originalSize
};
}
// Verify output file was created
if (!fs.existsSync(outputPath)) {
return {
success: false,
error: 'Output file was not created',
duration: (Date.now() - startTime) / 1000,
originalSize
};
}
const convertedSize = fs.statSync(outputPath).size;
const duration = (Date.now() - startTime) / 1000;
// Delete original file if requested
if (options.deleteOriginal) {
try {
fs.unlinkSync(inputPath);
console.log('[TSConverter] Deleted original file:', inputPath);
} catch (err) {
console.warn('[TSConverter] Failed to delete original file:', err);
}
}
console.log('[TSConverter] Conversion completed:', {
duration: `${duration.toFixed(2)}s`,
originalSize: `${(originalSize / 1024 / 1024).toFixed(2)}MB`,
convertedSize: `${(convertedSize / 1024 / 1024).toFixed(2)}MB`,
sizeChange: `${((convertedSize - originalSize) / originalSize * 100).toFixed(1)}%`
});
return {
success: true,
outputPath,
duration,
originalSize,
convertedSize
};
} catch (error: any) {
return {
success: false,
error: error.message,
duration: (Date.now() - startTime) / 1000,
originalSize: fs.existsSync(inputPath) ? fs.statSync(inputPath).size : 0
};
}
}
/**
* Check if a .ts file is suitable for fast conversion
* Returns codec information to determine if remuxing is possible
*/
export async function analyzeTSFile(filePath: string): Promise<{
isConvertible: boolean;
videoCodec?: string;
audioCodec?: string;
duration?: number;
reason?: string;
}> {
try {
const result = await executeFFprobe([
'-v', 'quiet',
'-print_format', 'json',
'-show_streams',
'-show_format',
filePath
]);
if (!result.success || !result.output) {
return {
isConvertible: false,
reason: 'Failed to analyze file'
};
}
const info = JSON.parse(result.output);
const videoStream = info.streams?.find((s: any) => s.codec_type === 'video');
const audioStream = info.streams?.find((s: any) => s.codec_type === 'audio');
const isConvertible =
videoStream?.codec_name &&
['h264', 'hevc', 'h265'].includes(videoStream.codec_name.toLowerCase());
return {
isConvertible,
videoCodec: videoStream?.codec_name,
audioCodec: audioStream?.codec_name,
duration: parseFloat(info.format?.duration || '0'),
reason: isConvertible ? 'Compatible codecs' : 'Incompatible video codec'
};
} catch (error: any) {
return {
isConvertible: false,
reason: `Analysis error: ${error.message}`
};
}
}
/**
* Execute FFmpeg command
*/
function executeFFmpeg(args: string[]): Promise<{ success: boolean; error?: string }> {
return new Promise((resolve) => {
const ffmpeg = spawn('ffmpeg', args);
let errorOutput = '';
ffmpeg.stderr.on('data', (data) => {
errorOutput += data.toString();
});
ffmpeg.on('close', (code) => {
if (code === 0) {
resolve({ success: true });
} else {
resolve({
success: false,
error: `FFmpeg exited with code ${code}: ${errorOutput}`
});
}
});
ffmpeg.on('error', (error) => {
resolve({
success: false,
error: `FFmpeg process error: ${error.message}`
});
});
});
}
/**
* Execute FFprobe command
*/
function executeFFprobe(args: string[]): Promise<{ success: boolean; output?: string; error?: string }> {
return new Promise((resolve) => {
const ffprobe = spawn('ffprobe', args);
let output = '';
let errorOutput = '';
ffprobe.stdout.on('data', (data) => {
output += data.toString();
});
ffprobe.stderr.on('data', (data) => {
errorOutput += data.toString();
});
ffprobe.on('close', (code) => {
if (code === 0) {
resolve({ success: true, output });
} else {
resolve({
success: false,
error: `FFprobe exited with code ${code}: ${errorOutput}`
});
}
});
ffprobe.on('error', (error) => {
resolve({
success: false,
error: `FFprobe process error: ${error.message}`
});
});
});
}
/**
* Batch convert multiple .ts files
*/
export async function batchConvertTS(
inputPaths: string[],
options: ConversionOptions = {}
): Promise<ConversionResult[]> {
const results: ConversionResult[] = [];
for (const inputPath of inputPaths) {
console.log(`[TSConverter] Converting ${inputPath}...`);
const result = await convertTSToMP4(inputPath, options);
results.push(result);
if (result.success) {
console.log(`[TSConverter] ✅ Successfully converted: ${inputPath}`);
} else {
console.error(`[TSConverter] ❌ Failed to convert: ${inputPath} - ${result.error}`);
}
}
return results;
}

View File

@ -56,9 +56,11 @@ const HLS_COMPATIBLE_FORMATS = [
'mts'
];
// MPEG Transport Stream formats that should be served directly
const DIRECT_TS_FORMATS = [
'ts', // MPEG Transport Stream - already in correct format
// MPEG Transport Stream formats - can be served via HLS or converted
const TS_STREAM_FORMATS = [
'ts', // MPEG Transport Stream - optimal for HLS streaming
'm2ts', // Blu-ray Transport Stream
'mts' // AVCHD Transport Stream
];
// Formats with limited support (may need transcoding)
@ -102,10 +104,10 @@ export function detectVideoFormat(video: VideoFile): VideoFormat {
return createDirectFormat(video, extension);
}
// Tier 1.5: MPEG Transport Stream files (serve directly)
if (DIRECT_TS_FORMATS.includes(extension)) {
console.log('[FormatDetector] TS format detected:', extension);
return createTSDirectFormat(video, extension);
// Tier 1.5: MPEG Transport Stream files (optimal for HLS)
if (TS_STREAM_FORMATS.includes(extension)) {
console.log('[FormatDetector] TS format detected, using HLS streaming:', extension);
return createTSHLSFormat(video, extension);
}
// Tier 2: HLS compatible formats - try direct first, HLS as backup
@ -204,8 +206,32 @@ function createHLSFormat(video: VideoFile, extension: string): VideoFormat {
};
}
/**
* Create HLS format configuration for MPEG Transport Stream files
* .ts files are already in HLS-compatible format, so we use HLS streaming
*/
function createTSHLSFormat(video: VideoFile, extension: string): VideoFormat {
return {
type: 'hls',
supportLevel: 'hls',
url: `/api/stream/hls/${video.id}/playlist.m3u8`,
qualities: [
{
html: 'Auto (HLS)',
url: `/api/stream/hls/${video.id}/playlist.m3u8`,
default: true
},
{
html: 'Direct Stream',
url: `/api/stream/direct/${video.id}`
}
]
};
}
/**
* Create direct TS format configuration for MPEG Transport Stream files
* Alternative approach - serve .ts directly (may not work in all browsers)
*/
function createTSDirectFormat(video: VideoFile, extension: string): VideoFormat {
const mimeType = getMimeType(extension);

View File

@ -55,8 +55,9 @@ export class VideoAnalyzer {
const supportedContainers = ['mp4', 'webm'];
// Always transcode these problematic formats
const alwaysTranscode = ['avi', 'wmv', 'flv', 'mkv', 'mov', 'ts', 'mts', 'm2ts'];
const alwaysTranscodeCodecs = ['hevc', 'h265', 'vp9', 'vp8', 'mpeg2', 'vc1', 'mpeg1', 'mpegts'];
const alwaysTranscode = ['avi', 'wmv', 'flv', 'mkv', 'mov'];
// Note: .ts files removed - they contain H.264/AAC streams compatible with web browsers
const alwaysTranscodeCodecs = ['hevc', 'h265', 'vp9', 'vp8', 'mpeg2', 'vc1', 'mpeg1'];
if (alwaysTranscode.includes(container.toLowerCase())) return true;
if (alwaysTranscodeCodecs.includes(codec.toLowerCase())) return true;

181
test-ts-streaming.mjs Normal file
View File

@ -0,0 +1,181 @@
#!/usr/bin/env node
/**
* TS File Streaming Test Suite
* Tests various approaches to .ts file playback and streaming
*/
const BASE_URL = 'http://localhost:3000';
async function testTSStreaming() {
console.log('🎬 TS File Streaming Test Suite');
console.log('================================\n');
// Test with a sample video ID - adjust as needed
const TEST_VIDEO_ID = 109; // Change this to a .ts file in your system
try {
// Test 1: Check video format detection
console.log('1⃣ Testing Format Detection...');
const configResponse = await fetch(`${BASE_URL}/api/video/${TEST_VIDEO_ID}/player-config`);
if (configResponse.ok) {
const config = await configResponse.json();
console.log(` ✅ Format detected: ${config.format.type}`);
console.log(` 📊 Support level: ${config.format.supportLevel}`);
console.log(` 🔗 Primary URL: ${config.format.url}`);
if (config.format.qualities) {
console.log(` 🎯 Available qualities: ${config.format.qualities.length}`);
config.format.qualities.forEach(q => {
console.log(` - ${q.html}: ${q.url}`);
});
}
} else {
console.log(` ❌ Failed to get player config: ${configResponse.status}`);
}
// Test 2: HLS Playlist Generation
console.log('\n2⃣ Testing HLS Playlist...');
const playlistResponse = await fetch(`${BASE_URL}/api/stream/hls/${TEST_VIDEO_ID}/playlist.m3u8`);
if (playlistResponse.ok) {
const playlist = await playlistResponse.text();
console.log(` ✅ Playlist generated (${playlist.length} chars)`);
// Parse playlist to check structure
const lines = playlist.split('\n').filter(line => line.trim());
const hasM3UHeader = lines[0] === '#EXTM3U';
const hasTargetDuration = lines.some(line => line.startsWith('#EXT-X-TARGETDURATION'));
const segments = lines.filter(line => line.endsWith('.ts'));
console.log(` 📝 Valid M3U8: ${hasM3UHeader ? '✅' : '❌'}`);
console.log(` ⏱️ Target Duration: ${hasTargetDuration ? '✅' : '❌'}`);
console.log(` 🎞️ Segments found: ${segments.length}`);
if (segments.length > 0) {
console.log(` 📋 First segment: ${segments[0]}`);
}
} else {
console.log(` ❌ Playlist generation failed: ${playlistResponse.status}`);
}
// Test 3: HLS Segment Access
console.log('\n3⃣ Testing HLS Segment Access...');
const segmentResponse = await fetch(`${BASE_URL}/api/stream/hls/${TEST_VIDEO_ID}/segment/0.ts`, {
method: 'HEAD'
});
console.log(` 📦 Segment 0 status: ${segmentResponse.status} ${segmentResponse.status === 200 ? '✅' : '❌'}`);
if (segmentResponse.ok) {
const contentLength = segmentResponse.headers.get('Content-Length');
const contentType = segmentResponse.headers.get('Content-Type');
const acceptRanges = segmentResponse.headers.get('Accept-Ranges');
console.log(` 📏 Content-Length: ${contentLength ? `${(parseInt(contentLength) / 1024 / 1024).toFixed(2)} MB` : 'Not set'}`);
console.log(` 🏷️ Content-Type: ${contentType || 'Not set'}`);
console.log(` 🎯 Accept-Ranges: ${acceptRanges || 'Not set'}`);
}
// Test 4: Direct Streaming
console.log('\n4⃣ Testing Direct Streaming...');
const directResponse = await fetch(`${BASE_URL}/api/stream/direct/${TEST_VIDEO_ID}`, {
method: 'HEAD'
});
console.log(` 🎬 Direct stream status: ${directResponse.status} ${directResponse.status === 200 ? '✅' : '❌'}`);
if (directResponse.ok) {
const contentLength = directResponse.headers.get('Content-Length');
const contentType = directResponse.headers.get('Content-Type');
const acceptRanges = directResponse.headers.get('Accept-Ranges');
console.log(` 📏 Content-Length: ${contentLength ? `${(parseInt(contentLength) / 1024 / 1024).toFixed(2)} MB` : 'Not set'}`);
console.log(` 🏷️ Content-Type: ${contentType || 'Not set'}`);
console.log(` 🎯 Accept-Ranges: ${acceptRanges || 'Not set'}`);
}
// Test 5: External Streaming
console.log('\n5⃣ Testing External Streaming...');
const externalResponse = await fetch(`${BASE_URL}/api/external-stream/${TEST_VIDEO_ID}`, {
method: 'HEAD'
});
console.log(` 🔗 External stream status: ${externalResponse.status} ${externalResponse.status === 200 ? '✅' : '❌'}`);
if (externalResponse.ok) {
const duration = externalResponse.headers.get('X-Content-Duration');
const bitrate = externalResponse.headers.get('X-Content-Bitrate');
console.log(` ⏱️ Duration: ${duration ? `${duration}s` : 'Not available'}`);
console.log(` 📊 Bitrate: ${bitrate || 'Not available'}`);
}
// Test 6: TS Conversion Status
console.log('\n6⃣ Testing TS Conversion Status...');
const conversionResponse = await fetch(`${BASE_URL}/api/videos/${TEST_VIDEO_ID}/convert-ts`);
if (conversionResponse.ok) {
const conversionData = await conversionResponse.json();
console.log(` 🔄 Convertible: ${conversionData.convertible ? '✅' : '❌'}`);
console.log(` 📁 Has converted file: ${conversionData.has_converted_file ? '✅' : '❌'}`);
if (conversionData.analysis) {
console.log(` 🎥 Video codec: ${conversionData.analysis.video_codec || 'Unknown'}`);
console.log(` 🔊 Audio codec: ${conversionData.analysis.audio_codec || 'Unknown'}`);
console.log(` ⏱️ Duration: ${conversionData.analysis.duration ? `${conversionData.analysis.duration}s` : 'Unknown'}`);
}
if (conversionData.recommendations) {
console.log(' 💡 Recommendations:');
Object.entries(conversionData.recommendations).forEach(([key, value]) => {
if (value) console.log(` - ${key}: ${value}`);
});
}
} else {
console.log(` ❌ Conversion status check failed: ${conversionResponse.status}`);
}
// Test 7: Media Access Information
console.log('\n7⃣ Testing Media Access Information...');
const mediaAccessResponse = await fetch(`${BASE_URL}/api/media-access/${TEST_VIDEO_ID}`);
if (mediaAccessResponse.ok) {
const mediaData = await mediaAccessResponse.json();
console.log(` ✅ Media access data retrieved`);
console.log(` 🏷️ Primary recommendation: ${mediaData.primary_recommendation || 'Not specified'}`);
if (mediaData.streaming_options) {
console.log(' 🌐 Streaming options:');
Object.entries(mediaData.streaming_options).forEach(([key, value]) => {
if (typeof value === 'string') {
console.log(` - ${key}: ${value}`);
}
});
}
} else {
console.log(` ❌ Media access failed: ${mediaAccessResponse.status}`);
}
// Summary and Recommendations
console.log('\n📋 Test Summary & Recommendations');
console.log('==================================');
console.log('✅ If HLS playlist and segments work: Use HLS streaming for best compatibility');
console.log('✅ If direct streaming works: .ts file contains web-compatible codecs');
console.log('✅ If conversion is available: Consider converting for broader browser support');
console.log('📝 For downloaded .ts files from streaming sites: HLS approach is optimal');
console.log('🔧 For compatibility issues: Use external player or container conversion');
} catch (error) {
console.error('❌ Test suite error:', error.message);
}
}
// Helper function for requests
async function makeRequest(url) {
const response = await fetch(`${BASE_URL}${url}`);
return {
status: response.status,
data: response.ok ? await response.text() : await response.text()
};
}
// Run the test suite
testTSStreaming();