diff --git a/server/src/routes/stream.js b/server/src/routes/stream.js index 0d16686..6434252 100644 --- a/server/src/routes/stream.js +++ b/server/src/routes/stream.js @@ -12,19 +12,26 @@ export const streamRouter = express.Router(); /** * Start a new streaming session * POST /api/stream/start + * + * Body params: + * - hash: torrent hash (required) + * - name: display name (required) + * - quality: quality string (optional) + * - isSeasonPack: boolean (optional) - if true, this is a season pack + * - episodeFilePattern: regex pattern (optional) - pattern to match episode file in season pack */ streamRouter.post('/start', async (req, res) => { try { - const { hash, name, quality } = req.body; + const { hash, name, quality, isSeasonPack, episodeFilePattern } = req.body; if (!hash || !name) { return res.status(400).json({ error: 'Missing hash or name' }); } const sessionId = uuidv4(); - console.log(`🎬 Starting stream session ${sessionId} for "${name}"`); + console.log(`🎬 Starting stream session ${sessionId} for "${name}"${isSeasonPack ? ' (Season Pack)' : ''}`); - // Start torrent download + // Start torrent download with optional episode file selection const session = await torrentManager.startSession( sessionId, hash, @@ -34,7 +41,8 @@ streamRouter.post('/start', async (req, res) => { type: 'progress', ...torrentManager.getSessionStatus(sessionId), }); - } + }, + isSeasonPack ? { episodeFilePattern } : undefined ); res.json({ @@ -57,6 +65,8 @@ streamRouter.post('/start', async (req, res) => { errorMessage = 'Unable to connect to torrent peers. The torrent may be dead or have no active seeders.'; } else if (error.message.includes('duplicate')) { errorMessage = 'This torrent is already being processed. Please wait a moment and try again.'; + } else if (error.message.includes('Episode not found in season pack')) { + errorMessage = 'The requested episode was not found in the season pack. Please try a different torrent.'; } res.status(500).json({ error: errorMessage }); diff --git a/server/src/services/torrentManager.js b/server/src/services/torrentManager.js index 46526f1..37eecba 100644 --- a/server/src/services/torrentManager.js +++ b/server/src/services/torrentManager.js @@ -66,8 +66,16 @@ class TorrentManager { /** * Start a new streaming session + * @param {string} sessionId - Unique session identifier + * @param {string} torrentHash - Torrent hash + * @param {string} movieName - Display name + * @param {Function} onProgress - Progress callback + * @param {Object} options - Optional settings + * @param {string} options.episodeFilePattern - Regex pattern to match specific episode in season pack */ - async startSession(sessionId, torrentHash, movieName, onProgress) { + async startSession(sessionId, torrentHash, movieName, onProgress, options = {}) { + const { episodeFilePattern } = options || {}; + // Check if already downloading this torrent const existingSession = Array.from(this.sessions.values()).find( (s) => s.hash === torrentHash && (s.status === 'ready' || s.status === 'downloading') @@ -122,7 +130,7 @@ class TorrentManager { } const magnetUri = this.generateMagnetUri(torrentHash, movieName); - console.log(`🧲 Starting torrent: ${movieName}`); + console.log(`🧲 Starting torrent: ${movieName}${episodeFilePattern ? ` (episode pattern: ${episodeFilePattern})` : ''}`); const session = { id: sessionId, @@ -138,6 +146,7 @@ class TorrentManager { videoFile: null, torrent: null, startedAt: Date.now(), + episodeFilePattern: episodeFilePattern || null, // Store pattern for season pack file selection }; this.sessions.set(sessionId, session); @@ -160,13 +169,21 @@ class TorrentManager { // Wait for torrent to be ready with metadata const onReady = () => { - // Find the video file - const videoFile = this.findVideoFile(torrent); + // Find the video file (with optional episode pattern for season packs) + const videoFile = this.findVideoFile(torrent, session.episodeFilePattern); if (!videoFile) { // Retry after a short delay - sometimes files take a moment to populate setTimeout(() => { - const retryVideoFile = this.findVideoFile(torrent); + const retryVideoFile = this.findVideoFile(torrent, session.episodeFilePattern); if (!retryVideoFile) { + // If pattern was provided but no match, provide specific error + if (session.episodeFilePattern) { + session.status = 'error'; + session.error = 'Episode not found in season pack'; + console.error(`❌ Episode not found in season pack: ${movieName}`); + reject(new Error('Episode not found in season pack')); + return; + } session.status = 'error'; session.error = 'No video file found in torrent'; console.error(`❌ No video file found in: ${movieName}`); @@ -307,19 +324,58 @@ class TorrentManager { /** * Find the main video file in a torrent + * @param {Object} torrent - WebTorrent torrent object + * @param {string} episodePattern - Optional regex pattern to match specific episode file */ - findVideoFile(torrent) { + findVideoFile(torrent, episodePattern = null) { const videoExtensions = ['.mp4', '.mkv', '.avi', '.mov', '.webm', '.m4v']; - // Sort by size and find largest video file - const videoFiles = torrent.files + // Filter to video files only + let videoFiles = torrent.files + .filter((file) => { + const ext = path.extname(file.name).toLowerCase(); + return videoExtensions.includes(ext); + }); + + // If episode pattern provided, try to find matching file + if (episodePattern) { + const pattern = new RegExp(episodePattern, 'i'); + const matchingFiles = videoFiles.filter(file => pattern.test(file.name)); + + if (matchingFiles.length > 0) { + // Sort by size (prefer larger/higher quality) and return best match + matchingFiles.sort((a, b) => b.length - a.length); + console.log(`📹 Found episode file matching pattern: ${matchingFiles[0].name}`); + return matchingFiles[0]; + } + + // Log available files for debugging if no match found + console.warn(`⚠️ No file matching pattern "${episodePattern}" found. Available files:`); + videoFiles.forEach(f => console.log(` - ${f.name}`)); + } + + // Default: return largest video file + videoFiles.sort((a, b) => b.length - a.length); + return videoFiles[0] || null; + } + + /** + * List all video files in a torrent (useful for season packs) + */ + listVideoFiles(torrent) { + const videoExtensions = ['.mp4', '.mkv', '.avi', '.mov', '.webm', '.m4v']; + + return torrent.files .filter((file) => { const ext = path.extname(file.name).toLowerCase(); return videoExtensions.includes(ext); }) - .sort((a, b) => b.length - a.length); - - return videoFiles[0] || null; + .map((file) => ({ + name: file.name, + size: file.length, + path: file.path, + })) + .sort((a, b) => a.name.localeCompare(b.name)); } /** diff --git a/src/components/movie/QualitySelector.tsx b/src/components/movie/QualitySelector.tsx index 0d2a69d..8f8310e 100644 --- a/src/components/movie/QualitySelector.tsx +++ b/src/components/movie/QualitySelector.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { motion } from 'framer-motion'; -import { Play, Users, HardDrive, Check } from 'lucide-react'; +import { Play, Users, HardDrive, Check, Package } from 'lucide-react'; import type { Torrent } from '../../types'; import type { EztvTorrent } from '../../services/api/tvDiscovery'; import Button from '../ui/Button'; @@ -18,6 +18,11 @@ function isYtsTorrent(torrent: Torrent | EztvTorrent): torrent is Torrent { return 'type' in torrent && 'video_codec' in torrent; } +// Type guard to check if torrent is EZTV Torrent with season pack +function isSeasonPack(torrent: Torrent | EztvTorrent): boolean { + return 'isSeasonPack' in torrent && (torrent as EztvTorrent).isSeasonPack === true; +} + export default function QualitySelector({ torrents, onSelect, @@ -35,17 +40,8 @@ export default function QualitySelector({ ); } - // Group torrents by quality - const torrentsByQuality = torrents.reduce((acc, torrent) => { - const quality = torrent.quality; - if (!acc[quality]) { - acc[quality] = []; - } - acc[quality].push(torrent); - return acc; - }, {} as Record); - // Sort qualities by preference (1080p > 720p > 480p > others) + // Also separate episode torrents from season packs const qualityOrder: Record = { '2160p': 5, '1080p': 4, @@ -54,12 +50,42 @@ export default function QualitySelector({ '360p': 1, }; + // Separate episode torrents and season packs + const episodeTorrents = torrents.filter(t => !isSeasonPack(t)); + const seasonPackTorrents = torrents.filter(t => isSeasonPack(t)); + + // Group episode torrents by quality + const torrentsByQuality = episodeTorrents.reduce((acc, torrent) => { + const quality = torrent.quality; + if (!acc[quality]) { + acc[quality] = []; + } + acc[quality].push(torrent); + return acc; + }, {} as Record); + + // Group season packs by quality + const seasonPacksByQuality = seasonPackTorrents.reduce((acc, torrent) => { + const quality = torrent.quality; + if (!acc[quality]) { + acc[quality] = []; + } + acc[quality].push(torrent); + return acc; + }, {} as Record); + const sortedQualities = Object.entries(torrentsByQuality).sort((a, b) => { const orderA = qualityOrder[a[0]] || 0; const orderB = qualityOrder[b[0]] || 0; return orderB - orderA; }); + const sortedSeasonPacks = Object.entries(seasonPacksByQuality).sort((a, b) => { + const orderA = qualityOrder[a[0]] || 0; + const orderB = qualityOrder[b[0]] || 0; + return orderB - orderA; + }); + const handlePlay = () => { if (selectedTorrent) { onSelect(selectedTorrent); @@ -75,51 +101,108 @@ export default function QualitySelector({ {/* Quality Selection Grid */}
-
- {sortedQualities.map(([quality, qualityTorrents]) => { - const torrent = qualityTorrents[0]; - const isSelected = selectedTorrent?.hash === torrent.hash; - const seeds = isYtsTorrent(torrent) - ? torrent.seeds - : (torrent as EztvTorrent).seeds; - const peers = isYtsTorrent(torrent) - ? torrent.peers - : (torrent as EztvTorrent).peers || 0; + {sortedQualities.length > 0 && ( + <> +

Episode Torrents

+
+ {sortedQualities.map(([quality, qualityTorrents]) => { + const torrent = qualityTorrents[0]; + const isSelected = selectedTorrent?.hash === torrent.hash; + const seeds = isYtsTorrent(torrent) + ? torrent.seeds + : (torrent as EztvTorrent).seeds; + const peers = isYtsTorrent(torrent) + ? torrent.peers + : (torrent as EztvTorrent).peers || 0; - return ( - setSelectedTorrent(torrent)} - className={`p-4 rounded-lg border-2 transition-all text-left ${ - isSelected - ? 'border-netflix-red bg-netflix-red/10' - : 'border-white/10 bg-white/5 hover:border-white/30' - }`} - > -
-
{quality}
- {isSelected && ( - - )} -
-
- {torrent.size || 'Unknown size'} -
-
- - - {seeds + peers} - - {isYtsTorrent(torrent) && torrent.type && ( - {torrent.type} - )} -
-
- ); - })} -
+ return ( + setSelectedTorrent(torrent)} + className={`p-4 rounded-lg border-2 transition-all text-left ${ + isSelected + ? 'border-netflix-red bg-netflix-red/10' + : 'border-white/10 bg-white/5 hover:border-white/30' + }`} + > +
+
{quality}
+ {isSelected && ( + + )} +
+
+ {torrent.size || 'Unknown size'} +
+
+ + + {seeds + peers} + + {isYtsTorrent(torrent) && torrent.type && ( + {torrent.type} + )} +
+
+ ); + })} +
+ + )} + + {/* Season Packs Section */} + {sortedSeasonPacks.length > 0 && ( + <> +

+ + Season Packs {sortedQualities.length === 0 && '(Episode file will be extracted)'} +

+
+ {sortedSeasonPacks.map(([quality, qualityTorrents]) => { + const torrent = qualityTorrents[0]; + const isSelected = selectedTorrent?.hash === torrent.hash; + const seeds = (torrent as EztvTorrent).seeds; + const peers = (torrent as EztvTorrent).peers || 0; + + return ( + setSelectedTorrent(torrent)} + className={`p-4 rounded-lg border-2 transition-all text-left ${ + isSelected + ? 'border-orange-500 bg-orange-500/10' + : 'border-orange-500/30 bg-orange-500/5 hover:border-orange-500/50' + }`} + > +
+
{quality}
+
+ + {isSelected && ( + + )} +
+
+
+ {torrent.size || 'Unknown size'} +
+
+ + + {seeds + peers} + + Full Season +
+
+ ); + })} +
+ + )}
{/* Selected Torrent Details */} @@ -140,6 +223,12 @@ export default function QualitySelector({ ? selectedTorrent.peers : (selectedTorrent as EztvTorrent).peers || 0} Peers + {isSeasonPack(selectedTorrent) && ( + + + Season Pack + + )} {isYtsTorrent(selectedTorrent) && selectedTorrent.video_codec && ( {selectedTorrent.video_codec} @@ -152,6 +241,15 @@ export default function QualitySelector({ )} + {isSeasonPack(selectedTorrent) && ( +
+

+ + This is a full season torrent. Only the selected episode file will be downloaded. +

+
+ )} +
diff --git a/src/components/player/StreamingPlayer.tsx b/src/components/player/StreamingPlayer.tsx index 1cc26bd..bbf2164 100644 --- a/src/components/player/StreamingPlayer.tsx +++ b/src/components/player/StreamingPlayer.tsx @@ -17,6 +17,8 @@ import { Users, HardDrive, Subtitles, + AlertTriangle, + MonitorPlay, } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { clsx } from 'clsx'; @@ -24,6 +26,8 @@ import type { Movie, Subtitle } from '../../types'; import type { StreamSession } from '../../services/streaming/streamingService'; import { searchSubtitles, downloadSubtitle, convertSrtToVtt } from '../../services/subtitles/opensubtitles'; import { useSettingsStore } from '../../stores/settingsStore'; +import { isCapacitor } from '../../utils/platform'; +import { playWithNativePlayer } from '../../plugins/ExoPlayer'; interface StreamingPlayerProps { movie: Movie; @@ -47,6 +51,8 @@ export default function StreamingPlayer({ const containerRef = useRef(null); const controlsTimeoutRef = useRef(); const currentSourceRef = useRef(null); // Track current source to avoid re-setting + const hasAppliedInitialTimeRef = useRef(false); // Track if initial seek has been applied + const lastPlaybackPositionRef = useRef(0); // Track last playback position for source switches const navigate = useNavigate(); const [isPlaying, setIsPlaying] = useState(false); @@ -68,8 +74,80 @@ export default function StreamingPlayer({ const [networkSpeed, setNetworkSpeed] = useState(0); const [currentQualityLevel, setCurrentQualityLevel] = useState('auto'); const [availableQualities, setAvailableQualities] = useState(['auto']); + const [audioWarning, setAudioWarning] = useState(null); + const [audioWarningDismissed, setAudioWarningDismissed] = useState(false); + const [useNativePlayer, setUseNativePlayer] = useState(false); + const [isNativePlayerPlaying, setIsNativePlayerPlaying] = useState(false); const { settings } = useSettingsStore(); + // Detect potential audio issues on Android with MKV files and enable native player + useEffect(() => { + // Only check on Capacitor (Android) since desktop has FFmpeg transcoding + if (!isCapacitor()) return; + + // Check if this is an MKV file (common for TV shows from EZTV) + const videoName = streamSession?.videoFile?.name?.toLowerCase() || ''; + const isMkv = videoName.endsWith('.mkv'); + + if (isMkv && !hlsUrl) { + // MKV files often have AC3/DTS audio that Android WebView can't play + // Enable native player option + setUseNativePlayer(true); + setAudioWarning( + 'This video uses an MKV format. Click "Play with Native Player" for better audio codec support, or try a different torrent with MP4 format.' + ); + } else { + setAudioWarning(null); + setUseNativePlayer(false); + } + }, [streamSession?.videoFile?.name, hlsUrl]); + + // Handle launching native ExoPlayer for better codec support + const handleNativePlayerLaunch = useCallback(async () => { + if (!streamUrl) return; + + setIsNativePlayerPlaying(true); + + // Pause webview video + if (videoRef.current) { + videoRef.current.pause(); + } + + const title = movie.title || 'Video'; + const startPos = currentTime; + + console.log('[NativePlayer] Launching ExoPlayer with URL:', streamUrl); + + const result = await playWithNativePlayer(streamUrl, title, startPos); + + setIsNativePlayerPlaying(false); + + if (result && result.success) { + console.log('[NativePlayer] Playback ended at:', result.position, 'completed:', result.completed); + + // Update current time from native player + if (result.position > 0) { + setCurrentTime(result.position); + if (videoRef.current) { + videoRef.current.currentTime = result.position; + } + // Report time to parent + if (onTimeUpdate) { + onTimeUpdate(result.position, result.duration || duration); + } + } + + // If video completed, navigate back + if (result.completed) { + navigate(-1); + } + } else { + console.error('[NativePlayer] Failed to play with native player'); + // Show error and fall back to webview + setAudioWarning('Native player failed. Using built-in player which may have audio issues with this format.'); + } + }, [streamUrl, movie.title, currentTime, duration, navigate, onTimeUpdate]); + // Format time to MM:SS or HH:MM:SS const formatTime = (seconds: number): string => { if (!isFinite(seconds)) return '0:00'; @@ -109,6 +187,13 @@ export default function StreamingPlayer({ const useHls = hlsUrl && Hls.isSupported(); + // Save current playback position before potentially switching sources + // This prevents the video from jumping back to initialTime when HLS becomes available + if (video.currentTime > 0 && !video.paused) { + lastPlaybackPositionRef.current = video.currentTime; + console.log('[Source] Saving current position before switch:', video.currentTime); + } + if (useHls) { // Skip if we already have this HLS source loaded if (currentSourceRef.current === hlsUrl && hlsRef.current) { @@ -191,9 +276,19 @@ export default function StreamingPlayer({ setAvailableQualities(sortedQualities); console.log('[HLS] Available qualities:', sortedQualities); - if (initialTime > 0) { - video.currentTime = initialTime; + // Determine the correct seek position: + // - If we were already playing (source switch), use last playback position + // - If this is first load and initialTime is set, use that + // - Otherwise start from beginning + const seekPosition = lastPlaybackPositionRef.current > 0 + ? lastPlaybackPositionRef.current + : (!hasAppliedInitialTimeRef.current && initialTime > 0 ? initialTime : 0); + + if (seekPosition > 0) { + console.log('[HLS] Seeking to position:', seekPosition); + video.currentTime = seekPosition; } + hasAppliedInitialTimeRef.current = true; video.play().catch(console.error); }); @@ -270,9 +365,21 @@ export default function StreamingPlayer({ const handleCanPlay = () => { console.log('[Video] Can play, starting playback...'); setIsBuffering(false); - if (initialTime > 0) { - video.currentTime = initialTime; + + // Determine the correct seek position: + // - If we were already playing (source switch), use last playback position + // - If this is first load and initialTime is set, use that + // - Otherwise start from beginning + const seekPosition = lastPlaybackPositionRef.current > 0 + ? lastPlaybackPositionRef.current + : (!hasAppliedInitialTimeRef.current && initialTime > 0 ? initialTime : 0); + + if (seekPosition > 0) { + console.log('[Video] Seeking to position:', seekPosition); + video.currentTime = seekPosition; } + hasAppliedInitialTimeRef.current = true; + video.play().catch((err) => { console.warn('[Video] Auto-play blocked:', err.message); // Some browsers block autoplay, that's ok - user can click play @@ -283,6 +390,20 @@ export default function StreamingPlayer({ const handleLoadedMetadata = () => { console.log('[Video] Metadata loaded, duration:', video.duration); setDuration(video.duration); + + // Check for audio tracks on Android - if no audio tracks detected, show warning + if (isCapacitor()) { + const audioTracks = (video as any).audioTracks; + + // Log audio tracks info for debugging + console.log('[Video] Audio tracks:', audioTracks?.length || 'unknown'); + + if (audioTracks && audioTracks.length === 0) { + setAudioWarning( + 'No audio track detected. This video file may use an audio codec that Android cannot play. Try a different torrent.' + ); + } + } }; const handleLoadedData = () => { @@ -318,6 +439,8 @@ export default function StreamingPlayer({ // If HLS is available, try switching to it if (hlsUrl && !hlsRef.current) { console.log('[Video] Decode error detected, switching to HLS transcoding...'); + // Save current position before switching + const currentPosition = video.currentTime > 0 ? video.currentTime : lastPlaybackPositionRef.current; // Force HLS playback const hls = new Hls({ enableWorker: true, @@ -325,8 +448,13 @@ export default function StreamingPlayer({ }); hls.loadSource(hlsUrl); hls.attachMedia(video); + hls.on(Hls.Events.MANIFEST_PARSED, () => { + if (currentPosition > 0) { + video.currentTime = currentPosition; + } + video.play().catch(console.error); + }); hlsRef.current = hls; - video.play().catch(console.error); } break; case error.MEDIA_ERR_SRC_NOT_SUPPORTED: @@ -334,14 +462,21 @@ export default function StreamingPlayer({ // If HLS is available, try switching to it if (hlsUrl && !hlsRef.current) { console.log('[Video] Format not supported, switching to HLS transcoding...'); + // Save current position before switching + const currentPosition = video.currentTime > 0 ? video.currentTime : lastPlaybackPositionRef.current; const hls = new Hls({ enableWorker: true, lowLatencyMode: false, }); hls.loadSource(hlsUrl); hls.attachMedia(video); + hls.on(Hls.Events.MANIFEST_PARSED, () => { + if (currentPosition > 0) { + video.currentTime = currentPosition; + } + video.play().catch(console.error); + }); hlsRef.current = hls; - video.play().catch(console.error); } break; } @@ -422,6 +557,10 @@ export default function StreamingPlayer({ const handleTimeUpdate = () => { setCurrentTime(video.currentTime); + // Keep track of playback position for source switches + if (video.currentTime > 0) { + lastPlaybackPositionRef.current = video.currentTime; + } onTimeUpdate?.(video.currentTime, video.duration); }; @@ -668,6 +807,58 @@ export default function StreamingPlayer({ }} playsInline /> + + {/* Audio Warning Banner for Android MKV files with Native Player option */} + + {audioWarning && !audioWarningDismissed && ( + +
+ +
+

Audio Format Issue

+

{audioWarning}

+ {useNativePlayer && ( + + )} +
+ +
+
+ )} +
+ + {/* Native Player Loading Overlay */} + + {isNativePlayerPlaying && ( + + +

Opening Native Player...

+

ExoPlayer with full codec support

+
+ )} +
{/* Click outside to close menus */} {(showSettings || showSubtitles) && ( diff --git a/src/hooks/useDownloadManager.ts b/src/hooks/useDownloadManager.ts new file mode 100644 index 0000000..6461653 --- /dev/null +++ b/src/hooks/useDownloadManager.ts @@ -0,0 +1,173 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { useDownloadStore } from '../stores/downloadStore'; +import { downloadService, type DownloadSession } from '../services/download/downloadService'; +import type { Movie, Torrent } from '../types'; +import { logger } from '../utils/logger'; + +/** + * Hook to manage downloads with actual torrent downloading + * This combines the download store state with the download service + */ +export function useDownloadManager() { + const { + items, + addDownload: addToStore, + updateDownload, + removeDownload: removeFromStore, + pauseDownload: pauseInStore, + resumeDownload: resumeInStore, + getDownload, + getDownloadByMovieId, + clearCompleted, + } = useDownloadStore(); + + // Track active download sessions + const activeSessionsRef = useRef>(new Map()); + + /** + * Start a new download + */ + const startDownload = useCallback(async (movie: Movie, torrent: Torrent): Promise => { + // Add to store first to get the ID + const id = addToStore(movie, torrent); + + try { + // Update status to downloading + updateDownload(id, { status: 'downloading' }); + + // Start the actual download + const result = await downloadService.startDownload( + id, + movie, + torrent, + (data: DownloadSession) => { + // Update progress in store + updateDownload(id, { + progress: data.progress, + downloadSpeed: data.downloadSpeed, + uploadSpeed: data.uploadSpeed, + peers: data.peers, + status: data.status === 'ready' + ? 'completed' + : data.status === 'error' + ? 'error' + : 'downloading', + }); + + // Log progress periodically + if (Math.floor(data.progress * 100) % 10 === 0) { + logger.debug('Download progress', { + id, + progress: `${(data.progress * 100).toFixed(1)}%`, + speed: `${(data.downloadSpeed / 1024 / 1024).toFixed(2)} MB/s` + }); + } + } + ); + + // Store the session ID + activeSessionsRef.current.set(id, result.sessionId); + + logger.info('Download started successfully', { id, movie: movie.title }); + return id; + } catch (error) { + // Update status to error + updateDownload(id, { + status: 'error', + }); + logger.error('Failed to start download', error); + throw error; + } + }, [addToStore, updateDownload]); + + /** + * Stop and remove a download + */ + const removeDownload = useCallback(async (id: string): Promise => { + try { + // Stop the download on the server + await downloadService.stopDownload(id); + activeSessionsRef.current.delete(id); + } catch (error) { + logger.error('Failed to stop download', error); + } + + // Remove from store + removeFromStore(id); + }, [removeFromStore]); + + /** + * Pause a download + */ + const pauseDownload = useCallback(async (id: string): Promise => { + // For now, pausing just updates the UI state + // A full implementation would need server support for pausing torrents + pauseInStore(id); + logger.info('Download paused', { id }); + }, [pauseInStore]); + + /** + * Resume a download + */ + const resumeDownload = useCallback(async (id: string): Promise => { + const download = getDownload(id); + if (!download) return; + + // If the download was never started or needs to be restarted + if (!activeSessionsRef.current.has(id)) { + try { + resumeInStore(id); + + // Restart the download + await downloadService.startDownload( + id, + download.movie, + download.torrent, + (data: DownloadSession) => { + updateDownload(id, { + progress: data.progress, + downloadSpeed: data.downloadSpeed, + uploadSpeed: data.uploadSpeed, + peers: data.peers, + status: data.status === 'ready' + ? 'completed' + : data.status === 'error' + ? 'error' + : 'downloading', + }); + } + ); + } catch (error) { + updateDownload(id, { status: 'error' }); + logger.error('Failed to resume download', error); + } + } else { + resumeInStore(id); + } + }, [getDownload, resumeInStore, updateDownload]); + + /** + * Cleanup on unmount + */ + useEffect(() => { + return () => { + // Don't disconnect on unmount - downloads should continue in background + // downloadService.disconnect(); + }; + }, []); + + return { + downloads: items, + startDownload, + removeDownload, + pauseDownload, + resumeDownload, + getDownload, + getDownloadByMovieId, + clearCompleted, + activeCount: items.filter(d => d.status === 'downloading').length, + completedCount: items.filter(d => d.status === 'completed').length, + }; +} + +export default useDownloadManager; diff --git a/src/pages/Downloads.tsx b/src/pages/Downloads.tsx index 050f363..8a84820 100644 --- a/src/pages/Downloads.tsx +++ b/src/pages/Downloads.tsx @@ -1,20 +1,20 @@ import { motion } from 'framer-motion'; -import { Download, Trash2, Pause, Play, FolderOpen, X } from 'lucide-react'; +import { Download, Trash2, Pause, Play, FolderOpen, X, RefreshCw } from 'lucide-react'; import { Link } from 'react-router-dom'; import Button from '../components/ui/Button'; import ProgressBar from '../components/ui/ProgressBar'; import Badge from '../components/ui/Badge'; -import { useDownloadStore } from '../stores/downloadStore'; +import { useDownloadManager } from '../hooks/useDownloadManager'; import { formatSpeed } from '../services/torrent/webtorrent'; export default function Downloads() { const { - items, + downloads: items, pauseDownload, resumeDownload, removeDownload, clearCompleted, - } = useDownloadStore(); + } = useDownloadManager(); const activeDownloads = items.filter( (item) => item.status === 'downloading' || item.status === 'queued' diff --git a/src/pages/MovieDetails.tsx b/src/pages/MovieDetails.tsx index 0d915db..7eb47d8 100644 --- a/src/pages/MovieDetails.tsx +++ b/src/pages/MovieDetails.tsx @@ -12,10 +12,11 @@ import { Share2, ChevronDown, ChevronUp, + Loader2, } from 'lucide-react'; import { useMovieDetails, useMovieSuggestions } from '../hooks/useMovies'; import { useWatchlistStore } from '../stores/watchlistStore'; -import { useDownloadStore } from '../stores/downloadStore'; +import { useDownloadManager } from '../hooks/useDownloadManager'; import Button from '../components/ui/Button'; import Badge from '../components/ui/Badge'; import MovieRow from '../components/movie/MovieRow'; @@ -33,11 +34,12 @@ export default function MovieDetails() { const { movie, isLoading, error } = useMovieDetails(movieId); const { movies: suggestions, isLoading: suggestionsLoading } = useMovieSuggestions(movieId); const { isInWatchlist, addToWatchlist, removeFromWatchlist } = useWatchlistStore(); - const { addDownload } = useDownloadStore(); + const { startDownload } = useDownloadManager(); const [showFullDescription, setShowFullDescription] = useState(false); const [showTorrentModal, setShowTorrentModal] = useState(false); const [showQualityModal, setShowQualityModal] = useState(false); + const [isStartingDownload, setIsStartingDownload] = useState(false); if (isLoading) return ; @@ -81,10 +83,19 @@ export default function MovieDetails() { } }; - const handleDownload = (torrent: Torrent) => { - addDownload(movie, torrent); - setShowTorrentModal(false); - navigate('/downloads'); + const handleDownload = async (torrent: Torrent) => { + setIsStartingDownload(true); + try { + await startDownload(movie, torrent); + setShowTorrentModal(false); + navigate('/downloads'); + } catch (error) { + console.error('Failed to start download:', error); + // Still navigate to downloads page to see error state + navigate('/downloads'); + } finally { + setIsStartingDownload(false); + } }; const handleShare = async () => { @@ -141,10 +152,10 @@ export default function MovieDetails() { className="flex-1" > {/* Title */} -

{movie.title}

+

{movie.title}

{/* Meta */} -
+
{movie.rating}/10 diff --git a/src/pages/TVPlayer.tsx b/src/pages/TVPlayer.tsx index a07ec57..ec2fcf9 100644 --- a/src/pages/TVPlayer.tsx +++ b/src/pages/TVPlayer.tsx @@ -35,6 +35,10 @@ export default function TVPlayer() { const episodeTitle = searchParams.get('title') || ''; const poster = searchParams.get('poster') || ''; const backdrop = searchParams.get('backdrop') || ''; + + // Season pack options + const isSeasonPack = searchParams.get('isSeasonPack') === 'true'; + const episodeFilePattern = searchParams.get('episodeFilePattern') || ''; // Extract hash from magnet URL if not provided directly const torrentHash = hash || extractHashFromMagnet(magnetUrl || ''); @@ -118,13 +122,14 @@ export default function TVPlayer() { const episodeName = `${showTitle} S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}${episodeTitle ? ` - ${episodeTitle}` : ''}`; try { - console.log(`[TVPlayer] Starting stream with hash: ${torrentHash}, magnet: ${magnetUrl?.substring(0, 60)}...`); + console.log(`[TVPlayer] Starting stream with hash: ${torrentHash}, magnet: ${magnetUrl?.substring(0, 60)}...${isSeasonPack ? ' (Season Pack)' : ''}`); - // Start the stream + // Start the stream (with season pack options if applicable) const result = await streamingService.startStream( torrentHash, episodeName, - quality + quality, + isSeasonPack ? { isSeasonPack, episodeFilePattern } : undefined ); if (!result) { diff --git a/src/pages/TVShowDetails.tsx b/src/pages/TVShowDetails.tsx index c425002..cbf0db7 100644 --- a/src/pages/TVShowDetails.tsx +++ b/src/pages/TVShowDetails.tsx @@ -175,13 +175,67 @@ export default function TVShowDetails() { } | null>(null); const [magnetInput, setMagnetInput] = useState(''); - // Handle episode play click - show quality selector if multiple options - const handlePlayEpisodeClick = (season: number, episode: number) => { + // Handle episode play click - automatically load torrents if needed, then show quality selector + const handlePlayEpisodeClick = async (season: number, episode: number) => { const episodeKey = `${season}-${episode}`; - const currentEpisodeTorrents = episodeTorrents[episodeKey] || []; + let currentEpisodeTorrents = episodeTorrents[episodeKey] || []; + + // If no torrents loaded yet, load them first + if (currentEpisodeTorrents.length === 0 && !loadingEpisodes.has(episodeKey)) { + // Show loading state + setLoadingEpisodes(prev => new Set(prev).add(episodeKey)); + + try { + if (!show) { + console.warn('[TVShowDetails] Show data not loaded yet'); + return; + } + const imdbId = (show as any).imdbId; + if (!imdbId) { + console.warn('[TVShowDetails] No IMDB ID available for torrent lookup'); + return; + } + + console.log(`[TVShowDetails] Auto-loading torrents for S${season}E${episode}`); + const torrents = await tvDiscoveryApi.searchEpisodeTorrents(show.title, season, episode, imdbId); + + setEpisodeTorrents(prev => ({ + ...prev, + [episodeKey]: torrents + })); + + currentEpisodeTorrents = torrents; + console.log(`[TVShowDetails] Found ${torrents.length} torrents for S${season}E${episode}`); + } catch (error) { + console.error(`Error loading torrents for S${season}E${episode}:`, error); + setEpisodeTorrents(prev => ({ + ...prev, + [episodeKey]: [] + })); + return; + } finally { + setLoadingEpisodes(prev => { + const newSet = new Set(prev); + newSet.delete(episodeKey); + return newSet; + }); + } + } + + // Wait if currently loading + if (loadingEpisodes.has(episodeKey)) { + return; // Already loading, user will need to click again + } if (currentEpisodeTorrents.length === 0) { - // No torrents loaded yet, this shouldn't happen since button only shows when torrents exist + // No torrents found - show a message or fallback + const episodeInfo = episodes.find(e => e.episode_number === episode); + setShowQualityModal({ + season, + episode, + torrents: [], + episodeTitle: episodeInfo?.name || `Episode ${episode}`, + }); return; } @@ -227,6 +281,14 @@ export default function TVShowDetails() { backdrop: encodeURIComponent(show?.backdrop || episodeInfo?.still_path ? `https://image.tmdb.org/t/p/original${episodeInfo?.still_path}` : ''), }); + // Add season pack info if applicable + if (torrent.isSeasonPack) { + params.set('isSeasonPack', 'true'); + if (torrent.episodeFilePattern) { + params.set('episodeFilePattern', torrent.episodeFilePattern); + } + } + // Navigate to the TV Player page navigate(`/tv/play/${id}?${params.toString()}`); }; @@ -331,9 +393,9 @@ export default function TVShowDetails() { TV SERIES
-

{show.title}

+

{show.title}

-
+
{show.rating.toFixed(1)} @@ -443,10 +505,10 @@ export default function TVShowDetails() { key={episode.id} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} - className="flex gap-4 p-4 rounded-lg bg-white/5 hover:bg-white/10 transition-colors group" + className="flex flex-col sm:flex-row gap-3 md:gap-4 p-3 md:p-4 rounded-lg bg-white/5 hover:bg-white/10 transition-colors group" > {/* Episode Thumbnail */} -
+
{episode.still_path ? ( )}
- {hasTorrents ? ( - - ) : ( - - )} +
E{episode.episode_number} @@ -497,10 +539,10 @@ export default function TVShowDetails() {
{/* Episode Info */} -
-
-
-

+
+
+
+

{episode.episode_number}. {episode.name}

@@ -522,41 +564,61 @@ export default function TVShowDetails() {
- {/* Torrent/Play Options */} - {hasTorrents && ( -
-
- {currentEpisodeTorrents.slice(0, 3).map((torrent, idx) => ( - - ))} -
+ {/* Torrent/Play Options - Always show play button */} +
+ {hasTorrents ? ( + <> +
+ {currentEpisodeTorrents.slice(0, 3).map((torrent, idx) => ( + + ))} +
+ + + ) : ( -
- )} + )} +

{episode.overview}

diff --git a/src/pages/TVShows.tsx b/src/pages/TVShows.tsx index 56d76aa..bf9de9c 100644 --- a/src/pages/TVShows.tsx +++ b/src/pages/TVShows.tsx @@ -168,7 +168,7 @@ function ShowRow({
{show.poster ? ( @@ -196,11 +196,11 @@ function ShowRow({
)}
-

+

{show.title}

-

- {show.year} {show.seasonCount ? `• ${show.seasonCount} Season${show.seasonCount !== 1 ? 's' : ''}` : ''} +

+ {show.year} {show.seasonCount ? `• ${show.seasonCount}S` : ''}

))} @@ -301,7 +301,7 @@ export default function TVShows() {
{/* Content */} -
0 && !isShowingSearch ? '-mt-32 relative z-10 pt-8' : 'pt-4'}> +
0 && !isShowingSearch ? '-mt-32 relative z-10 pt-32' : 'pt-4'}> {isShowingSearch ? ( // Search Results
diff --git a/src/plugins/ExoPlayer.ts b/src/plugins/ExoPlayer.ts new file mode 100644 index 0000000..10a9b86 --- /dev/null +++ b/src/plugins/ExoPlayer.ts @@ -0,0 +1,105 @@ +import { Capacitor, registerPlugin } from '@capacitor/core'; + +export interface ExoPlayerPlayResult { + success: boolean; + position: number; // Current position in seconds + duration: number; // Total duration in seconds + completed: boolean; // Whether playback completed +} + +export interface ExoPlayerAvailability { + available: boolean; + platform: string; +} + +export interface ExoPlayerPluginInterface { + /** + * Play a video using the native ExoPlayer + * @param options - Video options + * @returns Promise that resolves when playback ends + */ + play(options: { + url: string; + title?: string; + startPosition?: number; // Start position in seconds + }): Promise; + + /** + * Check if ExoPlayer is available on this platform + * @returns Promise with availability info + */ + isAvailable(): Promise; +} + +/** + * ExoPlayer Capacitor plugin + * Provides native video playback with full codec support on Android + */ +const ExoPlayerPlugin = registerPlugin('ExoPlayerPlugin', { + web: () => import('./ExoPlayer.web').then(m => new m.ExoPlayerWeb()), +}); + +/** + * Check if native video player is available + */ +export const isNativePlayerAvailable = async (): Promise => { + if (Capacitor.getPlatform() !== 'android') { + return false; + } + + try { + const result = await ExoPlayerPlugin.isAvailable(); + return result.available; + } catch (error) { + console.error('Failed to check ExoPlayer availability:', error); + return false; + } +}; + +/** + * Play video using native ExoPlayer + * @param url - The video URL to play + * @param title - Optional title to display + * @param startPosition - Optional start position in seconds + * @returns Playback result or null if failed/unavailable + */ +export const playWithNativePlayer = async ( + url: string, + title?: string, + startPosition?: number +): Promise => { + if (Capacitor.getPlatform() !== 'android') { + console.log('Native player only available on Android'); + return null; + } + + try { + const result = await ExoPlayerPlugin.play({ + url, + title: title || 'Video', + startPosition: startPosition || 0, + }); + return result; + } catch (error) { + console.error('Failed to play with native player:', error); + return null; + } +}; + +/** + * Check if a video URL should use the native player + * MKV files often contain AC3/DTS audio that WebView can't decode + */ +export const shouldUseNativePlayer = (url: string): boolean => { + if (Capacitor.getPlatform() !== 'android') { + return false; + } + + // Check for MKV files which commonly have AC3/DTS audio + const lowerUrl = url.toLowerCase(); + return lowerUrl.includes('.mkv') || + lowerUrl.includes('mkv=') || + lowerUrl.includes('format=mkv'); +}; + +export default ExoPlayerPlugin; diff --git a/src/plugins/ExoPlayer.web.ts b/src/plugins/ExoPlayer.web.ts new file mode 100644 index 0000000..e8813bc --- /dev/null +++ b/src/plugins/ExoPlayer.web.ts @@ -0,0 +1,28 @@ +import type { ExoPlayerPluginInterface, ExoPlayerPlayResult, ExoPlayerAvailability } from './ExoPlayer'; + +/** + * Web implementation of ExoPlayer plugin + * Since ExoPlayer is Android-only, this just returns unavailable + */ +export class ExoPlayerWeb implements ExoPlayerPluginInterface { + async play(_options: { + url: string; + title?: string; + startPosition?: number; + }): Promise { + // On web, native player is not available + return { + success: false, + position: 0, + duration: 0, + completed: false, + }; + } + + async isAvailable(): Promise { + return { + available: false, + platform: 'web', + }; + } +} diff --git a/src/services/api/tvDiscovery.ts b/src/services/api/tvDiscovery.ts index 4bc8c92..77ab9e7 100644 --- a/src/services/api/tvDiscovery.ts +++ b/src/services/api/tvDiscovery.ts @@ -39,6 +39,10 @@ export interface EztvTorrent { seeds: number; peers: number; magnetUrl: string; + // For season packs - indicates this is a full season torrent + isSeasonPack?: boolean; + // For season packs - the episode file pattern to select + episodeFilePattern?: string; } export interface DiscoveredShow { @@ -397,6 +401,7 @@ export const tvDiscoveryApi = { /** * Search for episode torrents using EZTV API + * Includes pagination, improved regex matching, and season pack support */ async searchEpisodeTorrents( _showTitle: string, @@ -416,43 +421,123 @@ export const tvDiscoveryApi = { console.log(`[Episode Search] Searching for S${seasonStr}E${episodeStr} with IMDB: ${numericId}`); try { - const response = await axios.get(`${getEztvBaseUrl()}/get-torrents`, { - params: { - imdb_id: numericId, - limit: 100, // Get plenty of results - }, - timeout: 15000, - validateStatus: (status) => status < 500, - }); + // Fetch torrents with pagination to get all results + const allTorrents: any[] = []; + let page = 1; + const maxPages = 5; // Safety limit + let hasMore = true; - if (response.data?.torrents && Array.isArray(response.data.torrents)) { - console.log(`[Episode Search] Found ${response.data.torrents.length} total torrents for IMDB ${numericId}`); + while (hasMore && page <= maxPages) { + const response = await axios.get(`${getEztvBaseUrl()}/get-torrents`, { + params: { + imdb_id: numericId, + limit: 100, + page: page, + }, + timeout: 15000, + validateStatus: (status) => status < 500, + }); + if (response.data?.torrents && Array.isArray(response.data.torrents)) { + allTorrents.push(...response.data.torrents); + + // Check if there are more pages + const totalCount = response.data.torrents_count || 0; + hasMore = allTorrents.length < totalCount && response.data.torrents.length === 100; + page++; + } else { + hasMore = false; + } + } - const filtered = response.data.torrents - .filter((t: any) => { - if (!t.title) return false; + console.log(`[Episode Search] Fetched ${allTorrents.length} total torrents across ${page - 1} pages`); - // Check if season/episode fields exist and match - const torrentSeason = t.season?.toString(); - const torrentEpisode = t.episode?.toString(); + if (allTorrents.length === 0) { + console.log(`[Episode Search] No torrents found for IMDB ${numericId} (show may not be on EZTV)`); + return []; + } - // EZTV returns season as "1" not "01", so compare normalized values - const seasonMatches = parseInt(torrentSeason || '0') === season; - const episodeMatches = parseInt(torrentEpisode || '0') === episode; + // Helper function to parse season/episode from title + const parseSeasonEpisode = (title: string): { season: number; episode: number } | null => { + // Match patterns like S01E05, S1E5, etc. + const match = title.match(/[Ss]0*(\d+)[Ee]0*(\d+)/); + if (match) { + return { season: parseInt(match[1]), episode: parseInt(match[2]) }; + } + return null; + }; - // Also try title-based matching as fallback - let titleMatches = false; - if (!seasonMatches || !episodeMatches) { - const titleLower = t.title.toLowerCase(); - const seasonPattern = new RegExp(`s0?${season}\\b`, 'i'); - const episodePattern = new RegExp(`e0?${episode}\\b`, 'i'); - titleMatches = seasonPattern.test(titleLower) && episodePattern.test(titleLower); - } + // Helper to check if a torrent is a season pack + const isSeasonPack = (torrent: any): boolean => { + const title = (torrent.title || torrent.filename || '').toLowerCase(); + // Check for "complete" or episode "0" or no episode number + if (torrent.episode === '0' || torrent.episode === 0) return true; + if (title.includes('complete') || title.includes('season.pack') || title.includes('season pack')) return true; + // Check if title has season but no episode pattern + const hasSeasonOnly = /[Ss]0*\d+(?![Ee])/.test(torrent.title || ''); + return hasSeasonOnly; + }; - return (seasonMatches && episodeMatches) || titleMatches; - }) - .map((t: any) => ({ + const episodeTorrents: EztvTorrent[] = []; + const seasonPackTorrents: EztvTorrent[] = []; + + for (const t of allTorrents) { + if (!t.title) continue; + + // First, check using EZTV's season/episode fields + const torrentSeason = parseInt(t.season?.toString() || '0'); + let torrentEpisode = parseInt(t.episode?.toString() || '0'); + + // Handle malformed episode numbers (e.g., "5720" from "S03E05720p") + // If episode seems unreasonably large, try to parse from title + if (torrentEpisode > 999) { + const parsed = parseSeasonEpisode(t.title); + if (parsed) { + torrentEpisode = parsed.episode; + } + } + + const seasonMatches = torrentSeason === season; + const episodeMatches = torrentEpisode === episode; + + // Title-based matching with improved regex (handles malformed titles like "S03E05720p") + let titleMatches = false; + if (!episodeMatches) { + // Season pattern: S followed by optional zeros and season number, then E or non-digit + const seasonPattern = new RegExp(`[Ss]0*${season}(?=[Ee]|[^0-9]|$)`, 'i'); + // Episode pattern: E followed by optional zeros and episode number + // Must be followed by: non-digit, 3+ digits (resolution like 720, 1080), or end + const episodePattern = new RegExp(`[Ee]0*${episode}(?=[^0-9]|[0-9]{3}|$)`, 'i'); + titleMatches = seasonPattern.test(t.title) && episodePattern.test(t.title); + } + + // Check if this is a season pack for the right season + if (seasonMatches && isSeasonPack(t)) { + // This is a season pack - can be used as fallback + const torrent: EztvTorrent = { + id: t.id || Date.now() + Math.random(), + hash: t.hash || t.info_hash || '', + filename: t.filename || t.title || '', + title: `${t.title || t.filename || ''} (Season Pack)`, + season: seasonStr, + episode: episodeStr, + quality: extractQuality(t.title || t.filename || ''), + size: t.size_bytes ? formatBytes(parseInt(t.size_bytes)) : (t.size || 'Unknown'), + sizeBytes: parseInt(t.size_bytes || t.size || '0'), + seeds: parseInt(t.seeds || '0'), + peers: parseInt(t.peers || '0'), + magnetUrl: t.magnet_url || t.magnet || '', + isSeasonPack: true, + // Pattern to find the episode file within the pack + episodeFilePattern: `[Ss]0*${season}[Ee]0*${episode}[^0-9]`, + }; + seasonPackTorrents.push(torrent); + continue; + } + + // This is a specific episode torrent + if ((seasonMatches && episodeMatches) || titleMatches) { + const torrent: EztvTorrent = { id: t.id || Date.now() + Math.random(), hash: t.hash || t.info_hash || '', filename: t.filename || t.title || '', @@ -465,14 +550,21 @@ export const tvDiscoveryApi = { seeds: parseInt(t.seeds || '0'), peers: parseInt(t.peers || '0'), magnetUrl: t.magnet_url || t.magnet || '', - })); - - console.log(`[Episode Search] Filtered to ${filtered.length} episodes for S${seasonStr}E${episodeStr}`); - return filtered; - } else { - console.log(`[Episode Search] No torrents found for IMDB ${numericId} (show may not be on EZTV)`); - return []; + isSeasonPack: false, + }; + episodeTorrents.push(torrent); + } } + + console.log(`[Episode Search] Found ${episodeTorrents.length} episode torrents, ${seasonPackTorrents.length} season packs for S${seasonStr}E${episodeStr}`); + + // Combine results: episode-specific first, then season packs as fallback + // Sort by seeds within each category + const sortBySeeds = (a: EztvTorrent, b: EztvTorrent) => b.seeds - a.seeds; + episodeTorrents.sort(sortBySeeds); + seasonPackTorrents.sort(sortBySeeds); + + return [...episodeTorrents, ...seasonPackTorrents]; } catch (error: any) { console.warn('[Episode Search] EZTV API error:', error.message); return []; diff --git a/src/services/download/downloadService.ts b/src/services/download/downloadService.ts new file mode 100644 index 0000000..305e6ca --- /dev/null +++ b/src/services/download/downloadService.ts @@ -0,0 +1,229 @@ +import axios from 'axios'; +import { getApiUrl } from '../../utils/platform'; +import { logger } from '../../utils/logger'; +import type { Movie, Torrent } from '../../types'; + +// Get API URL using platform-aware resolution +const getApiUrlValue = () => getApiUrl(); + +export interface DownloadSession { + sessionId: string; + status: 'connecting' | 'downloading' | 'ready' | 'error' | 'paused'; + progress: number; + downloadSpeed: number; + uploadSpeed: number; + peers: number; + downloaded: number; + total: number; + videoFile?: { + name: string; + size: number; + path?: string; + }; + error?: string; +} + +class DownloadService { + private ws: WebSocket | null = null; + private downloads: Map void; + }> = new Map(); + private reconnectAttempts = 0; + private maxReconnectAttempts = 5; + + /** + * Connect to WebSocket for download progress updates + */ + private async connectWebSocket(): Promise { + return new Promise((resolve, reject) => { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + resolve(); + return; + } + + try { + const apiUrl = getApiUrlValue(); + const wsUrl = apiUrl.replace('http://', 'ws://').replace('https://', 'wss://') + '/ws'; + this.ws = new WebSocket(wsUrl); + + this.ws.onopen = () => { + logger.info('Download WebSocket connected'); + this.reconnectAttempts = 0; + + // Resubscribe to all active downloads + this.downloads.forEach((download) => { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify({ type: 'subscribe', sessionId: download.sessionId })); + } + }); + + resolve(); + }; + + this.ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + // Find the download by sessionId + this.downloads.forEach((download) => { + if (download.sessionId === data.sessionId || data.type === 'progress') { + download.callback(data); + } + }); + } catch (e) { + logger.error('Download WebSocket message parse error', e); + } + }; + + this.ws.onerror = (error) => { + logger.error('Download WebSocket error', error); + }; + + this.ws.onclose = () => { + logger.info('Download WebSocket disconnected'); + this.attemptReconnect(); + }; + } catch (error) { + reject(error); + } + }); + } + + /** + * Attempt to reconnect WebSocket + */ + private attemptReconnect() { + if (this.downloads.size === 0) { + return; // No active downloads, no need to reconnect + } + + if (this.reconnectAttempts < this.maxReconnectAttempts) { + this.reconnectAttempts++; + logger.info(`Reconnecting download WebSocket... attempt ${this.reconnectAttempts}`); + setTimeout(() => this.connectWebSocket(), 1000 * this.reconnectAttempts); + } else { + logger.warn('Max reconnection attempts reached for download WebSocket'); + } + } + + /** + * Start a new download + */ + async startDownload( + id: string, + movie: Movie, + torrent: Torrent, + onProgress: (data: DownloadSession) => void + ): Promise<{ sessionId: string; status: string }> { + try { + // Ensure WebSocket is connected + await this.connectWebSocket(); + + const apiUrl = getApiUrlValue(); + + // Start the torrent session on the server + const response = await axios.post(`${apiUrl}/api/stream/start`, { + hash: torrent.hash, + name: `${movie.title_long || movie.title} [${torrent.quality}]`, + quality: torrent.quality, + }); + + const { sessionId, status } = response.data; + + // Store the download info + this.downloads.set(id, { + sessionId, + callback: onProgress, + }); + + // Subscribe to updates via WebSocket + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify({ type: 'subscribe', sessionId })); + } + + logger.info('Download started', { id, sessionId, movie: movie.title }); + + return { sessionId, status }; + } catch (error) { + logger.error('Failed to start download', error); + throw error; + } + } + + /** + * Stop a download + */ + async stopDownload(id: string): Promise { + const download = this.downloads.get(id); + if (!download) { + return; + } + + try { + const apiUrl = getApiUrlValue(); + await axios.delete(`${apiUrl}/api/stream/${download.sessionId}`); + this.downloads.delete(id); + logger.info('Download stopped', { id }); + } catch (error) { + logger.error('Failed to stop download', error); + throw error; + } + } + + /** + * Get download status + */ + async getStatus(id: string): Promise { + const download = this.downloads.get(id); + if (!download) { + return null; + } + + try { + const apiUrl = getApiUrlValue(); + const response = await axios.get(`${apiUrl}/api/stream/${download.sessionId}/status`); + return response.data; + } catch (error) { + logger.error('Failed to get download status', error); + return null; + } + } + + /** + * Check if download service is connected + */ + isConnected(): boolean { + return this.ws !== null && this.ws.readyState === WebSocket.OPEN; + } + + /** + * Disconnect and cleanup + */ + disconnect(): void { + if (this.ws) { + this.ws.close(); + this.ws = null; + } + this.downloads.clear(); + } + + /** + * Get the video file path for a completed download + */ + async getVideoPath(id: string): Promise { + const download = this.downloads.get(id); + if (!download) { + return null; + } + + try { + const status = await this.getStatus(id); + return status?.videoFile?.path || null; + } catch (error) { + return null; + } + } +} + +export const downloadService = new DownloadService(); +export default downloadService; diff --git a/src/services/streaming/streamingService.ts b/src/services/streaming/streamingService.ts index 08a765e..ae83e22 100644 --- a/src/services/streaming/streamingService.ts +++ b/src/services/streaming/streamingService.ts @@ -136,17 +136,23 @@ class StreamingService { /** * Start a new streaming session + * @param hash - Torrent hash + * @param name - Display name + * @param quality - Quality string (optional) + * @param options - Additional options for season packs */ async startStream( hash: string, name: string, - quality?: string + quality?: string, + options?: { isSeasonPack?: boolean; episodeFilePattern?: string } ): Promise<{ sessionId: string; status: string; videoFile?: { name: string; size: number } }> { const apiUrl = getApiUrlValue(); const response = await axios.post(`${apiUrl}/api/stream/start`, { hash, name, quality, + ...(options || {}), }); return response.data; }