Download location is managed by your operating system's default
diff --git a/src/pages/TVPlayer.tsx b/src/pages/TVPlayer.tsx
index ec2fcf9..f1b01ff 100644
--- a/src/pages/TVPlayer.tsx
+++ b/src/pages/TVPlayer.tsx
@@ -5,6 +5,8 @@ import { Loader2, AlertCircle, Server, RefreshCw } from 'lucide-react';
import StreamingPlayer from '../components/player/StreamingPlayer';
import NextEpisodeOverlay from '../components/player/NextEpisodeOverlay';
import streamingService, { type StreamSession } from '../services/streaming/streamingService';
+import { useSettingsStore } from '../stores/settingsStore';
+import { useHistoryStore } from '../stores/historyStore';
import { getApiUrl } from '../utils/platform';
import Button from '../components/ui/Button';
import type { Movie } from '../types';
@@ -68,6 +70,17 @@ export default function TVPlayer() {
const [hlsUrl, setHlsUrl] = useState(null);
const [status, setStatus] = useState<'checking' | 'connecting' | 'buffering' | 'ready' | 'error'>('checking');
const [error, setError] = useState(null);
+
+ // Settings
+ const { settings } = useSettingsStore();
+
+ // History for progress tracking
+ const { addToHistory, updateProgress } = useHistoryStore();
+
+ // Refs for cleanup (to avoid stale closures)
+ const sessionIdRef = useRef(null);
+ const autoDeleteRef = useRef(settings.autoDeleteStreams);
+ autoDeleteRef.current = settings.autoDeleteStreams;
// Next episode state
const [showNextEpisode, setShowNextEpisode] = useState(false);
@@ -137,6 +150,7 @@ export default function TVPlayer() {
}
setSessionId(result.sessionId);
+ sessionIdRef.current = result.sessionId;
// Get initial status immediately
const initialStatus = await streamingService.getStatus(result.sessionId);
@@ -179,6 +193,39 @@ export default function TVPlayer() {
setStreamUrl(streamingService.getVideoUrl(result.sessionId));
setStatus('ready');
+ // Add to history for progress tracking (use showId with negative to distinguish from movies)
+ // Create a unique ID combining show ID, season, and episode
+ const episodeId = parseInt(showId || '0') * 10000 + season * 100 + episode;
+ const historyEntry = {
+ id: episodeId,
+ url: '',
+ imdb_code: '',
+ title: `${showTitle} - S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}`,
+ title_english: episodeTitle || `Season ${season}, Episode ${episode}`,
+ title_long: `${showTitle} - ${episodeTitle || `Season ${season}, Episode ${episode}`}`,
+ slug: '',
+ year: new Date().getFullYear(),
+ rating: 0,
+ runtime: 0,
+ genres: [],
+ summary: '',
+ description_full: '',
+ synopsis: '',
+ yt_trailer_code: '',
+ language: 'en',
+ mpa_rating: '',
+ background_image: backdrop ? decodeURIComponent(backdrop) : '',
+ background_image_original: backdrop ? decodeURIComponent(backdrop) : '',
+ small_cover_image: poster ? decodeURIComponent(poster) : '',
+ medium_cover_image: poster ? decodeURIComponent(poster) : '',
+ large_cover_image: poster ? decodeURIComponent(poster) : '',
+ state: '',
+ torrents: [],
+ date_uploaded: new Date().toISOString(),
+ date_uploaded_unix: Math.floor(Date.now() / 1000),
+ };
+ addToHistory(historyEntry);
+
// Try to start HLS transcoding for better compatibility
// Wait longer for video file to be fully available on disk
const attemptHlsStart = async (attempt = 1, maxAttempts = 5) => {
@@ -234,12 +281,14 @@ export default function TVPlayer() {
// Cleanup on unmount
return () => {
- if (sessionId) {
- streamingService.stopStream(sessionId).catch(console.error);
+ // Use ref to get current sessionId (fixes stale closure issue)
+ if (sessionIdRef.current) {
+ // Auto-delete files if setting is enabled
+ streamingService.stopStream(sessionIdRef.current, autoDeleteRef.current).catch(console.error);
streamingService.disconnect();
}
};
- }, [status, torrentHash, showTitle, season, episode, episodeTitle, quality]);
+ }, [status, torrentHash, showTitle, season, episode, episodeTitle, quality, showId, poster, backdrop, addToHistory]);
// Get next episode info
const getNextEpisodeInfo = useCallback(async () => {
@@ -394,6 +443,12 @@ export default function TVPlayer() {
(currentTime: number, duration: number) => {
videoCurrentTimeRef.current = currentTime;
videoDurationRef.current = duration;
+
+ // Save progress to history store
+ if (currentTime > 0 && duration > 0) {
+ const episodeId = parseInt(showId || '0') * 10000 + season * 100 + episode;
+ updateProgress(episodeId, currentTime, duration);
+ }
// Check if we're in the last 30 seconds
const timeRemaining = duration - currentTime;
@@ -401,11 +456,14 @@ export default function TVPlayer() {
const SHOW_OVERLAY_THRESHOLD = 10; // Show overlay 10 seconds before end
if (timeRemaining <= PRELOAD_THRESHOLD && !nextEpisodeInfo && status === 'ready') {
- // Start loading next episode info and pre-loading
+ // Start loading next episode info
getNextEpisodeInfo().then((info) => {
if (info) {
setNextEpisodeInfo(info);
- preloadNextEpisode(info);
+ // Only pre-load if smart downloads is enabled
+ if (settings.autoDownloadNextEpisode) {
+ preloadNextEpisode(info);
+ }
}
});
}
@@ -440,7 +498,7 @@ export default function TVPlayer() {
handlePlayNext();
}
},
- [nextEpisodeInfo, status, showNextEpisode, countdown, getNextEpisodeInfo, preloadNextEpisode, handlePlayNext]
+ [nextEpisodeInfo, status, showNextEpisode, countdown, getNextEpisodeInfo, preloadNextEpisode, handlePlayNext, showId, season, episode, updateProgress, settings.autoDownloadNextEpisode]
);
// Handle cancel next episode
@@ -477,6 +535,7 @@ export default function TVPlayer() {
setError(null);
setStatus('checking');
setSessionId(null);
+ sessionIdRef.current = null;
setStreamSession(null);
setStreamUrl(null);
setHlsUrl(null);
diff --git a/src/services/cast/capacitorCast.ts b/src/services/cast/capacitorCast.ts
new file mode 100644
index 0000000..14e422b
--- /dev/null
+++ b/src/services/cast/capacitorCast.ts
@@ -0,0 +1,101 @@
+// Capacitor plugin interface for Google Cast on Android
+// This bridges to a native Android plugin implementation
+
+import { registerPlugin } from '@capacitor/core';
+
+export interface LoadMediaOptions {
+ url: string;
+ contentType: string;
+ title: string;
+ subtitle?: string;
+ imageUrl?: string;
+ startTime?: number;
+}
+
+export interface SeekOptions {
+ time: number;
+}
+
+export interface VolumeOptions {
+ level: number;
+}
+
+export interface CastDevice {
+ id: string;
+ name: string;
+ isConnected: boolean;
+}
+
+export interface CastStateChangeEvent {
+ state: 'unavailable' | 'available' | 'connecting' | 'connected';
+}
+
+export interface DeviceAvailableEvent {
+ devices: CastDevice[];
+}
+
+export interface SessionStartedEvent {
+ device: CastDevice;
+}
+
+export interface MediaStatusEvent {
+ playerState: 'idle' | 'playing' | 'paused' | 'buffering';
+ currentTime: number;
+ duration: number;
+}
+
+export interface CapacitorGoogleCastPlugin {
+ // Initialization
+ initialize(): Promise;
+
+ // Session management
+ showCastDialog(): Promise;
+ endSession(): Promise;
+
+ // Media control
+ loadMedia(options: LoadMediaOptions): Promise;
+ play(): Promise;
+ pause(): Promise;
+ stop(): Promise;
+ seek(options: SeekOptions): Promise;
+ setVolume(options: VolumeOptions): Promise;
+
+ // Event listeners
+ addListener(
+ eventName: 'castStateChanged',
+ listenerFunc: (event: CastStateChangeEvent) => void
+ ): Promise<{ remove: () => void }>;
+
+ addListener(
+ eventName: 'deviceAvailable',
+ listenerFunc: (event: DeviceAvailableEvent) => void
+ ): Promise<{ remove: () => void }>;
+
+ addListener(
+ eventName: 'sessionStarted',
+ listenerFunc: (event: SessionStartedEvent) => void
+ ): Promise<{ remove: () => void }>;
+
+ addListener(
+ eventName: 'sessionEnded',
+ listenerFunc: () => void
+ ): Promise<{ remove: () => void }>;
+
+ addListener(
+ eventName: 'mediaStatusUpdated',
+ listenerFunc: (event: MediaStatusEvent) => void
+ ): Promise<{ remove: () => void }>;
+
+ removeAllListeners(): Promise;
+}
+
+// Register the plugin
+// This will be available when the native Android plugin is implemented
+export const CapacitorGoogleCast = registerPlugin(
+ 'CapacitorGoogleCast',
+ {
+ // Web implementation fallback (no-op for web since we use the Cast SDK directly)
+ web: () =>
+ import('./capacitorCastWeb').then((m) => new m.CapacitorGoogleCastWeb()),
+ }
+);
diff --git a/src/services/cast/capacitorCastWeb.ts b/src/services/cast/capacitorCastWeb.ts
new file mode 100644
index 0000000..32bad1b
--- /dev/null
+++ b/src/services/cast/capacitorCastWeb.ts
@@ -0,0 +1,45 @@
+// Web implementation fallback for the Capacitor Google Cast plugin
+// On web, we use the Cast SDK directly through castService, so this is mostly a no-op
+
+import { WebPlugin } from '@capacitor/core';
+import type { CapacitorGoogleCastPlugin, LoadMediaOptions, SeekOptions, VolumeOptions } from './capacitorCast';
+
+export class CapacitorGoogleCastWeb extends WebPlugin implements CapacitorGoogleCastPlugin {
+ async initialize(): Promise {
+ // No-op on web - Cast SDK is initialized in castService
+ console.log('[CapacitorGoogleCast Web] No-op initialize');
+ }
+
+ async showCastDialog(): Promise {
+ // No-op - handled by castService on web
+ console.log('[CapacitorGoogleCast Web] No-op showCastDialog');
+ }
+
+ async endSession(): Promise {
+ console.log('[CapacitorGoogleCast Web] No-op endSession');
+ }
+
+ async loadMedia(_options: LoadMediaOptions): Promise {
+ console.log('[CapacitorGoogleCast Web] No-op loadMedia');
+ }
+
+ async play(): Promise {
+ console.log('[CapacitorGoogleCast Web] No-op play');
+ }
+
+ async pause(): Promise {
+ console.log('[CapacitorGoogleCast Web] No-op pause');
+ }
+
+ async stop(): Promise {
+ console.log('[CapacitorGoogleCast Web] No-op stop');
+ }
+
+ async seek(_options: SeekOptions): Promise {
+ console.log('[CapacitorGoogleCast Web] No-op seek');
+ }
+
+ async setVolume(_options: VolumeOptions): Promise {
+ console.log('[CapacitorGoogleCast Web] No-op setVolume');
+ }
+}
diff --git a/src/services/cast/castService.ts b/src/services/cast/castService.ts
new file mode 100644
index 0000000..54ebde0
--- /dev/null
+++ b/src/services/cast/castService.ts
@@ -0,0 +1,458 @@
+// Google Cast Service for casting to Chromecast devices
+// Uses Default Media Receiver for standard playback
+
+export type CastState = 'unavailable' | 'available' | 'connecting' | 'connected';
+
+interface CastDevice {
+ id: string;
+ name: string;
+ isConnected: boolean;
+}
+
+interface MediaInfo {
+ contentId: string; // Stream URL
+ contentType: string;
+ title: string;
+ subtitle?: string;
+ imageUrl?: string;
+ duration?: number;
+ currentTime?: number;
+}
+
+interface CastSession {
+ device: CastDevice;
+ media: MediaInfo | null;
+ playerState: 'idle' | 'playing' | 'paused' | 'buffering';
+ currentTime: number;
+ duration: number;
+ volume: number;
+ isMuted: boolean;
+}
+
+type CastStateListener = (state: CastState, session: CastSession | null) => void;
+type CastMediaListener = (playerState: string, currentTime: number, duration: number) => void;
+
+class CastService {
+ private state: CastState = 'unavailable';
+ private session: CastSession | null = null;
+ private stateListeners: Set = new Set();
+ private mediaListeners: Set = new Set();
+ private castContext: any = null;
+ private playerController: any = null;
+ private initialized = false;
+
+ constructor() {
+ // Check if we're on Android with Capacitor
+ if (this.isCapacitorAndroid()) {
+ this.initializeAndroid();
+ } else if (typeof window !== 'undefined' && 'chrome' in window) {
+ // Web Chrome browser - use Google Cast SDK
+ this.initializeWeb();
+ }
+ }
+
+ private isCapacitorAndroid(): boolean {
+ return typeof (window as any).Capacitor !== 'undefined' &&
+ (window as any).Capacitor?.getPlatform() === 'android';
+ }
+
+ private async initializeAndroid(): Promise {
+ // For Android, we use a Capacitor plugin bridge
+ // This will be implemented via a custom Capacitor plugin
+ try {
+ const { CapacitorGoogleCast } = await import('./capacitorCast');
+ if (CapacitorGoogleCast) {
+ await CapacitorGoogleCast.initialize();
+
+ // Listen for cast state changes
+ CapacitorGoogleCast.addListener('castStateChanged', (data: { state: CastState }) => {
+ this.state = data.state;
+ this.notifyStateListeners();
+ });
+
+ // Listen for device discovery
+ CapacitorGoogleCast.addListener('deviceAvailable', (data: { devices: CastDevice[] }) => {
+ if (data.devices.length > 0) {
+ this.state = 'available';
+ this.notifyStateListeners();
+ }
+ });
+
+ // Listen for connection events
+ CapacitorGoogleCast.addListener('sessionStarted', (data: { device: CastDevice }) => {
+ this.state = 'connected';
+ this.session = {
+ device: data.device,
+ media: null,
+ playerState: 'idle',
+ currentTime: 0,
+ duration: 0,
+ volume: 1,
+ isMuted: false,
+ };
+ this.notifyStateListeners();
+ });
+
+ CapacitorGoogleCast.addListener('sessionEnded', () => {
+ this.state = 'available';
+ this.session = null;
+ this.notifyStateListeners();
+ });
+
+ // Listen for media status updates
+ CapacitorGoogleCast.addListener('mediaStatusUpdated', (data: any) => {
+ if (this.session) {
+ this.session.playerState = data.playerState;
+ this.session.currentTime = data.currentTime;
+ this.session.duration = data.duration;
+ }
+ this.notifyMediaListeners(data.playerState, data.currentTime, data.duration);
+ });
+
+ this.initialized = true;
+ console.log('[Cast] Android Cast service initialized');
+ }
+ } catch (error) {
+ console.log('[Cast] Android Cast plugin not available:', error);
+ }
+ }
+
+ private initializeWeb(): void {
+ // For web, use the Cast Framework SDK
+ // Check if the Cast SDK is loaded
+ const checkCastApi = () => {
+ if ((window as any).cast && (window as any).chrome?.cast) {
+ this.setupCastFramework();
+ } else {
+ // Load the Cast SDK
+ const script = document.createElement('script');
+ script.src = 'https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1';
+ script.onload = () => this.setupCastFramework();
+ document.head.appendChild(script);
+ }
+ };
+
+ // Wait for DOM ready
+ if (document.readyState === 'complete') {
+ checkCastApi();
+ } else {
+ window.addEventListener('load', checkCastApi);
+ }
+ }
+
+ private setupCastFramework(): void {
+ try {
+ const cast = (window as any).cast;
+ const chrome = (window as any).chrome;
+
+ if (!cast?.framework || !chrome?.cast) {
+ console.log('[Cast] Cast framework not available');
+ return;
+ }
+
+ // Initialize the Cast API
+ cast.framework.CastContext.getInstance().setOptions({
+ receiverApplicationId: chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID,
+ autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED,
+ });
+
+ this.castContext = cast.framework.CastContext.getInstance();
+ this.playerController = new cast.framework.RemotePlayerController(
+ new cast.framework.RemotePlayer()
+ );
+
+ // Listen for cast state changes
+ this.castContext.addEventListener(
+ cast.framework.CastContextEventType.CAST_STATE_CHANGED,
+ (event: any) => {
+ switch (event.castState) {
+ case 'NO_DEVICES_AVAILABLE':
+ this.state = 'unavailable';
+ break;
+ case 'NOT_CONNECTED':
+ this.state = 'available';
+ break;
+ case 'CONNECTING':
+ this.state = 'connecting';
+ break;
+ case 'CONNECTED':
+ this.state = 'connected';
+ break;
+ }
+ this.notifyStateListeners();
+ }
+ );
+
+ // Listen for session state changes
+ this.castContext.addEventListener(
+ cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
+ (event: any) => {
+ if (event.sessionState === 'SESSION_STARTED' ||
+ event.sessionState === 'SESSION_RESUMED') {
+ const session = this.castContext.getCurrentSession();
+ if (session) {
+ const device = session.getCastDevice();
+ this.session = {
+ device: {
+ id: device.deviceId,
+ name: device.friendlyName,
+ isConnected: true,
+ },
+ media: null,
+ playerState: 'idle',
+ currentTime: 0,
+ duration: 0,
+ volume: 1,
+ isMuted: false,
+ };
+ }
+ } else if (event.sessionState === 'SESSION_ENDED') {
+ this.session = null;
+ }
+ this.notifyStateListeners();
+ }
+ );
+
+ // Listen for player changes
+ this.playerController.addEventListener(
+ cast.framework.RemotePlayerEventType.ANY_CHANGE,
+ (_event: any) => {
+ if (this.session) {
+ const player = this.playerController.remotePlayer;
+ this.session.currentTime = player.currentTime;
+ this.session.duration = player.duration;
+ this.session.volume = player.volumeLevel;
+ this.session.isMuted = player.isMuted;
+
+ if (player.playerState) {
+ this.session.playerState = player.playerState.toLowerCase();
+ }
+
+ this.notifyMediaListeners(
+ this.session.playerState,
+ this.session.currentTime,
+ this.session.duration
+ );
+ }
+ }
+ );
+
+ this.initialized = true;
+ console.log('[Cast] Web Cast service initialized');
+ } catch (error) {
+ console.error('[Cast] Failed to initialize Cast framework:', error);
+ }
+ }
+
+ // Public API
+
+ get isAvailable(): boolean {
+ return this.state !== 'unavailable';
+ }
+
+ get isConnected(): boolean {
+ return this.state === 'connected';
+ }
+
+ get currentState(): CastState {
+ return this.state;
+ }
+
+ get currentSession(): CastSession | null {
+ return this.session;
+ }
+
+ addStateListener(listener: CastStateListener): () => void {
+ this.stateListeners.add(listener);
+ // Immediately notify with current state
+ listener(this.state, this.session);
+ return () => this.stateListeners.delete(listener);
+ }
+
+ addMediaListener(listener: CastMediaListener): () => void {
+ this.mediaListeners.add(listener);
+ return () => this.mediaListeners.delete(listener);
+ }
+
+ private notifyStateListeners(): void {
+ this.stateListeners.forEach((listener) => {
+ listener(this.state, this.session);
+ });
+ }
+
+ private notifyMediaListeners(playerState: string, currentTime: number, duration: number): void {
+ this.mediaListeners.forEach((listener) => {
+ listener(playerState, currentTime, duration);
+ });
+ }
+
+ async requestSession(): Promise {
+ if (!this.initialized) {
+ console.log('[Cast] Service not initialized');
+ return false;
+ }
+
+ if (this.isCapacitorAndroid()) {
+ try {
+ const { CapacitorGoogleCast } = await import('./capacitorCast');
+ await CapacitorGoogleCast.showCastDialog();
+ return true;
+ } catch (error) {
+ console.error('[Cast] Failed to show cast dialog:', error);
+ return false;
+ }
+ }
+
+ // Web implementation
+ if (this.castContext) {
+ try {
+ await this.castContext.requestSession();
+ return true;
+ } catch (error) {
+ console.log('[Cast] Session request failed:', error);
+ return false;
+ }
+ }
+
+ return false;
+ }
+
+ async endSession(): Promise {
+ if (this.isCapacitorAndroid()) {
+ try {
+ const { CapacitorGoogleCast } = await import('./capacitorCast');
+ await CapacitorGoogleCast.endSession();
+ } catch (error) {
+ console.error('[Cast] Failed to end session:', error);
+ }
+ return;
+ }
+
+ if (this.castContext) {
+ const session = this.castContext.getCurrentSession();
+ if (session) {
+ session.endSession(true);
+ }
+ }
+ }
+
+ async loadMedia(media: MediaInfo): Promise {
+ if (!this.isConnected || !this.session) {
+ console.log('[Cast] Not connected');
+ return false;
+ }
+
+ if (this.isCapacitorAndroid()) {
+ try {
+ const { CapacitorGoogleCast } = await import('./capacitorCast');
+ await CapacitorGoogleCast.loadMedia({
+ url: media.contentId,
+ contentType: media.contentType,
+ title: media.title,
+ subtitle: media.subtitle || '',
+ imageUrl: media.imageUrl || '',
+ startTime: media.currentTime || 0,
+ });
+ this.session.media = media;
+ return true;
+ } catch (error) {
+ console.error('[Cast] Failed to load media:', error);
+ return false;
+ }
+ }
+
+ // Web implementation
+ const chrome = (window as any).chrome;
+ const cast = (window as any).cast;
+ if (!chrome?.cast || !cast?.framework) return false;
+
+ try {
+ const session = this.castContext.getCurrentSession();
+ if (!session) return false;
+
+ const mediaInfo = new chrome.cast.media.MediaInfo(media.contentId, media.contentType);
+ mediaInfo.metadata = new chrome.cast.media.GenericMediaMetadata();
+ mediaInfo.metadata.title = media.title;
+ mediaInfo.metadata.subtitle = media.subtitle;
+ if (media.imageUrl) {
+ mediaInfo.metadata.images = [{ url: media.imageUrl }];
+ }
+
+ const request = new chrome.cast.media.LoadRequest(mediaInfo);
+ request.currentTime = media.currentTime || 0;
+ request.autoplay = true;
+
+ await session.loadMedia(request);
+ this.session.media = media;
+ return true;
+ } catch (error) {
+ console.error('[Cast] Failed to load media:', error);
+ return false;
+ }
+ }
+
+ async play(): Promise {
+ if (this.isCapacitorAndroid()) {
+ const { CapacitorGoogleCast } = await import('./capacitorCast');
+ await CapacitorGoogleCast.play();
+ return;
+ }
+
+ if (this.playerController) {
+ this.playerController.playOrPause();
+ }
+ }
+
+ async pause(): Promise {
+ if (this.isCapacitorAndroid()) {
+ const { CapacitorGoogleCast } = await import('./capacitorCast');
+ await CapacitorGoogleCast.pause();
+ return;
+ }
+
+ if (this.playerController) {
+ this.playerController.playOrPause();
+ }
+ }
+
+ async seek(time: number): Promise {
+ if (this.isCapacitorAndroid()) {
+ const { CapacitorGoogleCast } = await import('./capacitorCast');
+ await CapacitorGoogleCast.seek({ time });
+ return;
+ }
+
+ if (this.playerController?.remotePlayer) {
+ this.playerController.remotePlayer.currentTime = time;
+ this.playerController.seek();
+ }
+ }
+
+ async setVolume(level: number): Promise {
+ if (this.isCapacitorAndroid()) {
+ const { CapacitorGoogleCast } = await import('./capacitorCast');
+ await CapacitorGoogleCast.setVolume({ level });
+ return;
+ }
+
+ if (this.playerController?.remotePlayer) {
+ this.playerController.remotePlayer.volumeLevel = level;
+ this.playerController.setVolumeLevel();
+ }
+ }
+
+ async stop(): Promise {
+ if (this.isCapacitorAndroid()) {
+ const { CapacitorGoogleCast } = await import('./capacitorCast');
+ await CapacitorGoogleCast.stop();
+ return;
+ }
+
+ if (this.playerController) {
+ this.playerController.stop();
+ }
+ }
+}
+
+// Singleton instance
+export const castService = new CastService();
diff --git a/src/services/recommendations/recommendationService.ts b/src/services/recommendations/recommendationService.ts
new file mode 100644
index 0000000..1783526
--- /dev/null
+++ b/src/services/recommendations/recommendationService.ts
@@ -0,0 +1,165 @@
+import { ytsApi } from '../api/yts';
+import type { Movie } from '../../types';
+import { useHistoryStore } from '../../stores/historyStore';
+
+interface GenreScore {
+ genre: string;
+ score: number;
+ count: number;
+}
+
+interface RecommendationResult {
+ movies: Movie[];
+ basedOnGenres: string[];
+}
+
+// Analyze watch history to determine preferred genres
+function analyzeGenrePreferences(historyMovies: Movie[]): GenreScore[] {
+ const genreScores: Map = new Map();
+
+ historyMovies.forEach((movie, index) => {
+ // More recent movies get higher weight (1.0 for most recent, decreasing)
+ const recencyWeight = 1.0 - (index * 0.1);
+ const weight = Math.max(recencyWeight, 0.3); // Minimum weight of 0.3
+
+ movie.genres?.forEach((genre) => {
+ const current = genreScores.get(genre) || { score: 0, count: 0 };
+ genreScores.set(genre, {
+ score: current.score + weight,
+ count: current.count + 1,
+ });
+ });
+ });
+
+ // Convert to array and sort by score
+ return Array.from(genreScores.entries())
+ .map(([genre, data]) => ({
+ genre,
+ score: data.score,
+ count: data.count,
+ }))
+ .sort((a, b) => b.score - a.score);
+}
+
+// Get personalized recommendations based on watch history
+export async function getPersonalizedRecommendations(
+ historyMovies: Movie[],
+ excludeIds: Set,
+ limit = 20
+): Promise {
+ if (historyMovies.length === 0) {
+ return { movies: [], basedOnGenres: [] };
+ }
+
+ const genrePreferences = analyzeGenrePreferences(historyMovies);
+ const topGenres = genrePreferences.slice(0, 3).map((g) => g.genre);
+
+ if (topGenres.length === 0) {
+ return { movies: [], basedOnGenres: [] };
+ }
+
+ // Fetch movies for each top genre
+ const genrePromises = topGenres.map((genre) =>
+ ytsApi.getByGenre(genre.toLowerCase(), 15).catch(() => ({ movies: [] }))
+ );
+
+ const genreResults = await Promise.all(genrePromises);
+
+ // Combine and deduplicate movies
+ const movieMap = new Map();
+ const movieScores = new Map();
+
+ genreResults.forEach((result, genreIndex) => {
+ result.movies.forEach((movie) => {
+ if (excludeIds.has(movie.id)) return; // Skip already watched
+
+ if (!movieMap.has(movie.id)) {
+ movieMap.set(movie.id, movie);
+ movieScores.set(movie.id, 0);
+ }
+
+ // Score based on genre preference rank
+ const genreWeight = 1.0 - genreIndex * 0.2;
+ const ratingBonus = (movie.rating || 0) / 20; // Up to 0.5 bonus for high-rated
+ movieScores.set(
+ movie.id,
+ (movieScores.get(movie.id) || 0) + genreWeight + ratingBonus
+ );
+ });
+ });
+
+ // Sort by score and return top recommendations
+ const sortedMovies = Array.from(movieMap.values())
+ .sort((a, b) => (movieScores.get(b.id) || 0) - (movieScores.get(a.id) || 0))
+ .slice(0, limit);
+
+ return {
+ movies: sortedMovies,
+ basedOnGenres: topGenres,
+ };
+}
+
+// Get "Because You Watched" recommendations for a specific movie
+export async function getBecauseYouWatched(
+ movie: Movie,
+ excludeIds: Set,
+ limit = 10
+): Promise {
+ try {
+ // First try the YTS suggestions API
+ const suggestions = await ytsApi.getMovieSuggestions(movie.id);
+
+ if (suggestions.movies.length > 0) {
+ return suggestions.movies
+ .filter((m) => !excludeIds.has(m.id))
+ .slice(0, limit);
+ }
+
+ // Fallback: search by genre
+ if (movie.genres && movie.genres.length > 0) {
+ const primaryGenre = movie.genres[0];
+ const result = await ytsApi.getByGenre(primaryGenre.toLowerCase(), limit * 2);
+ return result.movies
+ .filter((m) => !excludeIds.has(m.id) && m.id !== movie.id)
+ .slice(0, limit);
+ }
+
+ return [];
+ } catch (error) {
+ console.error('Error getting recommendations:', error);
+ return [];
+ }
+}
+
+// Hook for using recommendations in components
+export function useRecommendationsService() {
+ const { getProfileItems } = useHistoryStore();
+
+ const getRecommendations = async (limit = 20): Promise => {
+ const historyItems = getProfileItems();
+ const historyMovies = historyItems.map((item) => item.movie);
+ const excludeIds = new Set(historyItems.map((item) => item.movieId));
+
+ return getPersonalizedRecommendations(historyMovies, excludeIds, limit);
+ };
+
+ const getBecauseWatched = async (movieId: number, limit = 10): Promise<{ movie: Movie; recommendations: Movie[] } | null> => {
+ const historyItems = getProfileItems();
+ const historyItem = historyItems.find((item) => item.movieId === movieId);
+
+ if (!historyItem) return null;
+
+ const excludeIds = new Set(historyItems.map((item) => item.movieId));
+ const recommendations = await getBecauseYouWatched(historyItem.movie, excludeIds, limit);
+
+ return {
+ movie: historyItem.movie,
+ recommendations,
+ };
+ };
+
+ return {
+ getRecommendations,
+ getBecauseWatched,
+ };
+}
diff --git a/src/services/streaming/streamingService.ts b/src/services/streaming/streamingService.ts
index ae83e22..abbda4d 100644
--- a/src/services/streaming/streamingService.ts
+++ b/src/services/streaming/streamingService.ts
@@ -206,10 +206,14 @@ class StreamingService {
/**
* Stop streaming session
+ * @param sessionId - Session to stop
+ * @param cleanupFiles - If true, delete downloaded files
*/
- async stopStream(sessionId: string): Promise {
+ async stopStream(sessionId: string, cleanupFiles: boolean = false): Promise {
const apiUrl = getApiUrlValue();
- await axios.delete(`${apiUrl}/api/stream/${sessionId}`);
+ await axios.delete(`${apiUrl}/api/stream/${sessionId}`, {
+ params: { cleanup: cleanupFiles },
+ });
}
/**
diff --git a/src/stores/historyStore.ts b/src/stores/historyStore.ts
index 8465711..ff50db6 100644
--- a/src/stores/historyStore.ts
+++ b/src/stores/historyStore.ts
@@ -1,27 +1,39 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { Movie, HistoryItem } from '../types';
+import { useProfileStore } from './profileStore';
+
+interface HistoryItemWithProfile extends HistoryItem {
+ profileId: string;
+}
interface HistoryState {
- items: HistoryItem[];
+ items: HistoryItemWithProfile[];
addToHistory: (movie: Movie, progress?: number, duration?: number) => void;
updateProgress: (movieId: number, progress: number, duration: number) => void;
markAsCompleted: (movieId: number) => void;
removeFromHistory: (movieId: number) => void;
- getProgress: (movieId: number) => HistoryItem | undefined;
+ getProgress: (movieId: number) => HistoryItemWithProfile | undefined;
+ getProfileItems: () => HistoryItemWithProfile[];
+ getContinueWatching: () => HistoryItemWithProfile[];
clearHistory: () => void;
}
+const getActiveProfileId = () => useProfileStore.getState().activeProfileId || 'default';
+
export const useHistoryStore = create()(
persist(
(set, get) => ({
items: [],
addToHistory: (movie, progress = 0, duration = 0) => {
- const existing = get().items.find((item) => item.movieId === movie.id);
+ const profileId = getActiveProfileId();
+ const existing = get().items.find(
+ (item) => item.movieId === movie.id && item.profileId === profileId
+ );
if (existing) {
set((state) => ({
items: state.items.map((item) =>
- item.movieId === movie.id
+ item.movieId === movie.id && item.profileId === profileId
? { ...item, watchedAt: new Date(), progress, duration }
: item
),
@@ -37,39 +49,77 @@ export const useHistoryStore = create()(
progress,
duration,
completed: false,
+ profileId,
},
...state.items,
],
}));
}
},
- updateProgress: (movieId, progress, duration) =>
+ updateProgress: (movieId, progress, duration) => {
+ const profileId = getActiveProfileId();
set((state) => ({
items: state.items.map((item) =>
- item.movieId === movieId
+ item.movieId === movieId && item.profileId === profileId
? {
...item,
progress,
duration,
- completed: progress / duration > 0.9,
+ completed: duration > 0 ? progress / duration > 0.9 : false,
watchedAt: new Date(),
}
: item
),
- })),
- markAsCompleted: (movieId) =>
+ }));
+ },
+ markAsCompleted: (movieId) => {
+ const profileId = getActiveProfileId();
set((state) => ({
items: state.items.map((item) =>
- item.movieId === movieId ? { ...item, completed: true } : item
+ item.movieId === movieId && item.profileId === profileId
+ ? { ...item, completed: true }
+ : item
),
- })),
- removeFromHistory: (movieId) =>
+ }));
+ },
+ removeFromHistory: (movieId) => {
+ const profileId = getActiveProfileId();
set((state) => ({
- items: state.items.filter((item) => item.movieId !== movieId),
- })),
- getProgress: (movieId) =>
- get().items.find((item) => item.movieId === movieId),
- clearHistory: () => set({ items: [] }),
+ items: state.items.filter(
+ (item) => !(item.movieId === movieId && item.profileId === profileId)
+ ),
+ }));
+ },
+ getProgress: (movieId) => {
+ const profileId = getActiveProfileId();
+ return get().items.find(
+ (item) => item.movieId === movieId && item.profileId === profileId
+ );
+ },
+ getProfileItems: () => {
+ const profileId = getActiveProfileId();
+ return get().items.filter((item) => item.profileId === profileId);
+ },
+ getContinueWatching: () => {
+ const profileId = getActiveProfileId();
+ return get()
+ .items.filter((item) => {
+ if (item.profileId !== profileId) return false;
+ if (item.completed) return false;
+ if (!item.duration || item.duration === 0) return false;
+ const progressPercent = item.progress / item.duration;
+ // Show items between 5% and 90% progress
+ return progressPercent >= 0.05 && progressPercent < 0.9;
+ })
+ .sort((a, b) => new Date(b.watchedAt).getTime() - new Date(a.watchedAt).getTime())
+ .slice(0, 10); // Limit to 10 items
+ },
+ clearHistory: () => {
+ const profileId = getActiveProfileId();
+ set((state) => ({
+ items: state.items.filter((item) => item.profileId !== profileId),
+ }));
+ },
}),
{
name: 'bestream-history',
diff --git a/src/stores/profileStore.ts b/src/stores/profileStore.ts
new file mode 100644
index 0000000..b41db52
--- /dev/null
+++ b/src/stores/profileStore.ts
@@ -0,0 +1,121 @@
+import { create } from 'zustand';
+import { persist } from 'zustand/middleware';
+import type { Profile, AvatarId } from '../types';
+
+const MAX_PROFILES = 5;
+
+interface ProfileState {
+ profiles: Profile[];
+ activeProfileId: string | null;
+
+ // Actions
+ createProfile: (name: string, avatar: AvatarId) => Profile | null;
+ updateProfile: (id: string, updates: Partial>) => void;
+ deleteProfile: (id: string) => void;
+ setActiveProfile: (id: string | null) => void;
+ getActiveProfile: () => Profile | null;
+ canCreateProfile: () => boolean;
+}
+
+const generateId = () => `profile-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
+
+export const useProfileStore = create()(
+ persist(
+ (set, get) => ({
+ profiles: [],
+ activeProfileId: null,
+
+ createProfile: (name, avatar) => {
+ const { profiles } = get();
+ if (profiles.length >= MAX_PROFILES) {
+ return null;
+ }
+
+ const newProfile: Profile = {
+ id: generateId(),
+ name: name.trim(),
+ avatar,
+ createdAt: Date.now(),
+ };
+
+ set((state) => ({
+ profiles: [...state.profiles, newProfile],
+ // Auto-select if first profile
+ activeProfileId: state.profiles.length === 0 ? newProfile.id : state.activeProfileId,
+ }));
+
+ return newProfile;
+ },
+
+ updateProfile: (id, updates) => {
+ set((state) => ({
+ profiles: state.profiles.map((profile) =>
+ profile.id === id
+ ? { ...profile, ...updates, name: updates.name?.trim() || profile.name }
+ : profile
+ ),
+ }));
+ },
+
+ deleteProfile: (id) => {
+ const { profiles, activeProfileId } = get();
+
+ // Don't delete if it's the last profile
+ if (profiles.length <= 1) {
+ return;
+ }
+
+ // Clean up profile-specific data from localStorage
+ const keysToRemove: string[] = [];
+ for (let i = 0; i < localStorage.length; i++) {
+ const key = localStorage.key(i);
+ if (key && key.includes(id)) {
+ keysToRemove.push(key);
+ }
+ }
+ keysToRemove.forEach((key) => localStorage.removeItem(key));
+
+ set((state) => {
+ const newProfiles = state.profiles.filter((p) => p.id !== id);
+ return {
+ profiles: newProfiles,
+ // If deleting active profile, switch to first available
+ activeProfileId:
+ activeProfileId === id ? newProfiles[0]?.id || null : activeProfileId,
+ };
+ });
+ },
+
+ setActiveProfile: (id) => {
+ if (id === null) {
+ set({ activeProfileId: null });
+ return;
+ }
+ const { profiles } = get();
+ if (profiles.some((p) => p.id === id)) {
+ set({ activeProfileId: id });
+ }
+ },
+
+ getActiveProfile: () => {
+ const { profiles, activeProfileId } = get();
+ return profiles.find((p) => p.id === activeProfileId) || null;
+ },
+
+ canCreateProfile: () => {
+ return get().profiles.length < MAX_PROFILES;
+ },
+ }),
+ {
+ name: 'bestream-profiles',
+ }
+ )
+);
+
+// Helper to get storage key for profile-specific data
+export const getProfileStorageKey = (baseKey: string, profileId: string | null): string => {
+ if (!profileId) {
+ return baseKey;
+ }
+ return `${baseKey}-${profileId}`;
+};
diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts
index 695e6dd..7b44dc3 100644
--- a/src/stores/settingsStore.ts
+++ b/src/stores/settingsStore.ts
@@ -12,6 +12,9 @@ const defaultSettings: AppSettings = {
preferredQuality: '1080p',
preferredLanguage: 'en',
autoPlay: true,
+ autoDeleteStreams: true,
+ autoQuality: true,
+ autoDownloadNextEpisode: true,
notifications: true,
downloadPath: '',
maxConcurrentDownloads: 3,
diff --git a/src/stores/watchlistStore.ts b/src/stores/watchlistStore.ts
index 7a68f2e..c39849b 100644
--- a/src/stores/watchlistStore.ts
+++ b/src/stores/watchlistStore.ts
@@ -1,21 +1,32 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { Movie, WatchlistItem } from '../types';
+import { useProfileStore } from './profileStore';
+
+interface WatchlistItemWithProfile extends WatchlistItem {
+ profileId: string;
+}
interface WatchlistState {
- items: WatchlistItem[];
+ items: WatchlistItemWithProfile[];
addToWatchlist: (movie: Movie) => void;
removeFromWatchlist: (movieId: number) => void;
isInWatchlist: (movieId: number) => boolean;
+ getProfileItems: () => WatchlistItemWithProfile[];
clearWatchlist: () => void;
}
+const getActiveProfileId = () => useProfileStore.getState().activeProfileId || 'default';
+
export const useWatchlistStore = create()(
persist(
(set, get) => ({
items: [],
addToWatchlist: (movie) => {
- const exists = get().items.some((item) => item.movieId === movie.id);
+ const profileId = getActiveProfileId();
+ const exists = get().items.some(
+ (item) => item.movieId === movie.id && item.profileId === profileId
+ );
if (!exists) {
set((state) => ({
items: [
@@ -25,18 +36,36 @@ export const useWatchlistStore = create()(
movieId: movie.id,
movie,
addedAt: new Date(),
+ profileId,
},
],
}));
}
},
- removeFromWatchlist: (movieId) =>
+ removeFromWatchlist: (movieId) => {
+ const profileId = getActiveProfileId();
set((state) => ({
- items: state.items.filter((item) => item.movieId !== movieId),
- })),
- isInWatchlist: (movieId) =>
- get().items.some((item) => item.movieId === movieId),
- clearWatchlist: () => set({ items: [] }),
+ items: state.items.filter(
+ (item) => !(item.movieId === movieId && item.profileId === profileId)
+ ),
+ }));
+ },
+ isInWatchlist: (movieId) => {
+ const profileId = getActiveProfileId();
+ return get().items.some(
+ (item) => item.movieId === movieId && item.profileId === profileId
+ );
+ },
+ getProfileItems: () => {
+ const profileId = getActiveProfileId();
+ return get().items.filter((item) => item.profileId === profileId);
+ },
+ clearWatchlist: () => {
+ const profileId = getActiveProfileId();
+ set((state) => ({
+ items: state.items.filter((item) => item.profileId !== profileId),
+ }));
+ },
}),
{
name: 'bestream-watchlist',
diff --git a/src/types/index.ts b/src/types/index.ts
index 2fd4af0..4b0a788 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -166,12 +166,38 @@ export interface AppSettings {
preferredQuality: string;
preferredLanguage: string;
autoPlay: boolean;
+ autoDeleteStreams: boolean;
+ autoQuality: boolean;
+ autoDownloadNextEpisode: boolean;
notifications: boolean;
downloadPath: string;
maxConcurrentDownloads: number;
theme: 'dark' | 'light' | 'system';
}
+// Profile Types
+export interface Profile {
+ id: string;
+ name: string;
+ avatar: string; // avatar-1 through avatar-8
+ createdAt: number;
+}
+
+export const AVATAR_OPTIONS = [
+ 'avatar-1', 'avatar-2', 'avatar-3', 'avatar-4',
+ 'avatar-5', 'avatar-6', 'avatar-7', 'avatar-8',
+] as const;
+
+export type AvatarId = typeof AVATAR_OPTIONS[number];
+
+// Recommendation Types
+export interface RecommendationCache {
+ profileId: string;
+ recommendations: Movie[];
+ generatedAt: number;
+ basedOnGenres: string[];
+}
+
// Torrent Types
export interface TorrentInfo {
infoHash: string;
diff --git a/src/utils/network.ts b/src/utils/network.ts
new file mode 100644
index 0000000..81c6d0d
--- /dev/null
+++ b/src/utils/network.ts
@@ -0,0 +1,199 @@
+// Network utility for detecting connection quality and selecting appropriate video quality
+
+export type ConnectionType = 'wifi' | 'cellular' | 'ethernet' | 'unknown';
+export type ConnectionQuality = 'high' | 'medium' | 'low' | 'unknown';
+
+interface NetworkInfo {
+ type: ConnectionType;
+ quality: ConnectionQuality;
+ effectiveType?: '4g' | '3g' | '2g' | 'slow-2g';
+ downlink?: number; // Mbps
+ rtt?: number; // Round trip time in ms
+ saveData?: boolean;
+}
+
+// Get the current network information
+export function getNetworkInfo(): NetworkInfo {
+ const connection =
+ (navigator as any).connection ||
+ (navigator as any).mozConnection ||
+ (navigator as any).webkitConnection;
+
+ if (!connection) {
+ return {
+ type: 'unknown',
+ quality: 'unknown',
+ };
+ }
+
+ // Determine connection type
+ let type: ConnectionType = 'unknown';
+ if (connection.type) {
+ switch (connection.type) {
+ case 'wifi':
+ type = 'wifi';
+ break;
+ case 'ethernet':
+ type = 'ethernet';
+ break;
+ case 'cellular':
+ type = 'cellular';
+ break;
+ default:
+ type = 'unknown';
+ }
+ }
+
+ // Determine quality based on effective type and downlink
+ let quality: ConnectionQuality = 'unknown';
+ if (connection.effectiveType) {
+ switch (connection.effectiveType) {
+ case '4g':
+ quality = 'high';
+ break;
+ case '3g':
+ quality = 'medium';
+ break;
+ case '2g':
+ case 'slow-2g':
+ quality = 'low';
+ break;
+ }
+ }
+
+ // Override based on downlink if available
+ if (connection.downlink !== undefined) {
+ if (connection.downlink >= 10) {
+ quality = 'high';
+ } else if (connection.downlink >= 2) {
+ quality = 'medium';
+ } else {
+ quality = 'low';
+ }
+ }
+
+ return {
+ type,
+ quality,
+ effectiveType: connection.effectiveType,
+ downlink: connection.downlink,
+ rtt: connection.rtt,
+ saveData: connection.saveData,
+ };
+}
+
+// Quality thresholds in Mbps
+const QUALITY_BANDWIDTH_REQUIREMENTS = {
+ '2160p': 25, // 4K needs 25+ Mbps
+ '1080p': 8, // 1080p needs 8+ Mbps
+ '720p': 3, // 720p needs 3+ Mbps
+ '480p': 1.5, // 480p needs 1.5+ Mbps
+};
+
+// Select the best quality based on network conditions
+export function selectOptimalQuality(
+ availableQualities: string[],
+ networkInfo: NetworkInfo
+): string {
+ // If data saver is enabled, always use lowest quality
+ if (networkInfo.saveData) {
+ return findLowestQuality(availableQualities);
+ }
+
+ // If we have downlink info, use it
+ if (networkInfo.downlink !== undefined) {
+ return selectQualityByBandwidth(availableQualities, networkInfo.downlink);
+ }
+
+ // Otherwise use connection quality estimate
+ switch (networkInfo.quality) {
+ case 'high':
+ return findQuality(availableQualities, ['2160p', '1080p', '720p', '480p']);
+ case 'medium':
+ return findQuality(availableQualities, ['720p', '1080p', '480p']);
+ case 'low':
+ return findQuality(availableQualities, ['480p', '720p']);
+ default:
+ // Unknown quality, default to 720p for balance
+ return findQuality(availableQualities, ['720p', '1080p', '480p']);
+ }
+}
+
+function selectQualityByBandwidth(availableQualities: string[], bandwidthMbps: number): string {
+ // Check each quality from highest to lowest
+ const qualityOrder: Array = [
+ '2160p',
+ '1080p',
+ '720p',
+ '480p',
+ ];
+
+ for (const quality of qualityOrder) {
+ if (bandwidthMbps >= QUALITY_BANDWIDTH_REQUIREMENTS[quality]) {
+ // Check if this quality is available
+ if (availableQualities.some((q) => q === quality || q === '4K' && quality === '2160p')) {
+ return quality;
+ }
+ }
+ }
+
+ // Fallback to lowest available
+ return findLowestQuality(availableQualities);
+}
+
+function findQuality(availableQualities: string[], preferenceOrder: string[]): string {
+ for (const preferred of preferenceOrder) {
+ const found = availableQualities.find(
+ (q) => q === preferred || (q === '4K' && preferred === '2160p')
+ );
+ if (found) return found;
+ }
+ return availableQualities[0] || '720p';
+}
+
+function findLowestQuality(availableQualities: string[]): string {
+ const priorityOrder = ['480p', '720p', '1080p', '2160p', '4K'];
+ for (const quality of priorityOrder) {
+ if (availableQualities.includes(quality)) {
+ return quality;
+ }
+ }
+ return availableQualities[0] || '480p';
+}
+
+// Subscribe to network changes
+export function onNetworkChange(callback: (info: NetworkInfo) => void): () => void {
+ const connection =
+ (navigator as any).connection ||
+ (navigator as any).mozConnection ||
+ (navigator as any).webkitConnection;
+
+ if (!connection) {
+ return () => {}; // No-op cleanup
+ }
+
+ const handler = () => callback(getNetworkInfo());
+ connection.addEventListener('change', handler);
+
+ return () => connection.removeEventListener('change', handler);
+}
+
+// Check if we're on a metered connection (cellular)
+export function isMeteredConnection(): boolean {
+ const info = getNetworkInfo();
+ return info.type === 'cellular' || info.saveData === true;
+}
+
+// Estimate if we can handle a specific quality
+export function canHandleQuality(quality: string): boolean {
+ const info = getNetworkInfo();
+
+ if (info.downlink === undefined) {
+ // Can't determine, assume yes for non-4K
+ return quality !== '2160p' && quality !== '4K';
+ }
+
+ const required =
+ QUALITY_BANDWIDTH_REQUIREMENTS[quality as keyof typeof QUALITY_BANDWIDTH_REQUIREMENTS] || 3;
+ return info.downlink >= required;
+}