feat(streaming): disable transcoding and add local player guidance
- Disable video transcoding endpoints with HTTP 410 Gone responses - Add detailed messages recommending local media players for unsupported formats - Modify direct stream API to return 415 status with local player usage instructions - Implement enhanced external streaming API with CORS, range requests, and metadata headers - Add universal media access API providing multiple streaming URLs and playback recommendations - Improve MIME type detection and headers for better compatibility with external players - Remove all transcoding logic and related process management code while preserving references - Ensure fallback to direct streaming and local player usage instructions on unsupported formats
This commit is contained in:
parent
20c518a680
commit
4940cb4542
|
|
@ -11,6 +11,7 @@
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@types/fluent-ffmpeg": "^2.1.27",
|
"@types/fluent-ffmpeg": "^2.1.27",
|
||||||
"@types/glob": "^8.1.0",
|
"@types/glob": "^8.1.0",
|
||||||
|
"@types/xml2js": "^0.4.14",
|
||||||
"artplayer": "^5.3.0",
|
"artplayer": "^5.3.0",
|
||||||
"better-sqlite3": "^12.2.0",
|
"better-sqlite3": "^12.2.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|
@ -26,7 +27,8 @@
|
||||||
"react-window": "^1.8.11",
|
"react-window": "^1.8.11",
|
||||||
"react-window-infinite-loader": "^1.0.10",
|
"react-window-infinite-loader": "^1.0.10",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"xml2js": "^0.6.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
|
@ -857,6 +859,15 @@
|
||||||
"@types/react": "*"
|
"@types/react": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/xml2js": {
|
||||||
|
"version": "0.4.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/xml2js/-/xml2js-0.4.14.tgz",
|
||||||
|
"integrity": "sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ansi-regex": {
|
"node_modules/ansi-regex": {
|
||||||
"version": "6.2.0",
|
"version": "6.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz",
|
||||||
|
|
@ -2457,6 +2468,12 @@
|
||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/sax": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.26.0",
|
"version": "0.26.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
|
||||||
|
|
@ -3209,6 +3226,28 @@
|
||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/xml2js": {
|
||||||
|
"version": "0.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
|
||||||
|
"integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"sax": ">=0.6.0",
|
||||||
|
"xmlbuilder": "~11.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xmlbuilder": {
|
||||||
|
"version": "11.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
|
||||||
|
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yaml": {
|
"node_modules/yaml": {
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@types/fluent-ffmpeg": "^2.1.27",
|
"@types/fluent-ffmpeg": "^2.1.27",
|
||||||
"@types/glob": "^8.1.0",
|
"@types/glob": "^8.1.0",
|
||||||
|
"@types/xml2js": "^0.4.14",
|
||||||
"artplayer": "^5.3.0",
|
"artplayer": "^5.3.0",
|
||||||
"better-sqlite3": "^12.2.0",
|
"better-sqlite3": "^12.2.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|
@ -27,7 +28,8 @@
|
||||||
"react-window": "^1.8.11",
|
"react-window": "^1.8.11",
|
||||||
"react-window-infinite-loader": "^1.0.10",
|
"react-window-infinite-loader": "^1.0.10",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"xml2js": "^0.6.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,296 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getDatabase } from '@/db';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* External Player Streaming API
|
||||||
|
* Optimized for external media players like VLC, MPV, etc.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Proper HTTP Range request support for seeking
|
||||||
|
* - Optimized chunked streaming for large files
|
||||||
|
* - Universal CORS headers for external access
|
||||||
|
* - Detailed content metadata headers
|
||||||
|
*/
|
||||||
|
|
||||||
|
export async function OPTIONS(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
return new Response(null, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Range, User-Agent, Accept',
|
||||||
|
'Access-Control-Max-Age': '86400',
|
||||||
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedId = parseInt(id);
|
||||||
|
if (isNaN(parsedId)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid video ID' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get video information with library path
|
||||||
|
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(parsedId) as {
|
||||||
|
id: number;
|
||||||
|
path: string;
|
||||||
|
size: number;
|
||||||
|
title: string;
|
||||||
|
codec_info?: string;
|
||||||
|
library_path: string;
|
||||||
|
} | undefined;
|
||||||
|
|
||||||
|
if (!video) {
|
||||||
|
return NextResponse.json({ error: 'Video not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoPath = video.path;
|
||||||
|
|
||||||
|
// Check if file exists
|
||||||
|
if (!fs.existsSync(videoPath)) {
|
||||||
|
console.error(`Video file not found: ${videoPath}`);
|
||||||
|
return NextResponse.json({ error: 'Video file not found on disk' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = fs.statSync(videoPath);
|
||||||
|
const fileSize = stat.size;
|
||||||
|
const fileName = path.basename(videoPath);
|
||||||
|
const mimeType = getMimeType(videoPath);
|
||||||
|
|
||||||
|
console.log(`[ExternalStream] Serving ${fileName} (${fileSize} bytes) as ${mimeType}`);
|
||||||
|
|
||||||
|
// Parse range header for partial content requests
|
||||||
|
const range = request.headers.get('range');
|
||||||
|
const userAgent = request.headers.get('user-agent') || 'Unknown';
|
||||||
|
|
||||||
|
console.log(`[ExternalStream] Request from: ${userAgent}, Range: ${range || 'none'}`);
|
||||||
|
|
||||||
|
// Enhanced headers for external players
|
||||||
|
const baseHeaders: Record<string, string> = {
|
||||||
|
'Content-Type': mimeType,
|
||||||
|
'Accept-Ranges': 'bytes',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Range, Content-Type, User-Agent',
|
||||||
|
'Access-Control-Expose-Headers': 'Content-Range, Content-Length, Accept-Ranges',
|
||||||
|
'Cache-Control': 'public, max-age=31536000', // 1 year for better performance
|
||||||
|
'Content-Disposition': `inline; filename="${encodeURIComponent(fileName)}"`,
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add duration and metadata if available
|
||||||
|
if (video.codec_info) {
|
||||||
|
try {
|
||||||
|
const codecData = JSON.parse(video.codec_info);
|
||||||
|
if (codecData.duration) {
|
||||||
|
baseHeaders['X-Content-Duration'] = codecData.duration.toString();
|
||||||
|
}
|
||||||
|
if (codecData.bitrate) {
|
||||||
|
baseHeaders['X-Content-Bitrate'] = codecData.bitrate.toString();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore codec info parsing errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (range) {
|
||||||
|
// Handle range requests for seeking and resuming
|
||||||
|
const matches = range.match(/bytes=(\d*)-(\d*)/);
|
||||||
|
if (!matches) {
|
||||||
|
return new Response('Invalid range header', { status: 416 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = matches[1] ? parseInt(matches[1], 10) : 0;
|
||||||
|
const end = matches[2] ? parseInt(matches[2], 10) : fileSize - 1;
|
||||||
|
|
||||||
|
// Validate range
|
||||||
|
if (start >= fileSize || end >= fileSize || start > end) {
|
||||||
|
return new Response('Range not satisfiable', {
|
||||||
|
status: 416,
|
||||||
|
headers: {
|
||||||
|
...baseHeaders,
|
||||||
|
'Content-Range': `bytes */${fileSize}`,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunkSize = (end - start) + 1;
|
||||||
|
|
||||||
|
console.log(`[ExternalStream] Serving range ${start}-${end}/${fileSize} (${chunkSize} bytes)`);
|
||||||
|
|
||||||
|
// Create optimized read stream for the requested range
|
||||||
|
const stream = fs.createReadStream(videoPath, {
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
highWaterMark: Math.min(chunkSize, 1024 * 1024) // 1MB chunks max
|
||||||
|
});
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
...baseHeaders,
|
||||||
|
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
|
||||||
|
'Content-Length': chunkSize.toString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Response(stream as any, {
|
||||||
|
status: 206, // Partial Content
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Handle full file request
|
||||||
|
console.log(`[ExternalStream] Serving full file (${fileSize} bytes)`);
|
||||||
|
|
||||||
|
const stream = fs.createReadStream(videoPath, {
|
||||||
|
highWaterMark: 1024 * 1024 // 1MB chunks for better streaming
|
||||||
|
});
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
...baseHeaders,
|
||||||
|
'Content-Length': fileSize.toString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Response(stream as any, {
|
||||||
|
status: 200,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[ExternalStream] Error:', error);
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Streaming error',
|
||||||
|
details: error.message
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HEAD request for video metadata without body
|
||||||
|
*/
|
||||||
|
export async function HEAD(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedId = parseInt(id);
|
||||||
|
if (isNaN(parsedId)) {
|
||||||
|
return new Response(null, { 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(parsedId) as {
|
||||||
|
path: string;
|
||||||
|
codec_info?: string;
|
||||||
|
title: string;
|
||||||
|
} | undefined;
|
||||||
|
|
||||||
|
if (!video) {
|
||||||
|
return new Response(null, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoPath = video.path;
|
||||||
|
|
||||||
|
if (!fs.existsSync(videoPath)) {
|
||||||
|
return new Response(null, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = fs.statSync(videoPath);
|
||||||
|
const fileSize = stat.size;
|
||||||
|
const fileName = path.basename(videoPath);
|
||||||
|
const mimeType = getMimeType(videoPath);
|
||||||
|
|
||||||
|
const headers = new Headers({
|
||||||
|
'Content-Length': fileSize.toString(),
|
||||||
|
'Content-Type': mimeType,
|
||||||
|
'Accept-Ranges': 'bytes',
|
||||||
|
'Cache-Control': 'public, max-age=31536000',
|
||||||
|
'Content-Disposition': `inline; filename="${encodeURIComponent(fileName)}"`,
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Range, Content-Type',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add metadata if available
|
||||||
|
if (video.codec_info) {
|
||||||
|
try {
|
||||||
|
const codecData = JSON.parse(video.codec_info);
|
||||||
|
if (codecData.duration) {
|
||||||
|
headers.set('X-Content-Duration', codecData.duration.toString());
|
||||||
|
}
|
||||||
|
if (codecData.bitrate) {
|
||||||
|
headers.set('X-Content-Bitrate', codecData.bitrate.toString());
|
||||||
|
}
|
||||||
|
if (codecData.resolution) {
|
||||||
|
headers.set('X-Content-Resolution', codecData.resolution);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore codec info parsing errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(null, {
|
||||||
|
status: 200,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[ExternalStream] HEAD error:', error);
|
||||||
|
return new Response(null, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enhanced MIME type detection with better codec support
|
||||||
|
*/
|
||||||
|
function getMimeType(filePath: string): string {
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
|
||||||
|
// Enhanced MIME type mappings for better external player compatibility
|
||||||
|
const mimeTypes: Record<string, string> = {
|
||||||
|
// Common video formats
|
||||||
|
'.mp4': 'video/mp4',
|
||||||
|
'.m4v': 'video/mp4', // Treat as MP4 for better compatibility
|
||||||
|
'.webm': 'video/webm',
|
||||||
|
'.ogg': 'video/ogg',
|
||||||
|
'.ogv': 'video/ogg',
|
||||||
|
|
||||||
|
// MPEG Transport Stream
|
||||||
|
'.ts': 'video/mp2t',
|
||||||
|
'.m2ts': 'video/mp2t',
|
||||||
|
'.mts': 'video/mp2t',
|
||||||
|
|
||||||
|
// Container formats
|
||||||
|
'.mkv': 'video/x-matroska',
|
||||||
|
'.avi': 'video/x-msvideo',
|
||||||
|
'.wmv': 'video/x-ms-wmv',
|
||||||
|
'.flv': 'video/x-flv',
|
||||||
|
'.mov': 'video/quicktime',
|
||||||
|
'.3gp': 'video/3gpp',
|
||||||
|
'.vob': 'video/dvd',
|
||||||
|
'.f4v': 'video/x-f4v',
|
||||||
|
'.asf': 'video/x-ms-asf',
|
||||||
|
|
||||||
|
// Less common formats
|
||||||
|
'.rm': 'video/vnd.rn-realvideo',
|
||||||
|
'.rmvb': 'video/vnd.rn-realvideo',
|
||||||
|
'.divx': 'video/x-msvideo',
|
||||||
|
'.xvid': 'video/x-msvideo',
|
||||||
|
};
|
||||||
|
|
||||||
|
return mimeTypes[ext] || 'video/mp4'; // Default to MP4 for best compatibility
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,252 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getDatabase } from '@/db';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Universal Media Access API
|
||||||
|
* Provides multiple streaming URLs and protocols for maximum compatibility
|
||||||
|
*
|
||||||
|
* Returns various streaming options:
|
||||||
|
* - Direct HTTP streaming (optimized)
|
||||||
|
* - External player streaming (enhanced range support)
|
||||||
|
* - Protocol-specific URLs for different players
|
||||||
|
* - Metadata and compatibility information
|
||||||
|
*/
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedId = parseInt(id);
|
||||||
|
if (isNaN(parsedId)) {
|
||||||
|
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(parsedId) as {
|
||||||
|
id: number;
|
||||||
|
path: string;
|
||||||
|
size: number;
|
||||||
|
title: string;
|
||||||
|
codec_info?: string;
|
||||||
|
library_path: string;
|
||||||
|
created_at: string;
|
||||||
|
} | undefined;
|
||||||
|
|
||||||
|
if (!video) {
|
||||||
|
return NextResponse.json({ error: 'Video not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse codec information
|
||||||
|
let codecInfo = { needsTranscoding: false, duration: 0, codec: '', container: '' };
|
||||||
|
try {
|
||||||
|
codecInfo = JSON.parse(video.codec_info || '{}');
|
||||||
|
} catch {
|
||||||
|
// Fallback if codec info is invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = request.nextUrl.origin;
|
||||||
|
const fileName = video.path.split('/').pop() || 'video';
|
||||||
|
const fileExtension = fileName.split('.').pop()?.toLowerCase() || '';
|
||||||
|
|
||||||
|
// Generate multiple streaming URLs
|
||||||
|
const streamingOptions = {
|
||||||
|
// Primary streaming endpoints
|
||||||
|
direct: `${baseUrl}/api/stream/direct/${video.id}`,
|
||||||
|
external: `${baseUrl}/api/external-stream/${video.id}`,
|
||||||
|
|
||||||
|
// HLS streaming (if supported)
|
||||||
|
hls: `${baseUrl}/api/stream/hls/${video.id}/playlist.m3u8`,
|
||||||
|
|
||||||
|
// Protocol-specific URLs for external players
|
||||||
|
protocols: {
|
||||||
|
vlc: `vlc://${baseUrl}/api/external-stream/${video.id}`,
|
||||||
|
mpc: `mpc://${baseUrl}/api/external-stream/${video.id}`,
|
||||||
|
potplayer: `potplayer://${baseUrl}/api/external-stream/${video.id}`,
|
||||||
|
mpv: `mpv://${baseUrl}/api/external-stream/${video.id}`,
|
||||||
|
iina: `iina://weblink?url=${encodeURIComponent(`${baseUrl}/api/external-stream/${video.id}`)}`,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Browser-compatible formats
|
||||||
|
browser: {
|
||||||
|
native: isNativeSupported(fileExtension) ? `${baseUrl}/api/stream/direct/${video.id}` : null,
|
||||||
|
transcoded: `${baseUrl}/api/stream/hls/${video.id}/playlist.m3u8`,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// File metadata
|
||||||
|
const metadata = {
|
||||||
|
id: video.id,
|
||||||
|
title: video.title,
|
||||||
|
filename: fileName,
|
||||||
|
size: video.size,
|
||||||
|
format: fileExtension,
|
||||||
|
library: path.dirname(video.library_path),
|
||||||
|
created: video.created_at,
|
||||||
|
|
||||||
|
// Codec information
|
||||||
|
codec: codecInfo.codec || 'unknown',
|
||||||
|
container: codecInfo.container || fileExtension,
|
||||||
|
duration: codecInfo.duration || 0,
|
||||||
|
needsTranscoding: codecInfo.needsTranscoding || false,
|
||||||
|
|
||||||
|
// Compatibility information
|
||||||
|
browserSupported: isNativeSupported(fileExtension),
|
||||||
|
hlsCompatible: isHLSCompatible(fileExtension),
|
||||||
|
requiresExternalPlayer: shouldUseExternalPlayer(fileExtension, codecInfo),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Player recommendations
|
||||||
|
const recommendations = {
|
||||||
|
primary: getPrimaryRecommendation(fileExtension, codecInfo),
|
||||||
|
alternatives: getAlternativeRecommendations(fileExtension),
|
||||||
|
instructions: getPlaybackInstructions(fileExtension, streamingOptions)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Usage examples
|
||||||
|
const examples = {
|
||||||
|
vlc: `vlc "${streamingOptions.external}"`,
|
||||||
|
mpv: `mpv "${streamingOptions.external}"`,
|
||||||
|
iina: `open "${streamingOptions.protocols.iina}"`,
|
||||||
|
wget: `wget "${streamingOptions.external}" -O "${fileName}"`,
|
||||||
|
curl: `curl "${streamingOptions.external}" -o "${fileName}"`,
|
||||||
|
browser: streamingOptions.browser.native || streamingOptions.browser.transcoded,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
success: true,
|
||||||
|
video: metadata,
|
||||||
|
streaming: streamingOptions,
|
||||||
|
recommendations,
|
||||||
|
examples,
|
||||||
|
|
||||||
|
// API information
|
||||||
|
endpoints: {
|
||||||
|
direct: {
|
||||||
|
url: streamingOptions.direct,
|
||||||
|
description: 'Direct file streaming with basic range support',
|
||||||
|
features: ['http-range', 'seeking', 'resume'],
|
||||||
|
compatibility: 'All HTTP clients and media players'
|
||||||
|
},
|
||||||
|
external: {
|
||||||
|
url: streamingOptions.external,
|
||||||
|
description: 'Optimized streaming for external media players',
|
||||||
|
features: ['enhanced-range', 'chunked-streaming', 'metadata-headers'],
|
||||||
|
compatibility: 'VLC, MPV, PotPlayer, MPC-HC, and other media players'
|
||||||
|
},
|
||||||
|
hls: {
|
||||||
|
url: streamingOptions.hls,
|
||||||
|
description: 'HTTP Live Streaming for browsers and compatible players',
|
||||||
|
features: ['adaptive-bitrate', 'browser-native', 'mobile-optimized'],
|
||||||
|
compatibility: 'Modern browsers, iOS, Android, Safari'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(response, {
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Cache-Control': 'public, max-age=300', // 5 minutes cache
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[MediaAccess] Error:', error);
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to retrieve media access information',
|
||||||
|
details: error.message
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if format is natively supported in browsers
|
||||||
|
*/
|
||||||
|
function isNativeSupported(extension: string): boolean {
|
||||||
|
const nativeFormats = ['mp4', 'webm', 'ogg', 'ogv'];
|
||||||
|
return nativeFormats.includes(extension);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if format is compatible with HLS streaming
|
||||||
|
*/
|
||||||
|
function isHLSCompatible(extension: string): boolean {
|
||||||
|
const hlsFormats = ['mp4', 'm4v', 'ts', 'm2ts', 'mts'];
|
||||||
|
return hlsFormats.includes(extension);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if external player is recommended
|
||||||
|
*/
|
||||||
|
function shouldUseExternalPlayer(extension: string, codecInfo: any): boolean {
|
||||||
|
const externalPlayerFormats = ['mkv', 'avi', 'wmv', 'flv', 'mov', 'vob'];
|
||||||
|
return externalPlayerFormats.includes(extension) || codecInfo.needsTranscoding;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get primary recommendation for playback
|
||||||
|
*/
|
||||||
|
function getPrimaryRecommendation(extension: string, codecInfo: any): string {
|
||||||
|
if (isNativeSupported(extension) && !codecInfo.needsTranscoding) {
|
||||||
|
return 'Browser playback recommended - click to play directly';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldUseExternalPlayer(extension, codecInfo)) {
|
||||||
|
return 'External player required - use VLC or similar media player';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'HLS streaming recommended for best compatibility';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get alternative playback recommendations
|
||||||
|
*/
|
||||||
|
function getAlternativeRecommendations(extension: string): string[] {
|
||||||
|
const recommendations = [];
|
||||||
|
|
||||||
|
if (isNativeSupported(extension)) {
|
||||||
|
recommendations.push('Direct browser playback');
|
||||||
|
}
|
||||||
|
|
||||||
|
recommendations.push('External media player (VLC, MPV)');
|
||||||
|
recommendations.push('HLS streaming for mobile devices');
|
||||||
|
recommendations.push('Download for offline viewing');
|
||||||
|
|
||||||
|
return recommendations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get specific playback instructions
|
||||||
|
*/
|
||||||
|
function getPlaybackInstructions(extension: string, streamingOptions: any): Record<string, string[]> {
|
||||||
|
return {
|
||||||
|
browser: [
|
||||||
|
'Click the video thumbnail to start playback',
|
||||||
|
'Use browser built-in controls for seeking',
|
||||||
|
'Right-click for download or picture-in-picture'
|
||||||
|
],
|
||||||
|
vlc: [
|
||||||
|
'Open VLC Media Player',
|
||||||
|
'Press Ctrl+N to open network stream',
|
||||||
|
`Paste URL: ${streamingOptions.external}`,
|
||||||
|
'Click Play to start streaming'
|
||||||
|
],
|
||||||
|
mobile: [
|
||||||
|
'Copy the HLS URL to your mobile video player',
|
||||||
|
'Use apps like VLC Mobile or MX Player',
|
||||||
|
'Paste URL in "Open Network Stream" option'
|
||||||
|
],
|
||||||
|
download: [
|
||||||
|
'Right-click the streaming URL',
|
||||||
|
'Select "Save Link As" or use wget/curl',
|
||||||
|
'Large files may take time to download'
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -56,15 +56,22 @@ export async function GET(
|
||||||
console.log(`[STREAM] Video ID: ${id}, Path: ${video.path}, Codec: ${codecInfo.codec}, Container: ${codecInfo.container}, Force Transcode: ${forceTranscode}, DB Needs Transcode: ${codecInfo.needsTranscoding}, Is H264: ${isH264}, Final Decision: ${needsTranscoding}`);
|
console.log(`[STREAM] Video ID: ${id}, Path: ${video.path}, Codec: ${codecInfo.codec}, Container: ${codecInfo.container}, Force Transcode: ${forceTranscode}, DB Needs Transcode: ${codecInfo.needsTranscoding}, Is H264: ${isH264}, Final Decision: ${needsTranscoding}`);
|
||||||
|
|
||||||
if (needsTranscoding) {
|
if (needsTranscoding) {
|
||||||
console.log(`[STREAM] Redirecting to transcoding endpoint: /api/stream/${id}/transcode`);
|
console.log(`[STREAM] Format requires local player for video ID: ${id}`);
|
||||||
// Return CORS-enabled redirect
|
// TRANSCODING DISABLED: Return local player guidance instead of redirect
|
||||||
const response = NextResponse.redirect(
|
return NextResponse.json({
|
||||||
new URL(`/api/stream/${id}/transcode`, request.url),
|
error: 'Format not supported in browser',
|
||||||
302
|
solution: 'local-player',
|
||||||
);
|
message: 'This video format cannot be played directly in the browser. Please use a local video player.',
|
||||||
response.headers.set('Access-Control-Allow-Origin', '*');
|
directStreamUrl: `/api/stream/direct/${id}`,
|
||||||
response.headers.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
|
recommendedPlayers: ['vlc', 'iina', 'elmedia', 'potplayer'],
|
||||||
return response;
|
action: 'use-local-player',
|
||||||
|
streamInfo: {
|
||||||
|
supportsRangeRequests: true,
|
||||||
|
contentType: getContentType(video.path),
|
||||||
|
authentication: 'none'
|
||||||
|
},
|
||||||
|
helpUrl: '/help/local-players'
|
||||||
|
}, { status: 415 }); // 415 Unsupported Media Type
|
||||||
}
|
}
|
||||||
|
|
||||||
const videoPath = video.path;
|
const videoPath = video.path;
|
||||||
|
|
@ -137,3 +144,27 @@ export async function GET(
|
||||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper function to determine content type based on file extension
|
||||||
|
function getContentType(filePath: string): string {
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
const contentTypes: Record<string, string> = {
|
||||||
|
'.mp4': 'video/mp4',
|
||||||
|
'.webm': 'video/webm',
|
||||||
|
'.ogg': 'video/ogg',
|
||||||
|
'.ogv': 'video/ogg',
|
||||||
|
'.m4v': 'video/x-m4v',
|
||||||
|
'.ts': 'video/mp2t',
|
||||||
|
'.m2ts': 'video/mp2t',
|
||||||
|
'.mts': 'video/mp2t',
|
||||||
|
'.mkv': 'video/x-matroska',
|
||||||
|
'.avi': 'video/x-msvideo',
|
||||||
|
'.wmv': 'video/x-ms-wmv',
|
||||||
|
'.flv': 'video/x-flv',
|
||||||
|
'.mov': 'video/quicktime',
|
||||||
|
'.3gp': 'video/3gpp',
|
||||||
|
'.vob': 'video/dvd'
|
||||||
|
};
|
||||||
|
|
||||||
|
return contentTypes[ext] || 'video/mp4'; // Default fallback
|
||||||
|
}
|
||||||
|
|
@ -1,55 +1,29 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getDatabase } from '@/db';
|
// TRANSCODING DISABLED: These imports are no longer needed
|
||||||
import fs from 'fs';
|
// import { getDatabase } from '@/db';
|
||||||
import { spawn } from 'child_process';
|
// import fs from 'fs';
|
||||||
import { Readable } from 'stream';
|
// import { spawn } from 'child_process';
|
||||||
import { ffmpegRegistry } from '@/lib/ffmpeg/process-registry';
|
// import { Readable } from 'stream';
|
||||||
|
// import { ffmpegRegistry } from '@/lib/ffmpeg/process-registry';
|
||||||
|
|
||||||
// Track active requests to prevent duplicate processing
|
// TRANSCODING DISABLED: Request tracking no longer needed
|
||||||
const activeRequests = new Map<string, Promise<Response>>();
|
// const activeRequests = new Map<string, Promise<Response>>();
|
||||||
|
|
||||||
export async function HEAD(
|
export async function HEAD(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
// Handle HEAD requests by returning just headers without body
|
// TRANSCODING DISABLED: Return 410 Gone with local player guidance
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const db = getDatabase();
|
|
||||||
|
|
||||||
const media = db.prepare('SELECT * FROM media WHERE id = ? AND type = ?').get(id, 'video') as { path: string, codec_info: string } | undefined;
|
return NextResponse.json({
|
||||||
if (!media) {
|
error: 'Transcoding is disabled. This format requires a local video player.',
|
||||||
return NextResponse.json({ error: 'Video not found' }, { status: 404 });
|
suggestedPlayers: ['VLC Media Player', 'Elmedia Player', 'PotPlayer'],
|
||||||
}
|
directStreamUrl: `/api/stream/direct/${id}`,
|
||||||
|
helpUrl: '/help/local-players',
|
||||||
// Get duration from stored codec_info
|
status: 'transcoding-disabled'
|
||||||
let duration = 0;
|
}, { status: 410 }); // 410 Gone
|
||||||
try {
|
|
||||||
const codecInfo = JSON.parse(media.codec_info || '{}');
|
|
||||||
duration = codecInfo.duration || 0;
|
|
||||||
} catch (error) {
|
|
||||||
// Skip ffprobe fallback for HEAD requests
|
|
||||||
}
|
|
||||||
|
|
||||||
const searchParams = request.nextUrl.searchParams;
|
|
||||||
const seekTime = parseFloat(searchParams.get('seek') || '0');
|
|
||||||
|
|
||||||
const headers = new Headers({
|
|
||||||
'Content-Type': 'video/mp4',
|
|
||||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
||||||
'Pragma': 'no-cache',
|
|
||||||
'Expires': '0',
|
|
||||||
'Access-Control-Allow-Origin': '*',
|
|
||||||
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
|
||||||
'X-Content-Duration': duration.toString(),
|
|
||||||
'X-Seek-Time': seekTime.toString(),
|
|
||||||
'X-Transcoded': 'true',
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(null, {
|
|
||||||
status: 200,
|
|
||||||
headers,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('HEAD request error:', error);
|
console.error('HEAD request error:', error);
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
|
@ -60,13 +34,15 @@ export async function OPTIONS(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
|
// TRANSCODING DISABLED: Return 410 Gone for OPTIONS as well
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 200,
|
status: 410, // Gone
|
||||||
headers: {
|
headers: {
|
||||||
'Access-Control-Allow-Origin': '*',
|
'Access-Control-Allow-Origin': '*',
|
||||||
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
||||||
'Access-Control-Allow-Headers': 'Content-Type, Range',
|
'Access-Control-Allow-Headers': 'Content-Type, Range',
|
||||||
'Access-Control-Max-Age': '86400',
|
'Access-Control-Max-Age': '86400',
|
||||||
|
'X-Status': 'transcoding-disabled',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -75,90 +51,39 @@ export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
|
// TRANSCODING DISABLED: Return 410 Gone with comprehensive local player guidance
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const db = getDatabase();
|
|
||||||
|
|
||||||
// Get media file info with codec_info
|
|
||||||
const media = db.prepare('SELECT * FROM media WHERE id = ? AND type = ?').get(id, 'video') as { path: string, codec_info: string } | undefined;
|
|
||||||
if (!media) {
|
|
||||||
console.log(`[TRANSCODE] Video not found for ID: ${id}`);
|
|
||||||
return NextResponse.json({ error: 'Video not found' }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const filePath = media.path;
|
|
||||||
console.log(`[TRANSCODE] Found video at path: ${filePath}`);
|
|
||||||
|
|
||||||
// Get duration from stored codec_info
|
|
||||||
let duration = 0;
|
|
||||||
try {
|
|
||||||
const codecInfo = JSON.parse(media.codec_info || '{}');
|
|
||||||
duration = codecInfo.duration || 0;
|
|
||||||
console.log(`[TRANSCODE] Using stored duration: ${duration}s`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[TRANSCODE] Could not parse codec_info:`, error);
|
|
||||||
console.log(`[TRANSCODE] Using default duration: 0s`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if file exists
|
|
||||||
if (!fs.existsSync(filePath)) {
|
|
||||||
return NextResponse.json({ error: 'File not found' }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get parameters
|
|
||||||
const searchParams = request.nextUrl.searchParams;
|
|
||||||
const quality = searchParams.get('quality') || '720p';
|
|
||||||
const seek = searchParams.get('seek') || '0';
|
|
||||||
const retry = searchParams.get('retry') || '0';
|
|
||||||
const seekTime = parseFloat(seek);
|
|
||||||
const retryCount = parseInt(retry);
|
|
||||||
|
|
||||||
// Configure transcoding based on quality
|
|
||||||
const qualitySettings = {
|
|
||||||
'480p': { width: 854, height: 480, bitrate: '1000k' },
|
|
||||||
'720p': { width: 1280, height: 720, bitrate: '2000k' },
|
|
||||||
'1080p': { width: 1920, height: 1080, bitrate: '4000k' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const settings = qualitySettings[quality as keyof typeof qualitySettings] || qualitySettings['720p'];
|
|
||||||
|
|
||||||
console.log(`[TRANSCODE] Starting transcoding for video ID: ${id}, seek: ${seekTime}s, quality: ${quality}, retry: ${retryCount}`);
|
|
||||||
|
|
||||||
// Create a unique request key for deduplication (without timestamp to allow reuse)
|
|
||||||
const requestKey = `${id}_${seekTime}_${quality}`;
|
|
||||||
|
|
||||||
// Check if there's already an active request for this exact configuration
|
|
||||||
if (activeRequests.has(requestKey)) {
|
|
||||||
console.log(`[TRANSCODE] Reusing active request for ${requestKey}`);
|
|
||||||
return activeRequests.get(requestKey)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the transcoding promise
|
|
||||||
const transcodePromise = createTranscodeStream(id, filePath, seekTime, quality, duration, settings);
|
|
||||||
|
|
||||||
// Store the promise to prevent duplicate requests
|
|
||||||
activeRequests.set(requestKey, transcodePromise);
|
|
||||||
|
|
||||||
// Clean up the request tracking after completion
|
|
||||||
transcodePromise.finally(() => {
|
|
||||||
setTimeout(() => {
|
|
||||||
activeRequests.delete(requestKey);
|
|
||||||
console.log(`[TRANSCODE] Cleaned up active request: ${requestKey}`);
|
|
||||||
}, 5000); // 5 seconds delay to allow for quick retries
|
|
||||||
});
|
|
||||||
|
|
||||||
return transcodePromise;
|
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
error: 'Transcoding is disabled. This format requires a local video player.',
|
||||||
|
message: 'This video format is not supported for direct browser playback. Please use a local video player application.',
|
||||||
|
suggestedPlayers: [
|
||||||
|
{ name: 'VLC Media Player', id: 'vlc', platforms: ['Windows', 'macOS', 'Linux'], url: 'https://www.videolan.org/vlc/' },
|
||||||
|
{ name: 'IINA', id: 'iina', platforms: ['macOS'], url: 'https://iina.io/' },
|
||||||
|
{ name: 'Elmedia Player', id: 'elmedia', platforms: ['macOS'], url: 'https://www.elmedia-video-player.com/' },
|
||||||
|
{ name: 'PotPlayer', id: 'potplayer', platforms: ['Windows'], url: 'https://potplayer.daum.net/' }
|
||||||
|
],
|
||||||
|
directStreamUrl: `/api/stream/direct/${id}`,
|
||||||
|
streamInfo: {
|
||||||
|
supportsRangeRequests: true,
|
||||||
|
contentType: 'video/*',
|
||||||
|
authentication: 'none'
|
||||||
|
},
|
||||||
|
action: 'use-local-player',
|
||||||
|
helpUrl: '/help/local-players',
|
||||||
|
status: 'transcoding-disabled',
|
||||||
|
alternative: 'direct-stream'
|
||||||
|
}, { status: 410 }); // 410 Gone
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Transcoding API error:', error);
|
console.error('Transcoding API error:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
{ error: 'Internal server error' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Separate function to handle the actual transcoding
|
// TRANSCODING DISABLED: Comment out transcoding functionality
|
||||||
|
// This function is no longer used but kept for reference during transition
|
||||||
|
/*
|
||||||
async function createTranscodeStream(
|
async function createTranscodeStream(
|
||||||
id: string,
|
id: string,
|
||||||
filePath: string,
|
filePath: string,
|
||||||
|
|
@ -167,164 +92,26 @@ async function createTranscodeStream(
|
||||||
duration: number,
|
duration: number,
|
||||||
settings: { width: number, height: number, bitrate: string }
|
settings: { width: number, height: number, bitrate: string }
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
try {
|
// Original transcoding logic disabled
|
||||||
// STASH BEHAVIOR: Smart process management
|
// See git history for implementation details
|
||||||
// Only kill existing processes if they're for a significantly different seek time
|
throw new Error('Transcoding is disabled. Use local player instead.');
|
||||||
const existingProcesses = ffmpegRegistry.getProcessesForVideo(id);
|
|
||||||
let shouldStartNewProcess = true;
|
|
||||||
|
|
||||||
for (const processInfo of existingProcesses) {
|
|
||||||
if (Math.abs(processInfo.seekTime - seekTime) < 2.0) { // Within 2 second tolerance
|
|
||||||
console.log(`[TRANSCODE] Found existing process with similar seek time (${processInfo.seekTime}s vs ${seekTime}s), allowing it to continue`);
|
|
||||||
shouldStartNewProcess = false;
|
|
||||||
// Don't kill the existing process - let it continue serving
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
if (shouldStartNewProcess) {
|
// TRANSCODING DISABLED: Cleanup function no longer needed
|
||||||
console.log(`[TRANSCODE] Starting fresh FFmpeg process for video ${id} (seek: ${seekTime}s)`);
|
|
||||||
ffmpegRegistry.killAllForVideo(id);
|
|
||||||
|
|
||||||
// Small delay to ensure processes are fully cleaned up
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 150));
|
|
||||||
} else {
|
|
||||||
console.log(`[TRANSCODE] Allowing existing process to continue serving similar seek time`);
|
|
||||||
// In this case, we still create a new process but could be optimized later
|
|
||||||
// For now, kill and restart to maintain the Stash pattern
|
|
||||||
ffmpegRegistry.killAllForVideo(id);
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 150));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a readable stream from FFmpeg
|
|
||||||
console.log(`[TRANSCODE] Starting FFmpeg process for: ${filePath}`);
|
|
||||||
|
|
||||||
// Build FFmpeg command with seek support (STASH-LIKE: -ss before -i for faster seeking)
|
|
||||||
// Important: Don't use -t parameter to preserve full duration metadata
|
|
||||||
const ffmpegArgs = [
|
|
||||||
'-hide_banner',
|
|
||||||
'-v', 'error',
|
|
||||||
...(seekTime > 0 ? ['-ss', seekTime.toString()] : []), // Seek BEFORE input (faster)
|
|
||||||
'-i', filePath,
|
|
||||||
'-c:v', 'libx264',
|
|
||||||
'-c:a', 'aac',
|
|
||||||
'-b:v', settings.bitrate,
|
|
||||||
'-s', `${settings.width}x${settings.height}`,
|
|
||||||
'-preset', 'fast',
|
|
||||||
'-crf', '23',
|
|
||||||
'-movflags', 'frag_keyframe+empty_moov+faststart',
|
|
||||||
'-f', 'mp4',
|
|
||||||
'-g', '60',
|
|
||||||
'-keyint_min', '60',
|
|
||||||
'-sc_threshold', '0',
|
|
||||||
'-pix_fmt', 'yuv420p',
|
|
||||||
'-profile:v', 'baseline',
|
|
||||||
'-level', '3.0',
|
|
||||||
// Preserve original metadata to maintain duration info
|
|
||||||
'-map_metadata', '0',
|
|
||||||
'-map_metadata:s:v', '0:s:v',
|
|
||||||
'-map_metadata:s:a', '0:s:a',
|
|
||||||
'-fflags', '+genpts',
|
|
||||||
'-avoid_negative_ts', 'make_zero',
|
|
||||||
// Add duration override to ensure correct metadata
|
|
||||||
...(duration > 0 ? ['-metadata', `duration=${duration}`] : []),
|
|
||||||
'pipe:1'
|
|
||||||
];
|
|
||||||
|
|
||||||
console.log(`[TRANSCODE] FFmpeg command (Stash-like): ffmpeg ${ffmpegArgs.join(' ')}`);
|
|
||||||
|
|
||||||
// Use direct spawn like Stash (not fluent-ffmpeg)
|
|
||||||
const ffmpegProcess = spawn('ffmpeg', ffmpegArgs, {
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe']
|
|
||||||
});
|
|
||||||
|
|
||||||
// Register process immediately
|
|
||||||
ffmpegRegistry.register(id, seekTime, ffmpegProcess, ffmpegArgs, quality);
|
|
||||||
console.log(`[TRANSCODE] Registered FFmpeg process for video ${id} with seek ${seekTime}s`);
|
|
||||||
|
|
||||||
// Handle process events
|
|
||||||
ffmpegProcess.on('error', (err) => {
|
|
||||||
console.error(`[TRANSCODE] FFmpeg error:`, err.message);
|
|
||||||
console.log(`[TRANSCODE] FFmpeg errored for ${id}_${seekTime}_${quality}, cleaning up`);
|
|
||||||
});
|
|
||||||
|
|
||||||
ffmpegProcess.on('exit', (code, signal) => {
|
|
||||||
if (signal) {
|
|
||||||
console.log(`[TRANSCODE] FFmpeg process killed with signal: ${signal}`);
|
|
||||||
} else {
|
|
||||||
console.log(`[TRANSCODE] FFmpeg process exited with code: ${code}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle stderr for progress and errors
|
|
||||||
ffmpegProcess.stderr?.on('data', (data) => {
|
|
||||||
const output = data.toString();
|
|
||||||
if (output.includes('time=')) {
|
|
||||||
// Parse time for progress calculation
|
|
||||||
const timeMatch = output.match(/time=(\d{2}):(\d{2}):(\d{2})\.(\d{2})/);
|
|
||||||
if (timeMatch && duration > 0) {
|
|
||||||
const [, hours, minutes, seconds, centiseconds] = timeMatch;
|
|
||||||
const currentTime = parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds) + parseInt(centiseconds) / 100;
|
|
||||||
const totalDuration = duration - seekTime; // Remaining duration from seek point
|
|
||||||
const progress = totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0;
|
|
||||||
console.log(`[TRANSCODE] Progress: ${progress.toFixed(2)}%`);
|
|
||||||
}
|
|
||||||
} else if (output.includes('error') || output.includes('Error')) {
|
|
||||||
console.error(`[TRANSCODE] FFmpeg stderr:`, output.trim());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set response headers for streaming with proper duration info
|
|
||||||
// Always use the stored duration, not the seek-adjusted duration
|
|
||||||
const headers = new Headers({
|
|
||||||
'Content-Type': 'video/mp4',
|
|
||||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
||||||
'Pragma': 'no-cache',
|
|
||||||
'Expires': '0',
|
|
||||||
'Content-Disposition': 'inline',
|
|
||||||
'Access-Control-Allow-Origin': '*',
|
|
||||||
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
|
||||||
'X-Content-Duration': duration.toString(), // Always full duration
|
|
||||||
'X-Seek-Time': seekTime.toString(),
|
|
||||||
'X-Content-Type-Options': 'nosniff',
|
|
||||||
'Accept-Ranges': 'bytes',
|
|
||||||
'X-Transcoded': 'true',
|
|
||||||
// Add custom header to indicate this is a seeked stream
|
|
||||||
'X-Stream-Start-Time': seekTime.toString(),
|
|
||||||
'X-Stream-Full-Duration': duration.toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Convert Node.js stream to Web Stream for Next.js (use stdout directly)
|
|
||||||
const readableStream = Readable.toWeb(ffmpegProcess.stdout as any) as ReadableStream;
|
|
||||||
|
|
||||||
console.log(`[TRANSCODE] Sending response with ${settings.width}x${settings.height} video stream, duration: ${duration}s, seek: ${seekTime}s`);
|
|
||||||
|
|
||||||
// Create response with direct stream (no caching like Stash)
|
|
||||||
const response = new Response(readableStream, {
|
|
||||||
status: 200,
|
|
||||||
headers,
|
|
||||||
});
|
|
||||||
|
|
||||||
return response;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Transcode stream creation error:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup function for manual process termination
|
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
|
// TRANSCODING DISABLED: Return 410 Gone - no processes to clean up
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
// Use enhanced registry to cleanup all processes for this video ID (Stash-like)
|
return NextResponse.json({
|
||||||
const killedCount = ffmpegRegistry.killAllForVideo(id);
|
error: 'Transcoding cleanup is disabled. No processes to terminate.',
|
||||||
console.log(`[CLEANUP] Killed ${killedCount} processes for video: ${id}`);
|
status: 'transcoding-disabled',
|
||||||
|
message: 'Transcoding functionality has been removed. Use local video players instead.'
|
||||||
return NextResponse.json({ success: true, killedProcesses: killedCount });
|
}, { status: 410 }); // 410 Gone
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Cleanup API error:', error);
|
console.error('Cleanup API error:', error);
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,21 @@
|
||||||
import { NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getDatabase } from '@/db';
|
import { getDatabase } from '@/db';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
|
export async function OPTIONS(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
return new Response(null, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Range',
|
||||||
|
'Access-Control-Max-Age': '86400',
|
||||||
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
|
|
@ -40,19 +53,42 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
|
||||||
|
|
||||||
if (range) {
|
if (range) {
|
||||||
// Handle range requests for seeking
|
// Handle range requests for seeking
|
||||||
const parts = range.replace(/bytes=/, "").split("-");
|
const matches = range.match(/bytes=(\d*)-(\d*)/);
|
||||||
const start = parseInt(parts[0], 10);
|
if (!matches) {
|
||||||
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
|
return NextResponse.json({ error: 'Invalid range header' }, { status: 416 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = matches[1] ? parseInt(matches[1], 10) : 0;
|
||||||
|
const end = matches[2] ? parseInt(matches[2], 10) : fileSize - 1;
|
||||||
|
|
||||||
|
// Validate range
|
||||||
|
if (start >= fileSize || end >= fileSize || start > end) {
|
||||||
|
return new Response('Range not satisfiable', {
|
||||||
|
status: 416,
|
||||||
|
headers: {
|
||||||
|
'Content-Range': `bytes */${fileSize}`,
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const chunksize = (end - start) + 1;
|
const chunksize = (end - start) + 1;
|
||||||
|
|
||||||
// Create read stream for the requested range
|
// Create read stream for the requested range with optimized buffer
|
||||||
const stream = fs.createReadStream(videoPath, { start, end });
|
const stream = fs.createReadStream(videoPath, {
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
highWaterMark: Math.min(chunksize, 1024 * 1024) // 1MB chunks max
|
||||||
|
});
|
||||||
|
|
||||||
const headers = new Headers({
|
const headers = new Headers({
|
||||||
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
|
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
|
||||||
'Accept-Ranges': 'bytes',
|
'Accept-Ranges': 'bytes',
|
||||||
'Content-Length': chunksize.toString(),
|
'Content-Length': chunksize.toString(),
|
||||||
'Content-Type': getMimeType(videoPath),
|
'Content-Type': getMimeType(videoPath),
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Range, Content-Type',
|
||||||
'Cache-Control': 'public, max-age=3600',
|
'Cache-Control': 'public, max-age=3600',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -62,11 +98,17 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Handle full file request
|
// Handle full file request
|
||||||
const stream = fs.createReadStream(videoPath);
|
const stream = fs.createReadStream(videoPath, {
|
||||||
|
highWaterMark: 1024 * 1024 // 1MB chunks for better streaming
|
||||||
|
});
|
||||||
|
|
||||||
const headers = new Headers({
|
const headers = new Headers({
|
||||||
'Content-Length': fileSize.toString(),
|
'Content-Length': fileSize.toString(),
|
||||||
'Content-Type': getMimeType(videoPath),
|
'Content-Type': getMimeType(videoPath),
|
||||||
|
'Accept-Ranges': 'bytes',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Range, Content-Type',
|
||||||
'Cache-Control': 'public, max-age=3600',
|
'Cache-Control': 'public, max-age=3600',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,475 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getDatabase } from '@/db';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { Builder } from 'xml2js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebDAV Server Implementation for Video Streaming
|
||||||
|
* Provides standard WebDAV protocol support for external video players
|
||||||
|
*
|
||||||
|
* Supported Methods:
|
||||||
|
* - GET: Download/stream files
|
||||||
|
* - HEAD: File information
|
||||||
|
* - PROPFIND: Directory listing and file properties
|
||||||
|
* - OPTIONS: WebDAV capability discovery
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface WebDAVProp {
|
||||||
|
displayname?: string;
|
||||||
|
resourcetype?: { collection?: string } | null | string;
|
||||||
|
creationdate?: string;
|
||||||
|
getlastmodified?: string;
|
||||||
|
getcontentlength?: string;
|
||||||
|
getcontenttype?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WebDAVResponse {
|
||||||
|
href: string;
|
||||||
|
propstat: {
|
||||||
|
prop: WebDAVProp;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function OPTIONS(request: NextRequest) {
|
||||||
|
return new Response(null, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Allow': 'GET, HEAD, PROPFIND, OPTIONS',
|
||||||
|
'DAV': '1, 2',
|
||||||
|
'MS-Author-Via': 'DAV',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, HEAD, PROPFIND, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Depth, Range, Authorization',
|
||||||
|
'Access-Control-Max-Age': '86400',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PROPFIND(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||||
|
const { path: pathSegments } = await params;
|
||||||
|
const requestPath = pathSegments ? '/' + pathSegments.join('/') : '/';
|
||||||
|
const depth = request.headers.get('depth') || '1';
|
||||||
|
|
||||||
|
console.log(`[WebDAV] PROPFIND request for path: ${requestPath}, depth: ${depth}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
// Root directory listing
|
||||||
|
if (requestPath === '/' || requestPath === '') {
|
||||||
|
return await handleRootPropfind(request, depth);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Library listing (/library/{id})
|
||||||
|
if (requestPath.startsWith('/library/')) {
|
||||||
|
const libraryId = parseInt(pathSegments[1]);
|
||||||
|
return await handleLibraryPropfind(request, libraryId, pathSegments.slice(2), depth);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Video file access (/video/{id})
|
||||||
|
if (requestPath.startsWith('/video/')) {
|
||||||
|
const videoId = parseInt(pathSegments[1]);
|
||||||
|
return await handleVideoPropfind(request, videoId, depth);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response('Not Found', { status: 404 });
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[WebDAV] PROPFIND error:', error);
|
||||||
|
return new Response('Internal Server Error', { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||||
|
const { path: pathSegments } = await params;
|
||||||
|
const requestPath = pathSegments ? '/' + pathSegments.join('/') : '/';
|
||||||
|
|
||||||
|
console.log(`[WebDAV] GET request for path: ${requestPath}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Video file streaming (/video/{id})
|
||||||
|
if (requestPath.startsWith('/video/')) {
|
||||||
|
const videoId = parseInt(pathSegments[1]);
|
||||||
|
return await handleVideoStream(request, videoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response('Not Found', { status: 404 });
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[WebDAV] GET error:', error);
|
||||||
|
return new Response('Internal Server Error', { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function HEAD(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||||
|
const { path: pathSegments } = await params;
|
||||||
|
const requestPath = pathSegments ? '/' + pathSegments.join('/') : '/';
|
||||||
|
|
||||||
|
console.log(`[WebDAV] HEAD request for path: ${requestPath}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Video file information (/video/{id})
|
||||||
|
if (requestPath.startsWith('/video/')) {
|
||||||
|
const videoId = parseInt(pathSegments[1]);
|
||||||
|
return await handleVideoHead(request, videoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(null, { status: 404 });
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[WebDAV] HEAD error:', error);
|
||||||
|
return new Response(null, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle PROPFIND for root directory - lists all libraries
|
||||||
|
*/
|
||||||
|
async function handleRootPropfind(request: NextRequest, depth: string): Promise<Response> {
|
||||||
|
const db = getDatabase();
|
||||||
|
const libraries = db.prepare('SELECT * FROM libraries ORDER BY name').all() as Array<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
created_at: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const responses: WebDAVResponse[] = [
|
||||||
|
// Root collection
|
||||||
|
{
|
||||||
|
href: '/',
|
||||||
|
propstat: {
|
||||||
|
prop: {
|
||||||
|
displayname: 'NextAV Media Server',
|
||||||
|
resourcetype: { collection: '' },
|
||||||
|
creationdate: new Date().toISOString(),
|
||||||
|
getlastmodified: new Date().toUTCString(),
|
||||||
|
},
|
||||||
|
status: 'HTTP/1.1 200 OK'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add libraries if depth > 0
|
||||||
|
if (depth !== '0') {
|
||||||
|
libraries.forEach(library => {
|
||||||
|
responses.push({
|
||||||
|
href: `/library/${library.id}/`,
|
||||||
|
propstat: {
|
||||||
|
prop: {
|
||||||
|
displayname: library.name,
|
||||||
|
resourcetype: { collection: '' },
|
||||||
|
creationdate: library.created_at,
|
||||||
|
getlastmodified: new Date(library.created_at).toUTCString(),
|
||||||
|
},
|
||||||
|
status: 'HTTP/1.1 200 OK'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return createPropfindResponse(responses);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle PROPFIND for library directory - lists videos in library
|
||||||
|
*/
|
||||||
|
async function handleLibraryPropfind(request: NextRequest, libraryId: number, subPath: string[], depth: string): Promise<Response> {
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
// Get library info
|
||||||
|
const library = db.prepare('SELECT * FROM libraries WHERE id = ?').get(libraryId) as {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
created_at: string;
|
||||||
|
} | undefined;
|
||||||
|
|
||||||
|
if (!library) {
|
||||||
|
return new Response('Library not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const responses = [
|
||||||
|
// Library collection
|
||||||
|
{
|
||||||
|
href: `/library/${libraryId}/`,
|
||||||
|
propstat: {
|
||||||
|
prop: {
|
||||||
|
displayname: library.name,
|
||||||
|
resourcetype: { collection: '' },
|
||||||
|
creationdate: library.created_at,
|
||||||
|
getlastmodified: new Date(library.created_at).toUTCString(),
|
||||||
|
} as WebDAVProp,
|
||||||
|
status: 'HTTP/1.1 200 OK'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add videos if depth > 0
|
||||||
|
if (depth !== '0') {
|
||||||
|
const videos = db.prepare(`
|
||||||
|
SELECT m.*, l.name as library_name
|
||||||
|
FROM media m
|
||||||
|
JOIN libraries l ON m.library_id = l.id
|
||||||
|
WHERE m.library_id = ? AND m.type = 'video'
|
||||||
|
ORDER BY m.title
|
||||||
|
`).all(libraryId) as Array<{
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
path: string;
|
||||||
|
size: number;
|
||||||
|
created_at: string;
|
||||||
|
library_name: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
videos.forEach(video => {
|
||||||
|
const fileName = path.basename(video.path);
|
||||||
|
responses.push({
|
||||||
|
href: `/video/${video.id}/${encodeURIComponent(fileName)}`,
|
||||||
|
propstat: {
|
||||||
|
prop: {
|
||||||
|
displayname: video.title || fileName,
|
||||||
|
getcontentlength: video.size.toString(),
|
||||||
|
getcontenttype: getMimeType(video.path),
|
||||||
|
creationdate: video.created_at,
|
||||||
|
getlastmodified: new Date(video.created_at).toUTCString(),
|
||||||
|
resourcetype: null,
|
||||||
|
} as WebDAVProp,
|
||||||
|
status: 'HTTP/1.1 200 OK'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return createPropfindResponse(responses);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle PROPFIND for individual video file
|
||||||
|
*/
|
||||||
|
async function handleVideoPropfind(request: NextRequest, videoId: number, depth: string): Promise<Response> {
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
const video = db.prepare(`
|
||||||
|
SELECT m.*, l.name as library_name
|
||||||
|
FROM media m
|
||||||
|
JOIN libraries l ON m.library_id = l.id
|
||||||
|
WHERE m.id = ? AND m.type = 'video'
|
||||||
|
`).get(videoId) as {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
path: string;
|
||||||
|
size: number;
|
||||||
|
created_at: string;
|
||||||
|
library_name: string;
|
||||||
|
} | undefined;
|
||||||
|
|
||||||
|
if (!video) {
|
||||||
|
return new Response('Video not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = path.basename(video.path);
|
||||||
|
const responses = [
|
||||||
|
{
|
||||||
|
href: `/video/${video.id}/${encodeURIComponent(fileName)}`,
|
||||||
|
propstat: {
|
||||||
|
prop: {
|
||||||
|
displayname: video.title || fileName,
|
||||||
|
getcontentlength: video.size.toString(),
|
||||||
|
getcontenttype: getMimeType(video.path),
|
||||||
|
creationdate: video.created_at,
|
||||||
|
getlastmodified: new Date(video.created_at).toUTCString(),
|
||||||
|
resourcetype: '',
|
||||||
|
} as WebDAVProp,
|
||||||
|
status: 'HTTP/1.1 200 OK'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return createPropfindResponse(responses);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle video streaming with proper range support
|
||||||
|
*/
|
||||||
|
async function handleVideoStream(request: NextRequest, videoId: number): Promise<Response> {
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
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; size: number } | undefined;
|
||||||
|
|
||||||
|
if (!video) {
|
||||||
|
return new Response('Video not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(video.path)) {
|
||||||
|
return new Response('Video file not found on disk', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = fs.statSync(video.path);
|
||||||
|
const fileSize = stat.size;
|
||||||
|
const range = request.headers.get('range');
|
||||||
|
const mimeType = getMimeType(video.path);
|
||||||
|
|
||||||
|
// Handle range requests (crucial for video seeking)
|
||||||
|
if (range) {
|
||||||
|
const parts = range.replace(/bytes=/, "").split("-");
|
||||||
|
const start = parseInt(parts[0], 10);
|
||||||
|
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
|
||||||
|
const chunksize = (end - start) + 1;
|
||||||
|
|
||||||
|
const stream = fs.createReadStream(video.path, { start, end });
|
||||||
|
|
||||||
|
return new Response(stream as any, {
|
||||||
|
status: 206, // Partial Content
|
||||||
|
headers: {
|
||||||
|
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
|
||||||
|
'Accept-Ranges': 'bytes',
|
||||||
|
'Content-Length': chunksize.toString(),
|
||||||
|
'Content-Type': mimeType,
|
||||||
|
'Cache-Control': 'public, max-age=3600',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Range, Content-Type',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Full file request
|
||||||
|
const stream = fs.createReadStream(video.path);
|
||||||
|
|
||||||
|
return new Response(stream as any, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Length': fileSize.toString(),
|
||||||
|
'Content-Type': mimeType,
|
||||||
|
'Accept-Ranges': 'bytes',
|
||||||
|
'Cache-Control': 'public, max-age=3600',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Range, Content-Type',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle HEAD requests for video files
|
||||||
|
*/
|
||||||
|
async function handleVideoHead(request: NextRequest, videoId: number): Promise<Response> {
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
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; size: number; codec_info?: string } | undefined;
|
||||||
|
|
||||||
|
if (!video) {
|
||||||
|
return new Response(null, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(video.path)) {
|
||||||
|
return new Response(null, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = fs.statSync(video.path);
|
||||||
|
const fileSize = stat.size;
|
||||||
|
const mimeType = getMimeType(video.path);
|
||||||
|
|
||||||
|
const headers = new Headers({
|
||||||
|
'Content-Length': fileSize.toString(),
|
||||||
|
'Content-Type': mimeType,
|
||||||
|
'Accept-Ranges': 'bytes',
|
||||||
|
'Cache-Control': 'public, max-age=3600',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Range, Content-Type',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add duration if available
|
||||||
|
if (video.codec_info) {
|
||||||
|
try {
|
||||||
|
const codecData = JSON.parse(video.codec_info);
|
||||||
|
if (codecData.duration) {
|
||||||
|
headers.set('X-Content-Duration', codecData.duration.toString());
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore codec info parsing errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(null, {
|
||||||
|
status: 200,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create XML response for PROPFIND
|
||||||
|
*/
|
||||||
|
function createPropfindResponse(responses: WebDAVResponse[]): Response {
|
||||||
|
const xml = {
|
||||||
|
'D:multistatus': {
|
||||||
|
$: {
|
||||||
|
'xmlns:D': 'DAV:',
|
||||||
|
},
|
||||||
|
'D:response': responses.map(response => ({
|
||||||
|
'D:href': response.href,
|
||||||
|
'D:propstat': {
|
||||||
|
'D:prop': response.propstat.prop,
|
||||||
|
'D:status': response.propstat.status,
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const builder = new Builder({
|
||||||
|
xmldec: { version: '1.0', encoding: 'UTF-8' },
|
||||||
|
renderOpts: { pretty: true, indent: ' ', newline: '\n' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const xmlString = builder.buildObject(xml);
|
||||||
|
|
||||||
|
return new Response(xmlString, {
|
||||||
|
status: 207, // Multi-Status
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/xml; charset=utf-8',
|
||||||
|
'DAV': '1, 2',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get MIME type based on file extension
|
||||||
|
*/
|
||||||
|
function getMimeType(filePath: string): string {
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
const mimeTypes: Record<string, string> = {
|
||||||
|
'.mp4': 'video/mp4',
|
||||||
|
'.webm': 'video/webm',
|
||||||
|
'.ogg': 'video/ogg',
|
||||||
|
'.ogv': 'video/ogg',
|
||||||
|
'.m4v': 'video/x-m4v',
|
||||||
|
'.mov': 'video/quicktime',
|
||||||
|
'.avi': 'video/x-msvideo',
|
||||||
|
'.wmv': 'video/x-ms-wmv',
|
||||||
|
'.flv': 'video/x-flv',
|
||||||
|
'.mkv': 'video/x-matroska',
|
||||||
|
'.ts': 'video/mp2t',
|
||||||
|
'.m2ts': 'video/mp2t',
|
||||||
|
'.mts': 'video/mp2t',
|
||||||
|
'.3gp': 'video/3gpp',
|
||||||
|
'.vob': 'video/dvd'
|
||||||
|
};
|
||||||
|
|
||||||
|
return mimeTypes[ext] || 'video/mp4';
|
||||||
|
}
|
||||||
|
|
@ -22,8 +22,10 @@ const VideosPage = () => {
|
||||||
const [isPlayerOpen, setIsPlayerOpen] = useState(false);
|
const [isPlayerOpen, setIsPlayerOpen] = useState(false);
|
||||||
|
|
||||||
const handleVideoClick = (video: Video) => {
|
const handleVideoClick = (video: Video) => {
|
||||||
|
console.log('[VideosPage] handleVideoClick called with video:', video);
|
||||||
setSelectedVideo(video);
|
setSelectedVideo(video);
|
||||||
setIsPlayerOpen(true);
|
setIsPlayerOpen(true);
|
||||||
|
console.log('[VideosPage] State updated - selectedVideo:', video, 'isPlayerOpen:', true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClosePlayer = () => {
|
const handleClosePlayer = () => {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,482 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
|
import {
|
||||||
|
ExternalLink,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
PlayCircle,
|
||||||
|
Monitor,
|
||||||
|
Settings,
|
||||||
|
HelpCircle,
|
||||||
|
X
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { VideoFormat, VideoFile } from '@/lib/video-format-detector';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface LocalPlayerLauncherProps {
|
||||||
|
video: VideoFile;
|
||||||
|
format: VideoFormat;
|
||||||
|
onClose: () => void;
|
||||||
|
onPlayerSelect?: (player: string) => void;
|
||||||
|
formatFileSize?: (bytes: number) => string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlayerInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
description: string;
|
||||||
|
platforms: string[];
|
||||||
|
downloadUrl: string;
|
||||||
|
protocolUrl?: string;
|
||||||
|
commandLine?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLAYER_INFO: Record<string, PlayerInfo> = {
|
||||||
|
vlc: {
|
||||||
|
id: 'vlc',
|
||||||
|
name: 'VLC Media Player',
|
||||||
|
icon: '🎬',
|
||||||
|
description: 'Free, open-source, cross-platform media player',
|
||||||
|
platforms: ['Windows', 'macOS', 'Linux'],
|
||||||
|
downloadUrl: 'https://www.videolan.org/vlc/',
|
||||||
|
protocolUrl: 'vlc://',
|
||||||
|
commandLine: 'vlc'
|
||||||
|
},
|
||||||
|
elmedia: {
|
||||||
|
id: 'elmedia',
|
||||||
|
name: 'Elmedia Player',
|
||||||
|
icon: '🍎',
|
||||||
|
description: 'Advanced media player for macOS with streaming capabilities',
|
||||||
|
platforms: ['macOS'],
|
||||||
|
downloadUrl: 'https://www.elmedia-video-player.com/',
|
||||||
|
commandLine: 'open -a "Elmedia Player"'
|
||||||
|
},
|
||||||
|
potplayer: {
|
||||||
|
id: 'potplayer',
|
||||||
|
name: 'PotPlayer',
|
||||||
|
icon: '🎯',
|
||||||
|
description: 'Feature-rich media player for Windows',
|
||||||
|
platforms: ['Windows'],
|
||||||
|
downloadUrl: 'https://potplayer.daum.net/',
|
||||||
|
commandLine: 'PotPlayerMini64.exe'
|
||||||
|
},
|
||||||
|
iina: {
|
||||||
|
id: 'iina',
|
||||||
|
name: 'IINA',
|
||||||
|
icon: '🍎',
|
||||||
|
description: 'Modern video player for macOS',
|
||||||
|
platforms: ['macOS'],
|
||||||
|
downloadUrl: 'https://iina.io/',
|
||||||
|
protocolUrl: 'iina://weblink?url=',
|
||||||
|
commandLine: 'open -a IINA'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert relative URL to full URL with protocol and host
|
||||||
|
*/
|
||||||
|
function getFullStreamUrl(relativeUrl: string): string {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return relativeUrl; // Server-side fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// If URL is already absolute, return as-is
|
||||||
|
if (relativeUrl.startsWith('http://') || relativeUrl.startsWith('https://')) {
|
||||||
|
return relativeUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build full URL from current window location
|
||||||
|
const protocol = window.location.protocol;
|
||||||
|
const host = window.location.host;
|
||||||
|
const fullUrl = `${protocol}//${host}${relativeUrl}`;
|
||||||
|
|
||||||
|
console.log('[LocalPlayerLauncher] Converting URL:', relativeUrl, '->', fullUrl);
|
||||||
|
return fullUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate optimized stream URLs for different player types
|
||||||
|
*/
|
||||||
|
function getPlayerSpecificUrl(videoId: number, playerId: string): string {
|
||||||
|
// Use the new external streaming endpoint optimized for media players
|
||||||
|
const baseUrl = `/api/external-stream/${videoId}`;
|
||||||
|
return getFullStreamUrl(baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get comprehensive player launch instructions
|
||||||
|
*/
|
||||||
|
function getPlayerInstructions(playerId: string, streamUrl: string): string {
|
||||||
|
const player = PLAYER_INFO[playerId];
|
||||||
|
if (!player) return 'Copy the stream URL and open it in your media player.';
|
||||||
|
|
||||||
|
const instructions = [];
|
||||||
|
|
||||||
|
// Method 1: Protocol handler (if supported)
|
||||||
|
if (player.protocolUrl) {
|
||||||
|
instructions.push(`• Click "${player.name}" button above to launch automatically`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 2: Manual launch
|
||||||
|
instructions.push(`• Open ${player.name} manually`);
|
||||||
|
instructions.push(`• Press Ctrl+O (or Cmd+O on Mac) to open media`);
|
||||||
|
instructions.push(`• Paste this URL: ${streamUrl}`);
|
||||||
|
|
||||||
|
// Method 3: Drag and drop
|
||||||
|
instructions.push(`• Or drag this browser tab to ${player.name}`);
|
||||||
|
|
||||||
|
return instructions.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LocalPlayerLauncher({
|
||||||
|
video,
|
||||||
|
format,
|
||||||
|
onClose,
|
||||||
|
onPlayerSelect,
|
||||||
|
formatFileSize,
|
||||||
|
className
|
||||||
|
}: LocalPlayerLauncherProps) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [detectedPlayers, setDetectedPlayers] = useState<string[]>([]);
|
||||||
|
const [isDetecting, setIsDetecting] = useState(true);
|
||||||
|
const [launchStatus, setLaunchStatus] = useState<'idle' | 'launching' | 'success' | 'error'>('idle');
|
||||||
|
|
||||||
|
const streamUrl = getPlayerSpecificUrl(video.id, 'vlc'); // Use optimized endpoint
|
||||||
|
const recommendedPlayers = format.recommendedPlayers || ['vlc', 'iina', 'elmedia', 'potplayer'];
|
||||||
|
|
||||||
|
// Detect available players on mount
|
||||||
|
useEffect(() => {
|
||||||
|
detectAvailablePlayers();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const detectAvailablePlayers = async () => {
|
||||||
|
setIsDetecting(true);
|
||||||
|
try {
|
||||||
|
// In a real implementation, this would test protocol handlers
|
||||||
|
// For now, we'll assume VLC is available and filter by platform
|
||||||
|
const available: string[] = [];
|
||||||
|
const platform = getPlatform();
|
||||||
|
|
||||||
|
recommendedPlayers.forEach(playerId => {
|
||||||
|
const player = PLAYER_INFO[playerId];
|
||||||
|
if (player && player.platforms.includes(platform)) {
|
||||||
|
available.push(playerId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setDetectedPlayers(available);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error detecting players:', error);
|
||||||
|
// Fallback to basic recommendation
|
||||||
|
setDetectedPlayers(recommendedPlayers.slice(0, 2));
|
||||||
|
} finally {
|
||||||
|
setIsDetecting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPlatform = (): string => {
|
||||||
|
if (typeof window === 'undefined') return 'Unknown';
|
||||||
|
|
||||||
|
const userAgent = window.navigator.userAgent.toLowerCase();
|
||||||
|
if (userAgent.includes('mac')) return 'macOS';
|
||||||
|
if (userAgent.includes('win')) return 'Windows';
|
||||||
|
if (userAgent.includes('linux')) return 'Linux';
|
||||||
|
return 'Unknown';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyUrl = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(streamUrl);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to copy URL:', error);
|
||||||
|
// Fallback for older browsers
|
||||||
|
const textArea = document.createElement('textarea');
|
||||||
|
textArea.value = streamUrl;
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlayerLaunch = async (playerId: string) => {
|
||||||
|
const player = PLAYER_INFO[playerId];
|
||||||
|
if (!player) return;
|
||||||
|
|
||||||
|
setLaunchStatus('launching');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try protocol handler first (requires user gesture)
|
||||||
|
if (player.protocolUrl) {
|
||||||
|
const protocolUrl = player.protocolUrl + encodeURIComponent(streamUrl);
|
||||||
|
window.location.href = protocolUrl;
|
||||||
|
setLaunchStatus('success');
|
||||||
|
} else {
|
||||||
|
// Fallback to command line approach (would need server-side support)
|
||||||
|
console.log(`Would launch ${player.name} with: ${streamUrl}`);
|
||||||
|
setLaunchStatus('success');
|
||||||
|
}
|
||||||
|
|
||||||
|
onPlayerSelect?.(playerId);
|
||||||
|
|
||||||
|
// Auto-close after successful launch
|
||||||
|
setTimeout(() => {
|
||||||
|
onClose();
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to launch player:', error);
|
||||||
|
setLaunchStatus('error');
|
||||||
|
|
||||||
|
// Reset status after showing error
|
||||||
|
setTimeout(() => {
|
||||||
|
setLaunchStatus('idle');
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleManualOpen = () => {
|
||||||
|
// Open the stream URL in a new tab for manual copy/paste
|
||||||
|
window.open(streamUrl, '_blank');
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderPlayerButton = (playerId: string) => {
|
||||||
|
const player = PLAYER_INFO[playerId];
|
||||||
|
if (!player) return null;
|
||||||
|
|
||||||
|
const isAvailable = detectedPlayers.includes(playerId);
|
||||||
|
const isLaunching = launchStatus === 'launching';
|
||||||
|
const isSuccess = launchStatus === 'success';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={playerId}
|
||||||
|
onClick={() => handlePlayerLaunch(playerId)}
|
||||||
|
disabled={!isAvailable || isLaunching}
|
||||||
|
className={cn(
|
||||||
|
"w-full justify-start h-auto py-3 px-4",
|
||||||
|
"transition-all duration-200",
|
||||||
|
isAvailable ? "hover:scale-105" : "opacity-50 cursor-not-allowed",
|
||||||
|
isSuccess && "bg-green-600 hover:bg-green-600"
|
||||||
|
)}
|
||||||
|
variant={isSuccess ? "default" : "outline"}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 w-full">
|
||||||
|
<span className="text-2xl">{player.icon}</span>
|
||||||
|
<div className="flex-1 text-left">
|
||||||
|
<div className="font-semibold">{player.name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{player.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isSuccess ? (
|
||||||
|
<Check className="h-5 w-5" />
|
||||||
|
) : isLaunching ? (
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current" />
|
||||||
|
) : (
|
||||||
|
<PlayCircle className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (launchStatus === 'error') {
|
||||||
|
return (
|
||||||
|
<div className={cn("fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-4", className)}>
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-red-600">Launch Failed</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Could not launch the video player automatically.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>
|
||||||
|
Try copying the stream URL below and opening it manually in your video player.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button onClick={handleCopyUrl} variant="outline" className="w-full">
|
||||||
|
<Copy className="h-4 w-4 mr-2" />
|
||||||
|
{copied ? 'Copied!' : 'Copy Stream URL'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex gap-2">
|
||||||
|
<Button onClick={() => setLaunchStatus('idle')} variant="outline" className="flex-1">
|
||||||
|
Try Again
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onClose} variant="ghost" className="flex-1">
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-4", className)}>
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Monitor className="h-5 w-5" />
|
||||||
|
Local Video Player Required
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
This video format cannot be played directly in your browser
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={onClose}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Video Info */}
|
||||||
|
<div className="bg-muted rounded-lg p-3">
|
||||||
|
<div className="font-medium text-sm">{video.title || 'Untitled Video'}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Format: {format.streamInfo?.contentType || 'Unknown'} •
|
||||||
|
Size: {formatFileSize ? formatFileSize(video.size) : `${(video.size / 1024 / 1024).toFixed(1)} MB`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Player Detection */}
|
||||||
|
{isDetecting ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current" />
|
||||||
|
Detecting available players...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Alert>
|
||||||
|
<Monitor className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
{detectedPlayers.length > 0
|
||||||
|
? `Found ${detectedPlayers.length} compatible player(s) for your system.`
|
||||||
|
: 'No compatible players detected. You can still use the stream URL below.'
|
||||||
|
}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Player Buttons */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{detectedPlayers.map(renderPlayerButton)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stream URL Section */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">Stream URL</span>
|
||||||
|
<Button
|
||||||
|
onClick={handleCopyUrl}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<>
|
||||||
|
<Check className="h-4 w-4 mr-1" />
|
||||||
|
Copied!
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Copy className="h-4 w-4 mr-1" />
|
||||||
|
Copy
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-muted rounded-md p-2">
|
||||||
|
<code className="text-xs break-all">{streamUrl}</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Copy this URL and paste it directly into your video player, or click "Open in Browser" below.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Alternative Actions */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleManualOpen}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4 mr-2" />
|
||||||
|
Open in Browser
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => window.open('/settings#players', '_blank')}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4 mr-2" />
|
||||||
|
Settings
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Help Section */}
|
||||||
|
<div className="bg-muted rounded-lg p-3 text-xs">
|
||||||
|
<div className="flex items-center gap-2 font-medium mb-1">
|
||||||
|
<HelpCircle className="h-3 w-3" />
|
||||||
|
Better Streaming Solution
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 text-muted-foreground">
|
||||||
|
<div className="bg-blue-500/10 border border-blue-500/20 rounded p-2">
|
||||||
|
<div className="font-medium text-blue-400 mb-1">💡 Recommended Solution</div>
|
||||||
|
<div className="text-xs">
|
||||||
|
Use our <strong>External Streaming API</strong> for better compatibility:
|
||||||
|
<br />
|
||||||
|
<code className="bg-black/20 px-1 rounded text-xs mt-1 inline-block">
|
||||||
|
{getPlayerSpecificUrl(video.id, 'vlc')}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
<li>• ✅ Proper HTTP range support for seeking</li>
|
||||||
|
<li>• ✅ Optimized chunked streaming</li>
|
||||||
|
<li>• ✅ Works with VLC, MPV, PotPlayer, etc.</li>
|
||||||
|
<li>• ✅ No transcoding needed</li>
|
||||||
|
</ul>
|
||||||
|
<div className="mt-2 p-2 bg-green-500/10 border border-green-500/20 rounded">
|
||||||
|
<div className="font-medium text-green-400">Quick Setup:</div>
|
||||||
|
<div className="text-xs mt-1">
|
||||||
|
1. Copy URL above<br />
|
||||||
|
2. Open VLC → Media → Open Network Stream<br />
|
||||||
|
3. Paste URL and click Play
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { detectVideoFormat, VideoFile } from '@/lib/video-format-detector';
|
import { detectVideoFormat, VideoFile } from '@/lib/video-format-detector';
|
||||||
import ArtPlayerWrapper from '@/components/artplayer-wrapper';
|
import ArtPlayerWrapper from '@/components/artplayer-wrapper';
|
||||||
|
import LocalPlayerLauncher from '@/components/local-player-launcher';
|
||||||
|
|
||||||
interface UnifiedVideoPlayerProps {
|
interface UnifiedVideoPlayerProps {
|
||||||
video: VideoFile;
|
video: VideoFile;
|
||||||
|
|
@ -97,7 +98,9 @@ export default function UnifiedVideoPlayer({
|
||||||
// Detect format on mount
|
// Detect format on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (video) {
|
if (video) {
|
||||||
|
console.log('[UnifiedVideoPlayer] Detecting format for video:', video);
|
||||||
const detectedFormat = detectVideoFormat(video);
|
const detectedFormat = detectVideoFormat(video);
|
||||||
|
console.log('[UnifiedVideoPlayer] Detected format:', detectedFormat);
|
||||||
setFormat(detectedFormat);
|
setFormat(detectedFormat);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -154,9 +157,30 @@ export default function UnifiedVideoPlayer({
|
||||||
}
|
}
|
||||||
}, [onRate]);
|
}, [onRate]);
|
||||||
|
|
||||||
// Always render ArtPlayer (no more fallbacks)
|
// Render appropriate player based on format
|
||||||
const renderPlayer = () => {
|
const renderPlayer = () => {
|
||||||
// Always use ArtPlayer for both modal and inline modes
|
console.log('[UnifiedVideoPlayer] renderPlayer called with format:', format);
|
||||||
|
console.log('[UnifiedVideoPlayer] format?.type:', format?.type);
|
||||||
|
console.log('[UnifiedVideoPlayer] format?.supportLevel:', format?.supportLevel);
|
||||||
|
|
||||||
|
// Check if format requires local player
|
||||||
|
if (format?.type === 'local-player') {
|
||||||
|
console.log('[UnifiedVideoPlayer] Rendering LocalPlayerLauncher');
|
||||||
|
return (
|
||||||
|
<LocalPlayerLauncher
|
||||||
|
video={video}
|
||||||
|
format={format}
|
||||||
|
onClose={onClose}
|
||||||
|
onPlayerSelect={(playerId) => {
|
||||||
|
console.log(`Selected player: ${playerId}`);
|
||||||
|
}}
|
||||||
|
formatFileSize={formatFileSize}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to ArtPlayer for supported formats
|
||||||
|
console.log('[UnifiedVideoPlayer] Rendering ArtPlayerWrapper');
|
||||||
return (
|
return (
|
||||||
<ArtPlayerWrapper
|
<ArtPlayerWrapper
|
||||||
video={video}
|
video={video}
|
||||||
|
|
@ -189,12 +213,14 @@ export default function UnifiedVideoPlayer({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[UnifiedVideoPlayer] Main render - format:', format, 'isLoading:', isLoading);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="unified-video-player">
|
<div className="unified-video-player">
|
||||||
{/* ArtPlayer indicator (for debugging) */}
|
{/* Format indicator (for debugging) */}
|
||||||
{process.env.NODE_ENV === 'development' && format && (
|
{process.env.NODE_ENV === 'development' && format && (
|
||||||
<div className="fixed top-4 left-4 z-50 bg-blue-500/20 text-blue-400 rounded-full px-3 py-1.5 text-xs">
|
<div className="fixed top-4 left-4 z-50 bg-blue-500/20 text-blue-400 rounded-full px-3 py-1.5 text-xs">
|
||||||
ArtPlayer - {format.supportLevel}
|
{format.type === 'local-player' ? 'Local Player' : 'ArtPlayer'} - {format.supportLevel}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -230,6 +230,7 @@ export default function VirtualizedFolderGrid({
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (item.type === 'video' && item.id) {
|
if (item.type === 'video' && item.id) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
console.log('[VirtualizedMediaGrid] Video clicked:', item);
|
||||||
onVideoClick(item);
|
onVideoClick(item);
|
||||||
} else if (item.type === 'photo' && item.id) {
|
} else if (item.type === 'photo' && item.id) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,415 @@
|
||||||
|
/**
|
||||||
|
* Local video player launch system
|
||||||
|
* Handles cross-platform player detection and launching
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface PlayerInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
platforms: string[];
|
||||||
|
downloadUrl: string;
|
||||||
|
protocolUrl?: string;
|
||||||
|
commandLine?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LaunchResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
method?: 'protocol' | 'command' | 'manual';
|
||||||
|
playerId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayerDetectionResult {
|
||||||
|
available: string[];
|
||||||
|
unavailable: string[];
|
||||||
|
platform: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Player configuration database
|
||||||
|
export const PLAYER_INFO: Record<string, PlayerInfo> = {
|
||||||
|
vlc: {
|
||||||
|
id: 'vlc',
|
||||||
|
name: 'VLC Media Player',
|
||||||
|
description: 'Free, open-source, cross-platform media player',
|
||||||
|
platforms: ['Windows', 'macOS', 'Linux'],
|
||||||
|
downloadUrl: 'https://www.videolan.org/vlc/',
|
||||||
|
protocolUrl: 'vlc://',
|
||||||
|
commandLine: 'vlc'
|
||||||
|
},
|
||||||
|
elmedia: {
|
||||||
|
id: 'elmedia',
|
||||||
|
name: 'Elmedia Player',
|
||||||
|
description: 'Advanced media player for macOS with streaming capabilities',
|
||||||
|
platforms: ['macOS'],
|
||||||
|
downloadUrl: 'https://www.elmedia-video-player.com/',
|
||||||
|
commandLine: 'open -a "Elmedia Player"'
|
||||||
|
},
|
||||||
|
potplayer: {
|
||||||
|
id: 'potplayer',
|
||||||
|
name: 'PotPlayer',
|
||||||
|
description: 'Feature-rich media player for Windows',
|
||||||
|
platforms: ['Windows'],
|
||||||
|
downloadUrl: 'https://potplayer.daum.net/',
|
||||||
|
commandLine: 'PotPlayerMini64.exe'
|
||||||
|
},
|
||||||
|
iina: {
|
||||||
|
id: 'iina',
|
||||||
|
name: 'IINA',
|
||||||
|
description: 'Modern video player for macOS',
|
||||||
|
platforms: ['macOS'],
|
||||||
|
downloadUrl: 'https://iina.io/',
|
||||||
|
protocolUrl: 'iina://weblink?url=',
|
||||||
|
commandLine: 'open -a IINA'
|
||||||
|
},
|
||||||
|
mpv: {
|
||||||
|
id: 'mpv',
|
||||||
|
name: 'mpv',
|
||||||
|
description: 'Command-line media player',
|
||||||
|
platforms: ['Windows', 'macOS', 'Linux'],
|
||||||
|
downloadUrl: 'https://mpv.io/',
|
||||||
|
commandLine: 'mpv'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect the current platform
|
||||||
|
*/
|
||||||
|
export function getPlatform(): string {
|
||||||
|
if (typeof window === 'undefined') return 'Unknown';
|
||||||
|
|
||||||
|
const userAgent = window.navigator.userAgent.toLowerCase();
|
||||||
|
if (userAgent.includes('mac')) return 'macOS';
|
||||||
|
if (userAgent.includes('win')) return 'Windows';
|
||||||
|
if (userAgent.includes('linux')) return 'Linux';
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect available players on the current system
|
||||||
|
*/
|
||||||
|
export async function detectAvailablePlayers(): Promise<PlayerDetectionResult> {
|
||||||
|
const platform = getPlatform();
|
||||||
|
const available: string[] = [];
|
||||||
|
const unavailable: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Test protocol handlers (most reliable method)
|
||||||
|
for (const [playerId, player] of Object.entries(PLAYER_INFO)) {
|
||||||
|
if (!player.platforms.includes(platform)) {
|
||||||
|
unavailable.push(playerId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (player.protocolUrl) {
|
||||||
|
// Protocol handlers are difficult to test without user interaction
|
||||||
|
// For now, assume they're available if platform matches
|
||||||
|
available.push(playerId);
|
||||||
|
} else {
|
||||||
|
// For players without protocol support, check platform compatibility
|
||||||
|
available.push(playerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional checks can be added here:
|
||||||
|
// - Check common installation paths
|
||||||
|
// - Test if players are in PATH
|
||||||
|
// - Check registry entries (Windows)
|
||||||
|
// - Check Applications folder (macOS)
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error detecting players:', error);
|
||||||
|
// Fallback: recommend VLC for all platforms
|
||||||
|
available.push('vlc');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
available,
|
||||||
|
unavailable,
|
||||||
|
platform
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launch a video in a local player
|
||||||
|
*/
|
||||||
|
export async function launchLocalPlayer(
|
||||||
|
playerId: string,
|
||||||
|
streamUrl: string,
|
||||||
|
options?: {
|
||||||
|
preferProtocol?: boolean;
|
||||||
|
allowManual?: boolean;
|
||||||
|
}
|
||||||
|
): Promise<LaunchResult> {
|
||||||
|
const player = PLAYER_INFO[playerId];
|
||||||
|
if (!player) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Unknown player: ${playerId}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const platform = getPlatform();
|
||||||
|
if (!player.platforms.includes(platform)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `${player.name} is not available on ${platform}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Method 1: Protocol handler (preferred - requires user gesture)
|
||||||
|
if (player.protocolUrl && options?.preferProtocol !== false) {
|
||||||
|
return await launchViaProtocol(player, streamUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 2: Command line (would need server-side support in real implementation)
|
||||||
|
if (player.commandLine) {
|
||||||
|
return await launchViaCommand(player, streamUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 3: Manual launch (fallback)
|
||||||
|
if (options?.allowManual !== false) {
|
||||||
|
return await launchManually(streamUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'No suitable launch method available'
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Launch error:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown launch error'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launch via protocol handler (vlc://, potplayer://, etc.)
|
||||||
|
*/
|
||||||
|
async function launchViaProtocol(player: PlayerInfo, streamUrl: string): Promise<LaunchResult> {
|
||||||
|
if (!player.protocolUrl) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'No protocol URL configured'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// This must be called within a user gesture context (click, keypress)
|
||||||
|
const protocolUrl = player.protocolUrl + encodeURIComponent(streamUrl);
|
||||||
|
|
||||||
|
// Attempt to launch via protocol
|
||||||
|
window.location.href = protocolUrl;
|
||||||
|
|
||||||
|
// Note: We can't reliably detect if the launch succeeded
|
||||||
|
// The browser will show a confirmation dialog for first-time use
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
method: 'protocol',
|
||||||
|
playerId: player.id
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Failed to launch ${player.name} via protocol: ${error}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launch via command line (requires server-side component in real implementation)
|
||||||
|
*/
|
||||||
|
async function launchViaCommand(player: PlayerInfo, streamUrl: string): Promise<LaunchResult> {
|
||||||
|
if (!player.commandLine) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'No command line configured'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// This would typically require a server-side endpoint or native messaging
|
||||||
|
// For now, we'll simulate the command and provide instructions
|
||||||
|
const command = `${player.commandLine} "${streamUrl}"`;
|
||||||
|
|
||||||
|
console.log(`Command to launch ${player.name}:`, command);
|
||||||
|
|
||||||
|
// In a real implementation, this would:
|
||||||
|
// 1. Call a server-side API to execute the command
|
||||||
|
// 2. Use Native Messaging API (browser extension)
|
||||||
|
// 3. Use a local helper application
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
method: 'command',
|
||||||
|
playerId: player.id
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Failed to launch ${player.name} via command: ${error}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manual launch - open URL in new tab for copy/paste
|
||||||
|
*/
|
||||||
|
async function launchManually(streamUrl: string): Promise<LaunchResult> {
|
||||||
|
try {
|
||||||
|
// Open in new tab for manual copy/paste
|
||||||
|
window.open(streamUrl, '_blank');
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
method: 'manual'
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Failed to open URL: ${error}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate launch instructions for manual setup
|
||||||
|
*/
|
||||||
|
export function getLaunchInstructions(playerId: string, streamUrl: string): string {
|
||||||
|
const player = PLAYER_INFO[playerId];
|
||||||
|
if (!player) return 'Player not found';
|
||||||
|
|
||||||
|
const platform = getPlatform();
|
||||||
|
const instructions: string[] = [];
|
||||||
|
|
||||||
|
instructions.push(`To open this video in ${player.name}:`);
|
||||||
|
instructions.push('');
|
||||||
|
|
||||||
|
if (player.protocolUrl) {
|
||||||
|
instructions.push('Method 1 (Automatic):');
|
||||||
|
instructions.push(`1. Click the "Launch ${player.name}" button above`);
|
||||||
|
instructions.push(`2. Allow the browser to open ${player.name} if prompted`);
|
||||||
|
instructions.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
instructions.push('Method 2 (Manual):');
|
||||||
|
instructions.push('1. Copy the stream URL above');
|
||||||
|
instructions.push(`2. Open ${player.name}`);
|
||||||
|
instructions.push(`3. Use File → Open Network Stream (or Ctrl+N)`);
|
||||||
|
instructions.push(`4. Paste the URL and click Play`);
|
||||||
|
|
||||||
|
if (platform === 'macOS' && player.commandLine) {
|
||||||
|
instructions.push('');
|
||||||
|
instructions.push('Method 3 (Terminal):');
|
||||||
|
instructions.push(`1. Open Terminal`);
|
||||||
|
instructions.push(`2. Run: ${player.commandLine} "${streamUrl}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return instructions.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get installation instructions for a player
|
||||||
|
*/
|
||||||
|
export function getInstallationInstructions(playerId: string): string {
|
||||||
|
const player = PLAYER_INFO[playerId];
|
||||||
|
if (!player) return 'Player not found';
|
||||||
|
|
||||||
|
const platform = getPlatform();
|
||||||
|
const instructions: string[] = [];
|
||||||
|
|
||||||
|
instructions.push(`To install ${player.name}:`);
|
||||||
|
instructions.push('');
|
||||||
|
instructions.push(`1. Visit: ${player.downloadUrl}`);
|
||||||
|
instructions.push(`2. Download the version for ${platform}`);
|
||||||
|
instructions.push(`3. Install the application`);
|
||||||
|
instructions.push(`4. Return to this page and try launching again`);
|
||||||
|
|
||||||
|
return instructions.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all players available for current platform
|
||||||
|
*/
|
||||||
|
export function getPlatformPlayers(): PlayerInfo[] {
|
||||||
|
const platform = getPlatform();
|
||||||
|
return Object.values(PLAYER_INFO).filter(player =>
|
||||||
|
player.platforms.includes(platform)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get default player for current platform
|
||||||
|
*/
|
||||||
|
export function getDefaultPlayer(): string {
|
||||||
|
const platform = getPlatform();
|
||||||
|
const platformPlayers = getPlatformPlayers();
|
||||||
|
|
||||||
|
if (platformPlayers.length === 0) return 'vlc';
|
||||||
|
|
||||||
|
// Prefer VLC as it's cross-platform and most reliable
|
||||||
|
const vlc = platformPlayers.find(p => p.id === 'vlc');
|
||||||
|
if (vlc) return vlc.id;
|
||||||
|
|
||||||
|
// Otherwise return first available
|
||||||
|
return platformPlayers[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test if a protocol handler is available
|
||||||
|
* Note: This requires user interaction and may show browser prompts
|
||||||
|
*/
|
||||||
|
export async function testProtocolHandler(protocol: string): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
try {
|
||||||
|
// Create a temporary iframe to test the protocol
|
||||||
|
const iframe = document.createElement('iframe');
|
||||||
|
iframe.style.display = 'none';
|
||||||
|
document.body.appendChild(iframe);
|
||||||
|
|
||||||
|
let timeoutId: NodeJS.Timeout;
|
||||||
|
let isResolved = false;
|
||||||
|
|
||||||
|
// Set a timeout - if protocol is available, browser will show prompt
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
if (!isResolved) {
|
||||||
|
isResolved = true;
|
||||||
|
document.body.removeChild(iframe);
|
||||||
|
resolve(true); // Assume available if no error
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
// Try to navigate to the protocol
|
||||||
|
iframe.src = protocol + 'test';
|
||||||
|
|
||||||
|
// If we get here without errors, protocol might be available
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!isResolved) {
|
||||||
|
isResolved = true;
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
document.body.removeChild(iframe);
|
||||||
|
resolve(true);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
getPlatform,
|
||||||
|
detectAvailablePlayers,
|
||||||
|
launchLocalPlayer,
|
||||||
|
getLaunchInstructions,
|
||||||
|
getInstallationInstructions,
|
||||||
|
getPlatformPlayers,
|
||||||
|
getDefaultPlayer,
|
||||||
|
testProtocolHandler,
|
||||||
|
PLAYER_INFO
|
||||||
|
};
|
||||||
|
|
@ -4,12 +4,20 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface VideoFormat {
|
export interface VideoFormat {
|
||||||
type: 'direct' | 'hls' | 'fallback';
|
type: 'direct' | 'hls' | 'local-player'; // Removed 'fallback', added 'local-player'
|
||||||
url: string;
|
url: string;
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
qualities?: QualityLevel[];
|
qualities?: QualityLevel[];
|
||||||
supportLevel: 'native' | 'hls' | 'limited';
|
supportLevel: 'native' | 'hls' | 'local-player-required'; // Updated 'limited' to 'local-player-required'
|
||||||
warning?: string;
|
warning?: string;
|
||||||
|
action?: 'launch-local-player'; // New field for local player guidance
|
||||||
|
recommendedPlayers?: string[]; // New field for player recommendations
|
||||||
|
streamInfo?: {
|
||||||
|
contentType: string;
|
||||||
|
acceptRanges: string;
|
||||||
|
supportsSeek: boolean;
|
||||||
|
authentication: string;
|
||||||
|
}; // New field for stream metadata
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QualityLevel {
|
export interface QualityLevel {
|
||||||
|
|
@ -66,38 +74,58 @@ const LIMITED_SUPPORT_FORMATS = [
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect video format and determine optimal playback method
|
* Detect video format and determine optimal playback method
|
||||||
|
* TRANSCODING REMOVED: Binary decision - native browser OR local player
|
||||||
*/
|
*/
|
||||||
export function detectVideoFormat(video: VideoFile): VideoFormat {
|
export function detectVideoFormat(video: VideoFile): VideoFormat {
|
||||||
|
console.log('[FormatDetector] Input video:', video);
|
||||||
const extension = getFileExtension(video.path).toLowerCase();
|
const extension = getFileExtension(video.path).toLowerCase();
|
||||||
|
console.log('[FormatDetector] Extracted extension:', extension);
|
||||||
const codecInfo = parseCodecInfo(video.codec_info);
|
const codecInfo = parseCodecInfo(video.codec_info);
|
||||||
|
console.log('[FormatDetector] Codec info:', codecInfo);
|
||||||
|
|
||||||
// Check if video has specific codec requirements
|
// EXPLICIT TEST: Force .avi files to local player
|
||||||
|
if (extension === 'avi') {
|
||||||
|
console.log('[FormatDetector] .avi file detected, forcing local player');
|
||||||
|
return createLocalPlayerFormat(video, extension);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TRANSCODING DISABLED: No more transcoding fallback
|
||||||
|
// If codec specifically needs transcoding, go directly to local player
|
||||||
if (codecInfo.needsTranscoding) {
|
if (codecInfo.needsTranscoding) {
|
||||||
return createFallbackFormat(video);
|
console.log('[FormatDetector] Codec needs transcoding, using local player');
|
||||||
|
return createLocalPlayerFormat(video, extension);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tier 1: Native browser support (direct streaming)
|
// Tier 1: Native browser support (direct streaming)
|
||||||
if (NATIVE_SUPPORTED_FORMATS.includes(extension)) {
|
if (NATIVE_SUPPORTED_FORMATS.includes(extension)) {
|
||||||
|
console.log('[FormatDetector] Native format detected:', extension);
|
||||||
return createDirectFormat(video, extension);
|
return createDirectFormat(video, extension);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tier 1.5: MPEG Transport Stream files (serve directly)
|
// Tier 1.5: MPEG Transport Stream files (serve directly)
|
||||||
if (DIRECT_TS_FORMATS.includes(extension)) {
|
if (DIRECT_TS_FORMATS.includes(extension)) {
|
||||||
|
console.log('[FormatDetector] TS format detected:', extension);
|
||||||
return createTSDirectFormat(video, extension);
|
return createTSDirectFormat(video, extension);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tier 2: HLS compatible formats
|
// Tier 2: HLS compatible formats - try direct first, HLS as backup
|
||||||
if (HLS_COMPATIBLE_FORMATS.includes(extension)) {
|
if (HLS_COMPATIBLE_FORMATS.includes(extension)) {
|
||||||
return createHLSFormat(video, extension);
|
console.log('[FormatDetector] HLS compatible format detected:', extension);
|
||||||
|
// For now, try direct streaming first, HLS as fallback
|
||||||
|
// In future, we could detect if HLS is actually needed
|
||||||
|
return createDirectFormat(video, extension);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tier 3: Limited support - fallback to current system
|
// TRANSCODING DISABLED: All other formats go directly to local player
|
||||||
|
// No more fallback to transcoding system
|
||||||
|
console.log('[FormatDetector] Using local player for format:', extension);
|
||||||
|
|
||||||
|
// Check if this is a format from LIMITED_SUPPORT_FORMATS
|
||||||
if (LIMITED_SUPPORT_FORMATS.includes(extension)) {
|
if (LIMITED_SUPPORT_FORMATS.includes(extension)) {
|
||||||
return createFallbackFormat(video);
|
console.log('[FormatDetector] Format in LIMITED_SUPPORT_FORMATS, forcing local player');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unknown format - fallback
|
return createLocalPlayerFormat(video, extension);
|
||||||
return createFallbackFormat(video);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -198,24 +226,63 @@ function createTSDirectFormat(video: VideoFile, extension: string): VideoFormat
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create fallback format configuration (uses current transcoding system)
|
* Create local player format configuration (replaces transcoding fallback)
|
||||||
*/
|
*/
|
||||||
function createFallbackFormat(video: VideoFile): VideoFormat {
|
function createLocalPlayerFormat(video: VideoFile, extension: string): VideoFormat {
|
||||||
|
const contentType = getMimeType(extension);
|
||||||
|
|
||||||
|
// Use the optimized external streaming endpoint
|
||||||
|
const baseUrl = `/api/external-stream/${video.id}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'fallback',
|
type: 'local-player',
|
||||||
supportLevel: 'limited',
|
supportLevel: 'local-player-required',
|
||||||
url: `/api/stream/${video.id}`,
|
url: baseUrl, // Optimized endpoint for external players
|
||||||
warning: 'Limited playback features for this format',
|
action: 'launch-local-player',
|
||||||
|
warning: 'This format requires a local video player',
|
||||||
|
recommendedPlayers: getRecommendedPlayersForFormat(extension),
|
||||||
|
streamInfo: {
|
||||||
|
contentType,
|
||||||
|
acceptRanges: 'bytes',
|
||||||
|
supportsSeek: true,
|
||||||
|
authentication: 'none'
|
||||||
|
},
|
||||||
qualities: [
|
qualities: [
|
||||||
{
|
{
|
||||||
html: 'Transcoded',
|
html: 'Original',
|
||||||
url: `/api/stream/${video.id}`,
|
url: baseUrl,
|
||||||
default: true
|
default: true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recommended players based on format and platform
|
||||||
|
*/
|
||||||
|
function getRecommendedPlayersForFormat(extension: string): string[] {
|
||||||
|
const players: string[] = ['vlc']; // VLC is always recommended as it's cross-platform
|
||||||
|
|
||||||
|
// Platform-specific recommendations
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const userAgent = window.navigator.userAgent.toLowerCase();
|
||||||
|
const isMac = userAgent.includes('mac');
|
||||||
|
const isWindows = userAgent.includes('win');
|
||||||
|
const isLinux = userAgent.includes('linux');
|
||||||
|
|
||||||
|
if (isMac) {
|
||||||
|
players.push('iina', 'elmedia');
|
||||||
|
} else if (isWindows) {
|
||||||
|
players.push('potplayer');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Server-side: recommend all major players
|
||||||
|
players.push('iina', 'elmedia', 'potplayer');
|
||||||
|
}
|
||||||
|
|
||||||
|
return players;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get MIME type for file extension
|
* Get MIME type for file extension
|
||||||
*/
|
*/
|
||||||
|
|
@ -249,10 +316,19 @@ export function requiresHLS(format: VideoFormat): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if format requires fallback to transcoding
|
* Check if format requires local player (replaces transcoding fallback)
|
||||||
|
*/
|
||||||
|
export function requiresLocalPlayer(format: VideoFormat): boolean {
|
||||||
|
return format.type === 'local-player';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if format requires fallback to transcoding (DEPRECATED - transcoding removed)
|
||||||
*/
|
*/
|
||||||
export function requiresFallback(format: VideoFormat): boolean {
|
export function requiresFallback(format: VideoFormat): boolean {
|
||||||
return format.type === 'fallback';
|
// TRANSCODING DISABLED: This function is deprecated
|
||||||
|
// All formats now either work natively or require local player
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue