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:
tigeren 2025-09-29 17:07:05 +00:00
parent 20c518a680
commit 4940cb4542
14 changed files with 2236 additions and 310 deletions

41
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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
}

View File

@ -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'
]
};
}

View File

@ -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
}

View File

@ -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 });

View File

@ -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',
}); });

View File

@ -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';
}

View File

@ -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 = () => {

View File

@ -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>
);
}

View File

@ -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>
)} )}

View File

@ -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();

View File

@ -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
};

View File

@ -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;
} }
/** /**