diff --git a/package-lock.json b/package-lock.json index 5bb4447..215546c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@radix-ui/react-slot": "^1.2.3", "@types/fluent-ffmpeg": "^2.1.27", "@types/glob": "^8.1.0", + "@types/xml2js": "^0.4.14", "artplayer": "^5.3.0", "better-sqlite3": "^12.2.0", "class-variance-authority": "^0.7.1", @@ -26,7 +27,8 @@ "react-window": "^1.8.11", "react-window-infinite-loader": "^1.0.10", "tailwind-merge": "^3.3.1", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "xml2js": "^0.6.2" }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", @@ -857,6 +859,15 @@ "@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": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz", @@ -2457,6 +2468,12 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "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": { "version": "0.26.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", @@ -3209,6 +3226,28 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "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": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", diff --git a/package.json b/package.json index f46601f..0386b4e 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "@radix-ui/react-slot": "^1.2.3", "@types/fluent-ffmpeg": "^2.1.27", "@types/glob": "^8.1.0", + "@types/xml2js": "^0.4.14", "artplayer": "^5.3.0", "better-sqlite3": "^12.2.0", "class-variance-authority": "^0.7.1", @@ -27,7 +28,8 @@ "react-window": "^1.8.11", "react-window-infinite-loader": "^1.0.10", "tailwind-merge": "^3.3.1", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "xml2js": "^0.6.2" }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", diff --git a/src/app/api/external-stream/[id]/route.ts b/src/app/api/external-stream/[id]/route.ts new file mode 100644 index 0000000..8892c91 --- /dev/null +++ b/src/app/api/external-stream/[id]/route.ts @@ -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 = { + '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 = { + // 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 +} \ No newline at end of file diff --git a/src/app/api/media-access/[id]/route.ts b/src/app/api/media-access/[id]/route.ts new file mode 100644 index 0000000..641d295 --- /dev/null +++ b/src/app/api/media-access/[id]/route.ts @@ -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 { + 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' + ] + }; +} \ No newline at end of file diff --git a/src/app/api/stream/[id]/route.ts b/src/app/api/stream/[id]/route.ts index 6834c13..2892976 100644 --- a/src/app/api/stream/[id]/route.ts +++ b/src/app/api/stream/[id]/route.ts @@ -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}`); if (needsTranscoding) { - console.log(`[STREAM] Redirecting to transcoding endpoint: /api/stream/${id}/transcode`); - // Return CORS-enabled redirect - const response = NextResponse.redirect( - new URL(`/api/stream/${id}/transcode`, request.url), - 302 - ); - response.headers.set('Access-Control-Allow-Origin', '*'); - response.headers.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS'); - return response; + console.log(`[STREAM] Format requires local player for video ID: ${id}`); + // TRANSCODING DISABLED: Return local player guidance instead of redirect + return NextResponse.json({ + error: 'Format not supported in browser', + solution: 'local-player', + message: 'This video format cannot be played directly in the browser. Please use a local video player.', + directStreamUrl: `/api/stream/direct/${id}`, + recommendedPlayers: ['vlc', 'iina', 'elmedia', 'potplayer'], + 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; @@ -136,4 +143,28 @@ export async function GET( console.error("Error streaming video:", error); 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 = { + '.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 } \ No newline at end of file diff --git a/src/app/api/stream/[id]/transcode/route.ts b/src/app/api/stream/[id]/transcode/route.ts index f659f94..211df9a 100644 --- a/src/app/api/stream/[id]/transcode/route.ts +++ b/src/app/api/stream/[id]/transcode/route.ts @@ -1,55 +1,29 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getDatabase } from '@/db'; -import fs from 'fs'; -import { spawn } from 'child_process'; -import { Readable } from 'stream'; -import { ffmpegRegistry } from '@/lib/ffmpeg/process-registry'; +// TRANSCODING DISABLED: These imports are no longer needed +// import { getDatabase } from '@/db'; +// import fs from 'fs'; +// import { spawn } from 'child_process'; +// import { Readable } from 'stream'; +// import { ffmpegRegistry } from '@/lib/ffmpeg/process-registry'; -// Track active requests to prevent duplicate processing -const activeRequests = new Map>(); +// TRANSCODING DISABLED: Request tracking no longer needed +// const activeRequests = new Map>(); export async function HEAD( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { - // Handle HEAD requests by returning just headers without body + // TRANSCODING DISABLED: Return 410 Gone with local player guidance try { 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; - if (!media) { - return NextResponse.json({ error: 'Video not found' }, { status: 404 }); - } - - // Get duration from stored codec_info - let duration = 0; - 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, - }); + return NextResponse.json({ + error: 'Transcoding is disabled. This format requires a local video player.', + suggestedPlayers: ['VLC Media Player', 'Elmedia Player', 'PotPlayer'], + directStreamUrl: `/api/stream/direct/${id}`, + helpUrl: '/help/local-players', + status: 'transcoding-disabled' + }, { status: 410 }); // 410 Gone } catch (error) { console.error('HEAD request error:', error); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); @@ -60,13 +34,15 @@ export async function OPTIONS( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { + // TRANSCODING DISABLED: Return 410 Gone for OPTIONS as well return new Response(null, { - status: 200, + status: 410, // Gone headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Range', 'Access-Control-Max-Age': '86400', + 'X-Status': 'transcoding-disabled', }, }); } @@ -75,90 +51,39 @@ export async function GET( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { + // TRANSCODING DISABLED: Return 410 Gone with comprehensive local player guidance try { 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) { console.error('Transcoding API error:', error); - return NextResponse.json( - { error: 'Internal server error' }, - { status: 500 } - ); + return NextResponse.json({ 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( id: string, filePath: string, @@ -167,164 +92,26 @@ async function createTranscodeStream( duration: number, settings: { width: number, height: number, bitrate: string } ): Promise { - try { - // STASH BEHAVIOR: Smart process management - // Only kill existing processes if they're for a significantly different seek time - 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) { - 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; - } + // Original transcoding logic disabled + // See git history for implementation details + throw new Error('Transcoding is disabled. Use local player instead.'); } +*/ -// Cleanup function for manual process termination +// TRANSCODING DISABLED: Cleanup function no longer needed export async function DELETE( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { + // TRANSCODING DISABLED: Return 410 Gone - no processes to clean up try { const { id } = await params; - // Use enhanced registry to cleanup all processes for this video ID (Stash-like) - const killedCount = ffmpegRegistry.killAllForVideo(id); - console.log(`[CLEANUP] Killed ${killedCount} processes for video: ${id}`); - - return NextResponse.json({ success: true, killedProcesses: killedCount }); + return NextResponse.json({ + error: 'Transcoding cleanup is disabled. No processes to terminate.', + status: 'transcoding-disabled', + message: 'Transcoding functionality has been removed. Use local video players instead.' + }, { status: 410 }); // 410 Gone } catch (error) { console.error('Cleanup API error:', error); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); diff --git a/src/app/api/stream/direct/[id]/route.ts b/src/app/api/stream/direct/[id]/route.ts index 96e65e9..09497c2 100644 --- a/src/app/api/stream/direct/[id]/route.ts +++ b/src/app/api/stream/direct/[id]/route.ts @@ -1,8 +1,21 @@ -import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; import { getDatabase } from '@/db'; import fs from 'fs'; 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 }> }) { const { id } = await params; const db = getDatabase(); @@ -40,19 +53,42 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: if (range) { // Handle range requests for seeking - const parts = range.replace(/bytes=/, "").split("-"); - const start = parseInt(parts[0], 10); - const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1; + const matches = range.match(/bytes=(\d*)-(\d*)/); + if (!matches) { + 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; - // Create read stream for the requested range - const stream = fs.createReadStream(videoPath, { start, end }); + // Create read stream for the requested range with optimized buffer + const stream = fs.createReadStream(videoPath, { + start, + end, + highWaterMark: Math.min(chunksize, 1024 * 1024) // 1MB chunks max + }); const headers = new Headers({ 'Content-Range': `bytes ${start}-${end}/${fileSize}`, 'Accept-Ranges': 'bytes', 'Content-Length': chunksize.toString(), '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', }); @@ -62,11 +98,17 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: }); } else { // 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({ 'Content-Length': fileSize.toString(), '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', }); diff --git a/src/app/api/webdav/[...path]/route.ts b/src/app/api/webdav/[...path]/route.ts new file mode 100644 index 0000000..345cf5a --- /dev/null +++ b/src/app/api/webdav/[...path]/route.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 = { + '.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'; +} \ No newline at end of file diff --git a/src/app/videos/page.tsx b/src/app/videos/page.tsx index 780199e..5e79e1c 100644 --- a/src/app/videos/page.tsx +++ b/src/app/videos/page.tsx @@ -22,8 +22,10 @@ const VideosPage = () => { const [isPlayerOpen, setIsPlayerOpen] = useState(false); const handleVideoClick = (video: Video) => { + console.log('[VideosPage] handleVideoClick called with video:', video); setSelectedVideo(video); setIsPlayerOpen(true); + console.log('[VideosPage] State updated - selectedVideo:', video, 'isPlayerOpen:', true); }; const handleClosePlayer = () => { diff --git a/src/components/local-player-launcher.tsx b/src/components/local-player-launcher.tsx new file mode 100644 index 0000000..def6706 --- /dev/null +++ b/src/components/local-player-launcher.tsx @@ -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 = { + 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([]); + 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 ( + + ); + }; + + if (launchStatus === 'error') { + return ( +
+ + + Launch Failed + + Could not launch the video player automatically. + + + + + + Try copying the stream URL below and opening it manually in your video player. + + +
+ +
+
+ + +
+
+
+
+ ); + } + + return ( +
+ + +
+
+ + + Local Video Player Required + + + This video format cannot be played directly in your browser + +
+ +
+
+ + + {/* Video Info */} +
+
{video.title || 'Untitled Video'}
+
+ Format: {format.streamInfo?.contentType || 'Unknown'} • + Size: {formatFileSize ? formatFileSize(video.size) : `${(video.size / 1024 / 1024).toFixed(1)} MB`} +
+
+ + {/* Player Detection */} + {isDetecting ? ( +
+
+ Detecting available players... +
+ ) : ( + + + + {detectedPlayers.length > 0 + ? `Found ${detectedPlayers.length} compatible player(s) for your system.` + : 'No compatible players detected. You can still use the stream URL below.' + } + + + )} + + {/* Player Buttons */} +
+ {detectedPlayers.map(renderPlayerButton)} +
+ + {/* Stream URL Section */} +
+
+ Stream URL + +
+ +
+ {streamUrl} +
+ +
+ Copy this URL and paste it directly into your video player, or click "Open in Browser" below. +
+
+ + {/* Alternative Actions */} +
+ + +
+ + {/* Help Section */} +
+
+ + Better Streaming Solution +
+
+
+
💡 Recommended Solution
+
+ Use our External Streaming API for better compatibility: +
+ + {getPlayerSpecificUrl(video.id, 'vlc')} + +
+
+
    +
  • • ✅ Proper HTTP range support for seeking
  • +
  • • ✅ Optimized chunked streaming
  • +
  • • ✅ Works with VLC, MPV, PotPlayer, etc.
  • +
  • • ✅ No transcoding needed
  • +
+
+
Quick Setup:
+
+ 1. Copy URL above
+ 2. Open VLC → Media → Open Network Stream
+ 3. Paste URL and click Play +
+
+
+
+ + +
+ ); +} \ No newline at end of file diff --git a/src/components/unified-video-player.tsx b/src/components/unified-video-player.tsx index b63c198..8ae639c 100644 --- a/src/components/unified-video-player.tsx +++ b/src/components/unified-video-player.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useCallback } from 'react'; import { detectVideoFormat, VideoFile } from '@/lib/video-format-detector'; import ArtPlayerWrapper from '@/components/artplayer-wrapper'; +import LocalPlayerLauncher from '@/components/local-player-launcher'; interface UnifiedVideoPlayerProps { video: VideoFile; @@ -97,7 +98,9 @@ export default function UnifiedVideoPlayer({ // Detect format on mount useEffect(() => { if (video) { + console.log('[UnifiedVideoPlayer] Detecting format for video:', video); const detectedFormat = detectVideoFormat(video); + console.log('[UnifiedVideoPlayer] Detected format:', detectedFormat); setFormat(detectedFormat); setIsLoading(false); } @@ -154,9 +157,30 @@ export default function UnifiedVideoPlayer({ } }, [onRate]); - // Always render ArtPlayer (no more fallbacks) + // Render appropriate player based on format 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 ( + { + console.log(`Selected player: ${playerId}`); + }} + formatFileSize={formatFileSize} + /> + ); + } + + // Default to ArtPlayer for supported formats + console.log('[UnifiedVideoPlayer] Rendering ArtPlayerWrapper'); return ( - {/* ArtPlayer indicator (for debugging) */} + {/* Format indicator (for debugging) */} {process.env.NODE_ENV === 'development' && format && (
- ArtPlayer - {format.supportLevel} + {format.type === 'local-player' ? 'Local Player' : 'ArtPlayer'} - {format.supportLevel}
)} diff --git a/src/components/virtualized-media-grid.tsx b/src/components/virtualized-media-grid.tsx index 8675076..b579602 100644 --- a/src/components/virtualized-media-grid.tsx +++ b/src/components/virtualized-media-grid.tsx @@ -230,6 +230,7 @@ export default function VirtualizedFolderGrid({ onClick={(e) => { if (item.type === 'video' && item.id) { e.preventDefault(); + console.log('[VirtualizedMediaGrid] Video clicked:', item); onVideoClick(item); } else if (item.type === 'photo' && item.id) { e.preventDefault(); diff --git a/src/lib/local-player-launcher.ts b/src/lib/local-player-launcher.ts new file mode 100644 index 0000000..aed5ddb --- /dev/null +++ b/src/lib/local-player-launcher.ts @@ -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 = { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 +}; \ No newline at end of file diff --git a/src/lib/video-format-detector.ts b/src/lib/video-format-detector.ts index ed09b1b..780da38 100644 --- a/src/lib/video-format-detector.ts +++ b/src/lib/video-format-detector.ts @@ -4,12 +4,20 @@ */ export interface VideoFormat { - type: 'direct' | 'hls' | 'fallback'; + type: 'direct' | 'hls' | 'local-player'; // Removed 'fallback', added 'local-player' url: string; mimeType?: string; qualities?: QualityLevel[]; - supportLevel: 'native' | 'hls' | 'limited'; + supportLevel: 'native' | 'hls' | 'local-player-required'; // Updated 'limited' to 'local-player-required' 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 { @@ -66,38 +74,58 @@ const LIMITED_SUPPORT_FORMATS = [ /** * Detect video format and determine optimal playback method + * TRANSCODING REMOVED: Binary decision - native browser OR local player */ export function detectVideoFormat(video: VideoFile): VideoFormat { + console.log('[FormatDetector] Input video:', video); const extension = getFileExtension(video.path).toLowerCase(); + console.log('[FormatDetector] Extracted extension:', extension); 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) { - return createFallbackFormat(video); + console.log('[FormatDetector] Codec needs transcoding, using local player'); + return createLocalPlayerFormat(video, extension); } // Tier 1: Native browser support (direct streaming) if (NATIVE_SUPPORTED_FORMATS.includes(extension)) { + console.log('[FormatDetector] Native format detected:', extension); return createDirectFormat(video, extension); } // Tier 1.5: MPEG Transport Stream files (serve directly) if (DIRECT_TS_FORMATS.includes(extension)) { + console.log('[FormatDetector] TS format detected:', extension); return createTSDirectFormat(video, extension); } - // Tier 2: HLS compatible formats + // Tier 2: HLS compatible formats - try direct first, HLS as backup 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)) { - return createFallbackFormat(video); + console.log('[FormatDetector] Format in LIMITED_SUPPORT_FORMATS, forcing local player'); } - // Unknown format - fallback - return createFallbackFormat(video); + return createLocalPlayerFormat(video, extension); } /** @@ -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 { - type: 'fallback', - supportLevel: 'limited', - url: `/api/stream/${video.id}`, - warning: 'Limited playback features for this format', + type: 'local-player', + supportLevel: 'local-player-required', + url: baseUrl, // Optimized endpoint for external players + action: 'launch-local-player', + warning: 'This format requires a local video player', + recommendedPlayers: getRecommendedPlayersForFormat(extension), + streamInfo: { + contentType, + acceptRanges: 'bytes', + supportsSeek: true, + authentication: 'none' + }, qualities: [ { - html: 'Transcoded', - url: `/api/stream/${video.id}`, + html: 'Original', + url: baseUrl, 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 */ @@ -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 { - return format.type === 'fallback'; + // TRANSCODING DISABLED: This function is deprecated + // All formats now either work natively or require local player + return false; } /**