Fixes on downloading and series episode lookup.
This commit is contained in:
@@ -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 });
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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) && (
|
||||
|
||||
173
src/hooks/useDownloadManager.ts
Normal file
173
src/hooks/useDownloadManager.ts
Normal 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;
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
105
src/plugins/ExoPlayer.ts
Normal 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;
|
||||
28
src/plugins/ExoPlayer.web.ts
Normal file
28
src/plugins/ExoPlayer.web.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
|
||||
229
src/services/download/downloadService.ts
Normal file
229
src/services/download/downloadService.ts
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user