Fixes on downloading and series episode lookup.

This commit is contained in:
2026-01-02 13:02:41 +01:00
parent 5cecb0cd48
commit f7d43e10b7
15 changed files with 1274 additions and 208 deletions

View File

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

View File

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

View File

@@ -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<string, (Torrent | EztvTorrent)[]>);
// Sort qualities by preference (1080p > 720p > 480p > others)
// Also separate episode torrents from season packs
const qualityOrder: Record<string, number> = {
'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<string, (Torrent | EztvTorrent)[]>);
// 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<string, (Torrent | EztvTorrent)[]>);
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 */}
<div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{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 && (
<>
<h4 className="text-sm font-medium text-gray-400 mb-3">Episode Torrents</h4>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 mb-4">
{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 (
<motion.button
key={quality}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={() => 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'
}`}
>
<div className="flex items-center justify-between mb-2">
<div className="text-xl font-bold">{quality}</div>
{isSelected && (
<Check size={20} className="text-netflix-red" />
)}
</div>
<div className="text-sm text-gray-400 mb-2">
{torrent.size || 'Unknown size'}
</div>
<div className="flex items-center gap-3 text-xs text-gray-500">
<span className="flex items-center gap-1">
<Users size={12} />
{seeds + peers}
</span>
{isYtsTorrent(torrent) && torrent.type && (
<span>{torrent.type}</span>
)}
</div>
</motion.button>
);
})}
</div>
return (
<motion.button
key={quality}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={() => 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'
}`}
>
<div className="flex items-center justify-between mb-2">
<div className="text-xl font-bold">{quality}</div>
{isSelected && (
<Check size={20} className="text-netflix-red" />
)}
</div>
<div className="text-sm text-gray-400 mb-2">
{torrent.size || 'Unknown size'}
</div>
<div className="flex items-center gap-3 text-xs text-gray-500">
<span className="flex items-center gap-1">
<Users size={12} />
{seeds + peers}
</span>
{isYtsTorrent(torrent) && torrent.type && (
<span>{torrent.type}</span>
)}
</div>
</motion.button>
);
})}
</div>
</>
)}
{/* Season Packs Section */}
{sortedSeasonPacks.length > 0 && (
<>
<h4 className="text-sm font-medium text-gray-400 mb-3 flex items-center gap-2">
<Package size={14} />
Season Packs {sortedQualities.length === 0 && '(Episode file will be extracted)'}
</h4>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{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 (
<motion.button
key={`pack-${quality}`}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={() => 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'
}`}
>
<div className="flex items-center justify-between mb-2">
<div className="text-xl font-bold">{quality}</div>
<div className="flex items-center gap-1">
<Package size={16} className="text-orange-400" />
{isSelected && (
<Check size={20} className="text-orange-500" />
)}
</div>
</div>
<div className="text-sm text-gray-400 mb-2">
{torrent.size || 'Unknown size'}
</div>
<div className="flex items-center gap-3 text-xs text-gray-500">
<span className="flex items-center gap-1">
<Users size={12} />
{seeds + peers}
</span>
<span className="text-orange-400/70">Full Season</span>
</div>
</motion.button>
);
})}
</div>
</>
)}
</div>
{/* Selected Torrent Details */}
@@ -140,6 +223,12 @@ export default function QualitySelector({
? selectedTorrent.peers
: (selectedTorrent as EztvTorrent).peers || 0} Peers
</Badge>
{isSeasonPack(selectedTorrent) && (
<Badge variant="warning" className="bg-orange-500/20 text-orange-400">
<Package size={12} className="mr-1" />
Season Pack
</Badge>
)}
{isYtsTorrent(selectedTorrent) && selectedTorrent.video_codec && (
<Badge>
{selectedTorrent.video_codec}
@@ -152,6 +241,15 @@ export default function QualitySelector({
)}
</div>
{isSeasonPack(selectedTorrent) && (
<div className="mb-4 p-3 bg-orange-500/10 rounded-lg border border-orange-500/20">
<p className="text-sm text-orange-200">
<Package size={14} className="inline mr-2" />
This is a full season torrent. Only the selected episode file will be downloaded.
</p>
</div>
)}
<div className="grid grid-cols-2 gap-3 mb-4 text-sm">
<div className="flex items-center gap-2 text-gray-400">
<HardDrive size={16} />

View File

@@ -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<HTMLDivElement>(null);
const controlsTimeoutRef = useRef<NodeJS.Timeout>();
const currentSourceRef = useRef<string | null>(null); // Track current source to avoid re-setting
const hasAppliedInitialTimeRef = useRef(false); // Track if initial seek has been applied
const lastPlaybackPositionRef = useRef<number>(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<number>(0);
const [currentQualityLevel, setCurrentQualityLevel] = useState<string>('auto');
const [availableQualities, setAvailableQualities] = useState<string[]>(['auto']);
const [audioWarning, setAudioWarning] = useState<string | null>(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 */}
<AnimatePresence>
{audioWarning && !audioWarningDismissed && (
<motion.div
initial={{ opacity: 0, y: -50 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -50 }}
className="absolute top-0 left-0 right-0 z-50 bg-yellow-600/95 text-white p-3"
>
<div className="flex items-start gap-3 max-w-4xl mx-auto">
<AlertTriangle size={24} className="flex-shrink-0 mt-0.5" />
<div className="flex-1 text-sm">
<p className="font-medium mb-1">Audio Format Issue</p>
<p className="text-yellow-100">{audioWarning}</p>
{useNativePlayer && (
<button
onClick={handleNativePlayerLaunch}
disabled={isNativePlayerPlaying}
className="mt-2 flex items-center gap-2 px-4 py-2 bg-white/20 hover:bg-white/30 rounded-lg font-medium transition-colors disabled:opacity-50"
>
<MonitorPlay size={18} />
{isNativePlayerPlaying ? 'Opening Native Player...' : 'Play with Native Player (Full Audio Support)'}
</button>
)}
</div>
<button
onClick={() => setAudioWarningDismissed(true)}
className="text-yellow-200 hover:text-white p-1"
>
</button>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Native Player Loading Overlay */}
<AnimatePresence>
{isNativePlayerPlaying && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 flex flex-col items-center justify-center bg-black/80 z-50"
>
<Loader2 size={48} className="animate-spin text-netflix-red mb-4" />
<p className="text-white text-lg">Opening Native Player...</p>
<p className="text-gray-400 text-sm mt-2">ExoPlayer with full codec support</p>
</motion.div>
)}
</AnimatePresence>
{/* Click outside to close menus */}
{(showSettings || showSubtitles) && (

View File

@@ -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<Map<string, string>>(new Map());
/**
* Start a new download
*/
const startDownload = useCallback(async (movie: Movie, torrent: Torrent): Promise<string> => {
// 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<void> => {
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<void> => {
// 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<void> => {
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;

View File

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

View File

@@ -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 <MovieDetailsSkeleton />;
@@ -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 */}
<h1 className="text-3xl md:text-5xl font-bold mb-4">{movie.title}</h1>
<h1 className="text-2xl sm:text-3xl md:text-5xl font-bold mb-4 line-clamp-2">{movie.title}</h1>
{/* Meta */}
<div className="flex flex-wrap items-center gap-3 md:gap-4 mb-4">
<div className="flex flex-wrap items-center gap-2 sm:gap-3 md:gap-4 mb-4 text-sm sm:text-base">
<span className="flex items-center gap-1 text-green-400 font-semibold">
<Star size={18} fill="currentColor" />
{movie.rating}/10

View File

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

View File

@@ -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() {
<span className="text-blue-400 font-medium text-sm">TV SERIES</span>
</div>
<h1 className="text-4xl md:text-5xl font-bold mb-4">{show.title}</h1>
<h1 className="text-2xl sm:text-4xl md:text-5xl font-bold mb-4 line-clamp-2">{show.title}</h1>
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-300 mb-4">
<div className="flex flex-wrap items-center gap-2 sm:gap-4 text-xs sm:text-sm text-gray-300 mb-4">
<span className="flex items-center gap-1 text-green-400">
<Star size={16} fill="currentColor" />
{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 */}
<div className="relative flex-shrink-0 w-40 md:w-56 aspect-video rounded overflow-hidden bg-gray-800">
<div className="relative flex-shrink-0 w-full sm:w-40 md:w-56 aspect-video rounded overflow-hidden bg-gray-800">
{episode.still_path ? (
<img
src={`https://image.tmdb.org/t/p/w300${episode.still_path}`}
@@ -459,37 +521,17 @@ export default function TVShowDetails() {
</div>
)}
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity">
{hasTorrents ? (
<button
onClick={() => handlePlayEpisodeClick(selectedSeason, episode.episode_number)}
disabled={isStreaming}
className="p-3 rounded-full bg-white text-black hover:scale-110 transition-transform"
>
{isStreaming ? (
<Loader size={24} className="animate-spin" />
) : (
<Play size={24} fill="currentColor" />
)}
</button>
) : (
<button
onClick={() => loadEpisodeTorrents(selectedSeason, episode.episode_number)}
disabled={isLoadingTorrents}
className="px-4 py-2 rounded-full bg-netflix-red text-white hover:bg-netflix-red/80 transition-colors text-sm font-medium"
>
{isLoadingTorrents ? (
<div className="flex items-center gap-2">
<Loader size={14} className="animate-spin" />
Searching...
</div>
) : (
<div className="flex items-center gap-2">
<Search size={14} />
Find Streams
</div>
)}
</button>
)}
<button
onClick={() => handlePlayEpisodeClick(selectedSeason, episode.episode_number)}
disabled={isStreaming || isLoadingTorrents}
className="p-3 rounded-full bg-white text-black hover:scale-110 transition-transform disabled:opacity-50"
>
{isStreaming || isLoadingTorrents ? (
<Loader size={24} className="animate-spin" />
) : (
<Play size={24} fill="currentColor" />
)}
</button>
</div>
<div className="absolute bottom-2 left-2 bg-black/80 px-2 py-0.5 rounded text-xs">
E{episode.episode_number}
@@ -497,10 +539,10 @@ export default function TVShowDetails() {
</div>
{/* Episode Info */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-4 mb-2">
<div>
<h3 className="font-semibold truncate">
<div className="flex-1 min-w-0 overflow-hidden">
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-2 md:gap-4 mb-2">
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-sm md:text-base line-clamp-2 md:truncate">
{episode.episode_number}. {episode.name}
</h3>
<div className="flex items-center gap-3 text-sm text-gray-400 mt-1">
@@ -522,41 +564,61 @@ export default function TVShowDetails() {
</div>
</div>
{/* Torrent/Play Options */}
{hasTorrents && (
<div className="flex-shrink-0">
<div className="hidden md:flex gap-2">
{currentEpisodeTorrents.slice(0, 3).map((torrent, idx) => (
<button
key={idx}
onClick={() => {
// Filter out fallback torrents
if (!torrent.magnetUrl.startsWith('https://')) {
handlePlayEpisode(selectedSeason, episode.episode_number, torrent);
} else {
handlePlayEpisodeClick(selectedSeason, episode.episode_number);
}
}}
disabled={isStreaming}
className={`px-3 py-1.5 rounded text-xs font-medium transition-colors ${
torrent.quality === '1080p'
? 'bg-green-600 hover:bg-green-700'
: 'bg-blue-600 hover:bg-blue-700'
}`}
>
{torrent.quality}
</button>
))}
</div>
{/* Torrent/Play Options - Always show play button */}
<div className="flex-shrink-0">
{hasTorrents ? (
<>
<div className="hidden md:flex gap-2">
{currentEpisodeTorrents.slice(0, 3).map((torrent, idx) => (
<button
key={idx}
onClick={() => {
// Filter out fallback torrents
if (!torrent.magnetUrl.startsWith('https://')) {
handlePlayEpisode(selectedSeason, episode.episode_number, torrent);
} else {
handlePlayEpisodeClick(selectedSeason, episode.episode_number);
}
}}
disabled={isStreaming}
className={`px-3 py-1.5 rounded text-xs font-medium transition-colors ${
torrent.quality === '1080p'
? 'bg-green-600 hover:bg-green-700'
: 'bg-blue-600 hover:bg-blue-700'
}`}
>
{torrent.quality}
</button>
))}
</div>
<button
onClick={() => handlePlayEpisodeClick(selectedSeason, episode.episode_number)}
disabled={isStreaming}
className="md:hidden px-4 py-2 bg-netflix-red rounded text-sm font-medium"
>
Play
</button>
</>
) : (
<button
onClick={() => handlePlayEpisodeClick(selectedSeason, episode.episode_number)}
disabled={isStreaming}
className="md:hidden px-4 py-2 bg-netflix-red rounded text-sm font-medium"
disabled={isStreaming || isLoadingTorrents}
className="px-4 py-2 bg-netflix-red rounded text-sm font-medium flex items-center gap-2 disabled:opacity-50"
>
Play
{isLoadingTorrents ? (
<>
<Loader size={14} className="animate-spin" />
<span className="hidden sm:inline">Finding...</span>
</>
) : (
<>
<Play size={14} fill="currentColor" />
<span>Play</span>
</>
)}
</button>
</div>
)}
)}
</div>
</div>
<p className="text-sm text-gray-400 line-clamp-2">{episode.overview}</p>

View File

@@ -168,7 +168,7 @@ function ShowRow({
<Link
key={show.id}
to={`/tv/${show.id}`}
className="flex-shrink-0 w-[160px] md:w-[180px] group"
className="flex-shrink-0 w-[130px] sm:w-[160px] md:w-[180px] group"
>
<div className="relative aspect-[2/3] rounded-lg overflow-hidden mb-2 bg-gray-800">
{show.poster ? (
@@ -196,11 +196,11 @@ function ShowRow({
</div>
)}
</div>
<h3 className="font-medium text-sm truncate group-hover:text-white transition-colors">
<h3 className="font-medium text-xs sm:text-sm truncate group-hover:text-white transition-colors">
{show.title}
</h3>
<p className="text-xs text-gray-400">
{show.year} {show.seasonCount ? `${show.seasonCount} Season${show.seasonCount !== 1 ? 's' : ''}` : ''}
<p className="text-xs text-gray-400 truncate">
{show.year} {show.seasonCount ? `${show.seasonCount}S` : ''}
</p>
</Link>
))}
@@ -301,7 +301,7 @@ export default function TVShows() {
</div>
{/* Content */}
<div className={trending.length > 0 && !isShowingSearch ? '-mt-32 relative z-10 pt-8' : 'pt-4'}>
<div className={trending.length > 0 && !isShowingSearch ? '-mt-32 relative z-10 pt-32' : 'pt-4'}>
{isShowingSearch ? (
// Search Results
<div className="px-4 md:px-8 pb-16">

105
src/plugins/ExoPlayer.ts Normal file
View File

@@ -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<ExoPlayerPlayResult>;
/**
* Check if ExoPlayer is available on this platform
* @returns Promise with availability info
*/
isAvailable(): Promise<ExoPlayerAvailability>;
}
/**
* ExoPlayer Capacitor plugin
* Provides native video playback with full codec support on Android
*/
const ExoPlayerPlugin = registerPlugin<ExoPlayerPluginInterface>('ExoPlayerPlugin', {
web: () => import('./ExoPlayer.web').then(m => new m.ExoPlayerWeb()),
});
/**
* Check if native video player is available
*/
export const isNativePlayerAvailable = async (): Promise<boolean> => {
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<ExoPlayerPlayResult | null> => {
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;

View File

@@ -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<ExoPlayerPlayResult> {
// On web, native player is not available
return {
success: false,
position: 0,
duration: 0,
completed: false,
};
}
async isAvailable(): Promise<ExoPlayerAvailability> {
return {
available: false,
platform: 'web',
};
}
}

View File

@@ -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 [];

View File

@@ -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<string, {
sessionId: string;
callback: (data: DownloadSession) => void;
}> = new Map();
private reconnectAttempts = 0;
private maxReconnectAttempts = 5;
/**
* Connect to WebSocket for download progress updates
*/
private async connectWebSocket(): Promise<void> {
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<void> {
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<DownloadSession | null> {
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<string | null> {
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;

View File

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