diff --git a/App.tsx b/App.tsx
index 7ce219f0..960fe6c8 100644
--- a/App.tsx
+++ b/App.tsx
@@ -195,11 +195,9 @@ const ThemedApp = () => {
// Initialize memory monitoring service to prevent OutOfMemoryError
memoryMonitorService; // Just accessing it starts the monitoring
- console.log('Memory monitoring service initialized');
// Initialize AI service
await aiService.initialize();
- console.log('AI service initialized');
} catch (error) {
console.error('Error initializing app:', error);
diff --git a/assets/player-icons/ic_player_play_black.svg b/assets/player-icons/ic_player_play_black.svg
new file mode 100644
index 00000000..fb9e4a14
--- /dev/null
+++ b/assets/player-icons/ic_player_play_black.svg
@@ -0,0 +1,4 @@
+
+
diff --git a/src/components/common/DogLoadingSpinner.tsx b/src/components/common/DogLoadingSpinner.tsx
index f65e18dd..4a553e82 100644
--- a/src/components/common/DogLoadingSpinner.tsx
+++ b/src/components/common/DogLoadingSpinner.tsx
@@ -76,9 +76,7 @@ const LoadingSpinner: React.FC = ({
renderMode: 'SOFTWARE' as any, // Fallback to software rendering if hardware fails
})}
// Error handling
- onAnimationFinish={() => {
- if (__DEV__) console.log('Lottie animation finished');
- }}
+ onAnimationFinish={() => {}}
onAnimationFailure={(error) => {
if (__DEV__) console.warn('Lottie animation failed:', error);
}}
diff --git a/src/components/common/LoadingSpinner.tsx b/src/components/common/LoadingSpinner.tsx
index 45b8654f..6097a3a9 100644
--- a/src/components/common/LoadingSpinner.tsx
+++ b/src/components/common/LoadingSpinner.tsx
@@ -76,9 +76,7 @@ const LoadingSpinner: React.FC = ({
renderMode: 'SOFTWARE' as any, // Fallback to software rendering if hardware fails
})}
// Error handling
- onAnimationFinish={() => {
- if (__DEV__) console.log('Lottie animation finished');
- }}
+ onAnimationFinish={() => {}}
onAnimationFailure={(error) => {
if (__DEV__) console.warn('Lottie animation failed:', error);
}}
diff --git a/src/components/home/AppleTVHero.tsx b/src/components/home/AppleTVHero.tsx
index a06ddb7c..633bbd23 100644
--- a/src/components/home/AppleTVHero.tsx
+++ b/src/components/home/AppleTVHero.tsx
@@ -18,6 +18,7 @@ import { RootStackParamList } from '../../navigation/AppNavigator';
import { LinearGradient } from 'expo-linear-gradient';
import FastImage from '@d11/react-native-fast-image';
import { MaterialIcons, Entypo } from '@expo/vector-icons';
+import PlayerPlayIconBlack from '../../../assets/player-icons/ic_player_play_black.svg';
import Animated, {
FadeIn,
FadeOut,
@@ -1316,11 +1317,19 @@ const AppleTVHero: React.FC = ({
onPress={handlePlayAction}
activeOpacity={0.85}
>
-
+ {shouldResume ? (
+
+ ) : (
+
+ )}
{shouldResume ? t('home.resume') : t('home.play')}
@@ -1330,11 +1339,21 @@ const AppleTVHero: React.FC = ({
onPress={handleSaveAction}
activeOpacity={0.85}
>
-
+ {Platform.OS === 'ios' ? (
+
+
+
+ ) : (
+
+ )}
@@ -1487,11 +1506,18 @@ const styles = StyleSheet.create({
height: 52,
borderRadius: 30,
backgroundColor: 'rgba(255,255,255,0.2)',
+ overflow: 'hidden',
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1.5,
borderColor: 'rgba(255,255,255,0.3)',
},
+ saveButtonBlur: {
+ width: '100%',
+ height: '100%',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
paginationContainer: {
flexDirection: 'row',
alignItems: 'center',
diff --git a/src/components/metadata/HeroSection.tsx b/src/components/metadata/HeroSection.tsx
index 20f04b5e..478596c4 100644
--- a/src/components/metadata/HeroSection.tsx
+++ b/src/components/metadata/HeroSection.tsx
@@ -57,6 +57,7 @@ import { TMDBService } from '../../services/tmdbService';
import TrailerService, { TrailerPlaybackSource } from '../../services/trailerService';
import TrailerPlayer from '../video/TrailerPlayer';
import { HERO_HEIGHT, SCREEN_WIDTH as width, IS_TABLET as isTablet } from '../../constants/dimensions';
+import PlayerPlayIconBlack from '../../../assets/player-icons/ic_player_play_black.svg';
const { height } = Dimensions.get('window');
@@ -355,16 +356,19 @@ const ActionButtons = memo(({
onPress={handleShowStreams}
activeOpacity={0.85}
>
- {
- if (isWatched) {
- return type === 'movie' ? 'replay' : 'play-arrow';
- }
- return playButtonText === 'Resume' ? 'play-circle-outline' : 'play-arrow';
- })()}
- size={isTablet ? 28 : 24}
- color={isWatched && type === 'movie' ? "#fff" : "#000"}
- />
+ {isWatched && type === 'movie' ? (
+
+ ) : (
+
+ )}
{finalPlayButtonText}
diff --git a/src/components/metadata/MetadataDetails.tsx b/src/components/metadata/MetadataDetails.tsx
index cde9ab4d..8d869051 100644
--- a/src/components/metadata/MetadataDetails.tsx
+++ b/src/components/metadata/MetadataDetails.tsx
@@ -19,7 +19,7 @@ import Animated, {
Extrapolate,
} from 'react-native-reanimated';
import { useTheme } from '../../contexts/ThemeContext';
-import { isMDBListEnabled } from '../../screens/MDBListSettingsScreen';
+import { isMDBListEnabled } from '../../services/mdblistConstants';
import { getAgeRatingColor } from '../../utils/ageRatingColors';
import AgeRatingBadge from '../common/AgeRatingBadge';
diff --git a/src/components/metadata/RatingsSection.tsx b/src/components/metadata/RatingsSection.tsx
index 6d552425..ea569ec0 100644
--- a/src/components/metadata/RatingsSection.tsx
+++ b/src/components/metadata/RatingsSection.tsx
@@ -5,7 +5,7 @@ import { useTheme } from '../../contexts/ThemeContext';
import FastImage from '@d11/react-native-fast-image';
import { useMDBListRatings } from '../../hooks/useMDBListRatings';
import { mmkvStorage } from '../../services/mmkvStorage';
-import { isMDBListEnabled, RATING_PROVIDERS_STORAGE_KEY } from '../../screens/MDBListSettingsScreen';
+import { isMDBListEnabled, RATING_PROVIDERS_STORAGE_KEY } from '../../services/mdblistConstants';
// Import SVG icons
import LetterboxdIcon from '../../../assets/rating-icons/letterboxd.svg';
diff --git a/src/components/metadata/SeriesContent.tsx b/src/components/metadata/SeriesContent.tsx
index c0c23833..b2eb5c78 100644
--- a/src/components/metadata/SeriesContent.tsx
+++ b/src/components/metadata/SeriesContent.tsx
@@ -15,10 +15,18 @@ import { useFocusEffect } from '@react-navigation/native';
import Animated, { FadeIn, FadeOut, SlideInRight, SlideOutLeft } from 'react-native-reanimated';
import { TraktService } from '../../services/traktService';
import { watchedService } from '../../services/watchedService';
-import { logger } from '../../utils/logger';
import { mmkvStorage } from '../../services/mmkvStorage';
import { MalSync } from '../../services/mal/MalSync';
+const noop = (..._args: unknown[]) => {};
+const logger = {
+ log: noop,
+ error: noop,
+ warn: noop,
+ info: noop,
+ debug: noop,
+};
+
// ... other imports
const BREAKPOINTS = {
phone: 0,
@@ -212,10 +220,10 @@ const SeriesContentComponent: React.FC = ({
const savedMode = await mmkvStorage.getItem('global_season_view_mode');
if (savedMode === 'text' || savedMode === 'posters') {
setSeasonViewMode(savedMode);
- if (__DEV__) console.log('[SeriesContent] Loaded global view mode:', savedMode);
+ if (__DEV__) logger.log('[SeriesContent] Loaded global view mode:', savedMode);
}
} catch (error) {
- if (__DEV__) console.log('[SeriesContent] Error loading global view mode preference:', error);
+ if (__DEV__) logger.log('[SeriesContent] Error loading global view mode preference:', error);
}
};
@@ -239,7 +247,7 @@ const SeriesContentComponent: React.FC = ({
const updateViewMode = (newMode: 'posters' | 'text') => {
setSeasonViewMode(newMode);
mmkvStorage.setItem('global_season_view_mode', newMode).catch((error: any) => {
- if (__DEV__) console.log('[SeriesContent] Error saving global view mode preference:', error);
+ if (__DEV__) logger.log('[SeriesContent] Error saving global view mode preference:', error);
});
};
@@ -491,7 +499,7 @@ const SeriesContentComponent: React.FC = ({
useEffect(() => {
return () => {
// Clear any pending timeouts
- if (__DEV__) console.log('[SeriesContent] Component unmounted, cleaning up memory');
+ if (__DEV__) logger.log('[SeriesContent] Component unmounted, cleaning up memory');
// Force garbage collection if available (development only)
if (__DEV__ && global.gc) {
@@ -854,7 +862,7 @@ const SeriesContentComponent: React.FC = ({
onPress={() => {
const newMode = seasonViewMode === 'posters' ? 'text' : 'posters';
updateViewMode(newMode);
- if (__DEV__) console.log('[SeriesContent] View mode changed to:', newMode, 'Current ref value:', seasonViewMode);
+ if (__DEV__) logger.log('[SeriesContent] View mode changed to:', newMode, 'Current ref value:', seasonViewMode);
}}
activeOpacity={0.7}
>
diff --git a/src/components/metadata/TrailersSection.tsx b/src/components/metadata/TrailersSection.tsx
index 579a51cc..ca0b789d 100644
--- a/src/components/metadata/TrailersSection.tsx
+++ b/src/components/metadata/TrailersSection.tsx
@@ -17,12 +17,20 @@ import FastImage from '@d11/react-native-fast-image';
import { useTheme } from '../../contexts/ThemeContext';
import { useSettings } from '../../hooks/useSettings';
import { useTrailer } from '../../contexts/TrailerContext';
-import { logger } from '../../utils/logger';
import TrailerService from '../../services/trailerService';
import { TMDBService } from '../../services/tmdbService';
import TrailerModal from './TrailerModal';
import Animated, { useSharedValue, withTiming, withDelay, useAnimatedStyle } from 'react-native-reanimated';
+const noop = (..._args: unknown[]) => {};
+const logger = {
+ log: noop,
+ error: noop,
+ warn: noop,
+ info: noop,
+ debug: noop,
+};
+
// Enhanced responsive breakpoints for Trailers Section
const BREAKPOINTS = {
phone: 0,
diff --git a/src/components/promotions/CampaignManager.tsx b/src/components/promotions/CampaignManager.tsx
index 725f0fe1..d24cfabe 100644
--- a/src/components/promotions/CampaignManager.tsx
+++ b/src/components/promotions/CampaignManager.tsx
@@ -216,11 +216,9 @@ export const CampaignManager: React.FC = () => {
const checkForCampaigns = useCallback(async () => {
try {
- console.log('[CampaignManager] Checking for campaigns...');
await new Promise(resolve => setTimeout(resolve, 1500));
const campaign = await campaignService.getActiveCampaign();
- console.log('[CampaignManager] Got campaign:', campaign?.id, campaign?.type);
if (campaign) {
setActiveCampaign(campaign);
diff --git a/src/hooks/useMDBListRatings.ts b/src/hooks/useMDBListRatings.ts
index 46bcc868..d866f5ff 100644
--- a/src/hooks/useMDBListRatings.ts
+++ b/src/hooks/useMDBListRatings.ts
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { mdblistService, MDBListRatings } from '../services/mdblistService';
import { logger } from '../utils/logger';
-import { isMDBListEnabled } from '../screens/MDBListSettingsScreen';
+import { isMDBListEnabled } from '../services/mdblistConstants';
export const useMDBListRatings = (imdbId: string, mediaType: 'movie' | 'show') => {
const [ratings, setRatings] = useState(null);
diff --git a/src/hooks/useMetadata.ts b/src/hooks/useMetadata.ts
index 0b5c90c8..fa6c0707 100644
--- a/src/hooks/useMetadata.ts
+++ b/src/hooks/useMetadata.ts
@@ -7,7 +7,6 @@ import { cacheService } from '../services/cacheService';
import { localScraperService, ScraperInfo } from '../services/pluginService';
import { Cast, Episode, GroupedEpisodes, GroupedStreams } from '../types/metadata';
import { TMDBService } from '../services/tmdbService';
-import { logger } from '../utils/logger';
import { usePersistentSeasons } from './usePersistentSeasons';
import { mmkvStorage } from '../services/mmkvStorage';
import { Stream } from '../types/metadata';
@@ -15,6 +14,15 @@ import { storageService } from '../services/storageService';
import { useSettings } from './useSettings';
import { MalSync } from '../services/mal/MalSync';
+const noop = (..._args: unknown[]) => {};
+const logger = {
+ log: noop,
+ error: noop,
+ warn: noop,
+ info: noop,
+ debug: noop,
+};
+
// Constants for timeouts and retries
const API_TIMEOUT = 10000; // 10 seconds
const MAX_RETRIES = 1; // Reduced since stremioService already retries
@@ -117,9 +125,15 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
// Normalize anime subtypes to their base types for all internal logic.
// anime.series behaves like series; anime.movie behaves like movie.
- const normalizedType = type === 'anime.series' ? 'series'
- : type === 'anime.movie' ? 'movie'
- : type;
+ // Lowercase first â some addons use capitalized types (e.g. "Movie", "Series", "Other")
+ // which would break all type comparisons downstream.
+ const lowercasedType = type ? type.toLowerCase() : type;
+
+ // Normalize anime subtypes to their base types for all internal logic.
+ // anime.series behaves like series; anime.movie behaves like movie.
+ const normalizedType = lowercasedType === 'anime.series' ? 'series'
+ : lowercasedType === 'anime.movie' ? 'movie'
+ : lowercasedType;
const [metadata, setMetadata] = useState(null);
const [loading, setLoading] = useState(true);
@@ -163,8 +177,6 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
// Memory optimization: Stream cleanup and garbage collection
const cleanupStreams = useCallback(() => {
- if (__DEV__) console.log('[useMetadata] Running stream cleanup to free memory');
-
// Clear preloaded streams cache
setPreloadedStreams({});
setPreloadedEpisodeStreams({});
@@ -222,25 +234,11 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
(streams, addonId, addonName, error, installationId) => {
const processTime = Date.now() - sourceStartTime;
- console.log('đ [processStremioSource] Callback received:', {
- addonId,
- addonName,
- installationId,
- streamCount: streams?.length || 0,
- error: error?.message || null,
- processTime
- });
-
// ALWAYS remove from active fetching list when callback is received
// This ensures that even failed scrapers are removed from the "Fetching from:" chip
if (addonName) {
setActiveFetchingScrapers(prev => {
const updated = prev.filter(name => name !== addonName);
- console.log('đ [processStremioSource] Removing from activeFetchingScrapers:', {
- addonName,
- before: prev,
- after: updated
- });
return updated;
});
}
@@ -496,18 +494,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
const loadMetadata = async () => {
try {
- console.log('đ [useMetadata] loadMetadata CALLED for:', { id, type });
- console.log('đ [useMetadata] loadMetadata started:', {
- id,
- type,
- addonId,
- loadAttempts,
- maxRetries: MAX_RETRIES,
- settingsLoaded: settingsLoaded
- });
-
if (loadAttempts >= MAX_RETRIES) {
- console.log('đ [useMetadata] Max retries exceeded:', { loadAttempts, maxRetries: MAX_RETRIES });
setError(`Failed to load content after ${MAX_RETRIES + 1} attempts. Please check your connection and try again.`);
setLoading(false);
return;
@@ -520,14 +507,6 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
// Check metadata screen cache
const cachedScreen = cacheService.getMetadataScreen(id, normalizedType);
if (cachedScreen) {
- console.log('đ [useMetadata] Using cached metadata:', {
- id,
- type,
- hasMetadata: !!cachedScreen.metadata,
- hasCast: !!cachedScreen.cast,
- hasEpisodes: !!cachedScreen.episodes,
- tmdbId: cachedScreen.tmdbId
- });
setMetadata(cachedScreen.metadata);
setCast(cachedScreen.cast);
if (normalizedType === 'series' && cachedScreen.episodes) {
@@ -545,7 +524,6 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
setLoading(false);
return;
} else {
- console.log('đ [useMetadata] No cached metadata found, proceeding with fresh fetch');
}
// Handle TMDB-specific IDs
@@ -556,7 +534,6 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
// STRICT MODE: Do NOT convert to IMDb/Cinemeta.
// We want to force the app to use AnimeKitsu (or other MAL-compatible addons) for metadata.
// This ensures we get correct Season/Episode mapping (Separate entries) instead of Cinemeta's "S1E26" mess.
- console.log('đ [useMetadata] Keeping MAL ID for metadata fetch:', id);
// Note: Stream fetching (stremioService) WILL still convert this to IMDb secretly
// to ensure Torrentio works, but the Metadata UI will stay purely MAL-based.
@@ -564,13 +541,10 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
if (id.startsWith('tmdb:')) {
// Always try the original TMDB ID first - let addons decide if they support it
- console.log('đ [useMetadata] TMDB ID detected, trying original ID first:', { originalId: id });
-
// If enrichment disabled, try original ID first, then fallback to conversion if needed
if (!settings.enrichMetadataWithTMDB) {
// Keep the original TMDB ID - let the addon system handle it dynamically
actualId = id;
- console.log('đ [useMetadata] TMDB enrichment disabled, using original TMDB ID:', { actualId });
} else {
const tmdbId = id.split(':')[1];
// For TMDB IDs, we need to handle metadata differently
@@ -730,7 +704,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
// Load series data (episodes)
setTmdbId(parseInt(tmdbId));
- loadSeriesData().catch((error) => { if (__DEV__) console.error(error); });
+ loadSeriesData().catch((error) => { if (__DEV__) logger.error(error); });
(async () => {
const items = await catalogService.getLibraryItems();
@@ -749,7 +723,6 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
}
// Load all data in parallel
- console.log('đ [useMetadata] Starting parallel data fetch:', { type, actualId, addonId, apiTimeout: API_TIMEOUT });
if (__DEV__) logger.log('[loadMetadata] fetching addon metadata', { type, actualId, addonId });
let contentResult: any = null;
@@ -761,7 +734,6 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
if (preferExternal) {
// Try external meta addons first
try {
- console.log('đ [useMetadata] Trying external meta addons first');
const [content, castData] = await Promise.allSettled([
withRetry(async () => {
// Get all installed addons
@@ -791,20 +763,17 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
);
if (result) {
- console.log('đ [useMetadata] Got metadata from external addon:', addon.name);
if (actualId.startsWith('tt')) {
setImdbId(actualId);
}
return result;
}
} catch (error) {
- console.log('đ [useMetadata] External addon failed:', addon.name, error);
continue;
}
}
// If no external addon worked, fall back to catalog addon
- console.log('đ [useMetadata] No external meta addon worked, falling back to catalog addon');
const result = await withTimeout(
catalogService.getEnhancedContentDetails(normalizedType, actualId, addonId),
API_TIMEOUT
@@ -819,39 +788,29 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
contentResult = content;
if (content.status === 'fulfilled' && content.value) {
- console.log('đ [useMetadata] Successfully got metadata with external meta addon priority');
+ if (__DEV__) {
+ logger.log('[useMetadata] External meta addon priority success');
+ }
} else {
- console.log('đ [useMetadata] External meta addon priority failed, will try fallback');
lastError = (content as any)?.reason;
}
} catch (error) {
- console.log('đ [useMetadata] External meta addon attempt failed:', { error: error instanceof Error ? error.message : String(error) });
lastError = error;
}
} else {
// Original behavior: try with original ID first
try {
- console.log('đ [useMetadata] Attempting metadata fetch with original ID:', { type, actualId, addonId });
const [content, castData] = await Promise.allSettled([
// Load content with timeout and retry
withRetry(async () => {
- console.log('⥠[useMetadata] Calling catalogService.getEnhancedContentDetails...');
- console.log('đ [useMetadata] Calling catalogService.getEnhancedContentDetails:', { type, actualId, addonId });
const result = await withTimeout(
catalogService.getEnhancedContentDetails(normalizedType, actualId, addonId),
API_TIMEOUT
);
- console.log('â
[useMetadata] catalogService returned:', result ? 'DATA' : 'NULL');
// Store the actual ID used (could be IMDB)
if (actualId.startsWith('tt')) {
setImdbId(actualId);
}
- console.log('đ [useMetadata] catalogService.getEnhancedContentDetails result:', {
- hasResult: Boolean(result),
- resultId: result?.id,
- resultName: result?.name,
- resultType: result?.type
- });
if (__DEV__) logger.log('[loadMetadata] addon metadata fetched', { hasResult: Boolean(result) });
return result;
}),
@@ -861,13 +820,13 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
contentResult = content;
if (content.status === 'fulfilled' && content.value) {
- console.log('đ [useMetadata] Successfully got metadata with original ID');
+ if (__DEV__) {
+ logger.log('[useMetadata] Original ID metadata fetch succeeded');
+ }
} else {
- console.log('đ [useMetadata] Original ID failed, will try fallback conversion');
lastError = (content as any)?.reason;
}
} catch (error) {
- console.log('đ [useMetadata] Original ID attempt failed:', { error: error instanceof Error ? error.message : String(error) });
lastError = error;
}
}
@@ -875,12 +834,10 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
// If original TMDB ID failed and enrichment is disabled, try ID conversion as fallback
if (!contentResult || (contentResult.status === 'fulfilled' && !contentResult.value) || contentResult.status === 'rejected') {
if (id.startsWith('tmdb:') && !settings.enrichMetadataWithTMDB) {
- console.log('đ [useMetadata] Original TMDB ID failed, trying ID conversion fallback');
const tmdbRaw = id.split(':')[1];
try {
const stremioId = await catalogService.getStremioId(normalizedType === 'series' ? 'tv' : 'movie', tmdbRaw);
if (stremioId && stremioId !== id) {
- console.log('đ [useMetadata] Trying converted ID:', { originalId: id, convertedId: stremioId });
const [content, castData] = await Promise.allSettled([
withRetry(async () => {
const result = await withTimeout(
@@ -897,7 +854,9 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
contentResult = content;
}
} catch (e) {
- console.log('đ [useMetadata] ID conversion fallback also failed:', { error: e instanceof Error ? e.message : String(e) });
+ if (__DEV__) {
+ logger.log('[useMetadata] ID conversion fallback failed');
+ }
}
}
}
@@ -905,22 +864,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
const content = contentResult || { status: 'rejected' as const, reason: lastError || new Error('No content result') };
const castData = { status: 'fulfilled' as const, value: undefined };
- console.log('đ [useMetadata] Promise.allSettled results:', {
- contentStatus: content.status,
- contentFulfilled: content.status === 'fulfilled',
- hasContentValue: content.status === 'fulfilled' ? !!content.value : false,
- castStatus: castData.status,
- castFulfilled: castData.status === 'fulfilled'
- });
-
if (content.status === 'fulfilled' && content.value) {
- console.log('đ [useMetadata] Content fetch successful:', {
- id: content.value?.id,
- type: content.value?.type,
- name: content.value?.name,
- hasDescription: !!content.value?.description,
- hasPoster: !!content.value?.poster
- });
if (__DEV__) logger.log('[loadMetadata] addon metadata:success', { id: content.value?.id, type: content.value?.type, name: content.value?.name });
// Start with addon metadata
@@ -1025,7 +969,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
}
}
} catch (e) {
- if (__DEV__) console.log('[useMetadata] failed to merge TMDB title/description', e);
+ if (__DEV__) logger.log('[useMetadata] failed to merge TMDB title/description', e);
}
// Centralized logo fetching logic
@@ -1050,7 +994,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
// Use TMDB logo if found, otherwise fall back to addon logo
finalMetadata.logo = logoUrl || addonLogo || undefined;
if (__DEV__) {
- console.log('[useMetadata] Logo fetch result:', {
+ logger.log('[useMetadata] Logo fetch result:', {
contentType,
tmdbIdForLogo,
preferredLanguage,
@@ -1062,13 +1006,13 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
} else {
// No TMDB ID, fall back to addon logo
finalMetadata.logo = addonLogo || undefined;
- if (__DEV__) console.log('[useMetadata] No TMDB ID found for logo, using addon logo');
+ if (__DEV__) logger.log('[useMetadata] No TMDB ID found for logo, using addon logo');
}
} else {
// When enrichment or logos is OFF, use addon logo
finalMetadata.logo = addonLogo || finalMetadata.logo || undefined;
if (__DEV__) {
- console.log('[useMetadata] TMDB logo enrichment disabled, using addon logo:', {
+ logger.log('[useMetadata] TMDB logo enrichment disabled, using addon logo:', {
hasAddonLogo: !!finalMetadata.logo,
enrichmentEnabled: settings.enrichMetadataWithTMDB,
logosEnabled: settings.tmdbEnrichLogos
@@ -1077,7 +1021,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
}
} catch (error) {
// Handle error silently, keep existing logo behavior
- if (__DEV__) console.error('[useMetadata] Unexpected error in logo fetch:', error);
+ if (__DEV__) logger.error('[useMetadata] Unexpected error in logo fetch:', error);
finalMetadata.logo = undefined;
}
@@ -1114,17 +1058,8 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
const reason = (content as any)?.reason;
const reasonMessage = reason?.message || String(reason);
- console.log('đ [useMetadata] Content fetch failed:', {
- status: content.status,
- reason: reasonMessage,
- fullReason: reason,
- isAxiosError: reason?.isAxiosError,
- responseStatus: reason?.response?.status,
- responseData: reason?.response?.data
- });
-
if (__DEV__) {
- console.log('[loadMetadata] addon metadata:not found or failed', {
+ logger.log('[loadMetadata] addon metadata:not found or failed', {
status: content.status,
reason: reasonMessage,
fullReason: reason
@@ -1139,28 +1074,23 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
reasonMessage.includes('Network Error') ||
reasonMessage.includes('Request failed')
)) {
- console.log('đ [useMetadata] Detected server/network error, preserving original error');
// This was a server/network error, preserve the original error message
throw reason instanceof Error ? reason : new Error(reasonMessage);
} else {
- console.log('đ [useMetadata] Detected content not found error, throwing generic error');
// This was likely a content not found error
throw new Error('Content not found');
}
}
} catch (error) {
- console.log('đ [useMetadata] loadMetadata caught error:', {
- errorMessage: error instanceof Error ? error.message : String(error),
- errorType: typeof error,
- isAxiosError: (error as any)?.isAxiosError,
- responseStatus: (error as any)?.response?.status,
- responseData: (error as any)?.response?.data,
- stack: error instanceof Error ? error.stack : undefined
- });
-
if (__DEV__) {
- console.error('Failed to load metadata:', error);
- console.log('Error message being set:', error instanceof Error ? error.message : String(error));
+ logger.log('[loadMetadata] failed with error', {
+ errorMessage: error instanceof Error ? error.message : String(error),
+ errorType: typeof error,
+ isAxiosError: (error as any)?.isAxiosError,
+ responseStatus: (error as any)?.response?.status,
+ responseData: (error as any)?.response?.data,
+ stack: error instanceof Error ? error.stack : undefined
+ });
}
// Preserve the original error details for better error parsing
@@ -1173,7 +1103,6 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
setGroupedEpisodes({});
setEpisodes([]);
} finally {
- console.log('đ [useMetadata] loadMetadata completed, setting loading to false');
setLoading(false);
}
};
@@ -1309,7 +1238,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
if (__DEV__) logger.log('[useMetadata] merged episode names/overviews from TMDB (batch)');
}
} catch (e) {
- if (__DEV__) console.log('[useMetadata] failed to merge episode text from TMDB', e);
+ if (__DEV__) logger.log('[useMetadata] failed to merge episode text from TMDB', e);
}
}
@@ -1477,7 +1406,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
}
}
} catch (error) {
- if (__DEV__) console.error('Failed to load episodes:', error);
+ if (__DEV__) logger.error('Failed to load episodes:', error);
} finally {
setLoadingSeasons(false);
}
@@ -1531,7 +1460,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
}
if (allEmbeddedStreams.length > 0) {
- if (__DEV__) console.log(`â
[extractEmbeddedStreams] Found ${allEmbeddedStreams.length} embedded streams from ${addonName}`);
+ if (__DEV__) logger.log(`â
[extractEmbeddedStreams] Found ${allEmbeddedStreams.length} embedded streams from ${addonName}`);
// Add to grouped streams
setGroupedStreams(prevStreams => ({
@@ -1566,7 +1495,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
);
if (episodeVideo && episodeVideo.streams && episodeVideo.streams.length > 0) {
- if (__DEV__) console.log(`â
[extractEmbeddedStreams] Found embedded streams for episode ${episodeToUse}`);
+ if (__DEV__) logger.log(`â
[extractEmbeddedStreams] Found embedded streams for episode ${episodeToUse}`);
const episodeStreamsList: Stream[] = episodeVideo.streams.map((stream: any) => ({
...stream,
@@ -1592,7 +1521,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
const loadStreams = async () => {
const startTime = Date.now();
try {
- if (__DEV__) console.log('đ [loadStreams] START - Loading streams for:', id);
+ if (__DEV__) logger.log('đ [loadStreams] START - Loading streams for:', id);
updateLoadingState();
// Reset scraper tracking
@@ -1600,22 +1529,22 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
setActiveFetchingScrapers([]);
setAddonResponseOrder([]); // Reset response order
- if (__DEV__) console.log('đ [loadStreams] Getting TMDB ID for:', id);
+ if (__DEV__) logger.log('đ [loadStreams] Getting TMDB ID for:', id);
let tmdbId;
let stremioId = id;
let effectiveStreamType: string = type;
if (id.startsWith('tmdb:')) {
tmdbId = id.split(':')[1];
- if (__DEV__) console.log('â
[loadStreams] Using TMDB ID from ID:', tmdbId);
+ if (__DEV__) logger.log('â
[loadStreams] Using TMDB ID from ID:', tmdbId);
// Try to get IMDb ID from metadata first, then convert if needed
if (metadata?.imdb_id) {
stremioId = metadata.imdb_id;
- if (__DEV__) console.log('â
[loadStreams] Using IMDb ID from metadata for Stremio:', stremioId);
+ if (__DEV__) logger.log('â
[loadStreams] Using IMDb ID from metadata for Stremio:', stremioId);
} else if (imdbId) {
stremioId = imdbId;
- if (__DEV__) console.log('â
[loadStreams] Using stored IMDb ID for Stremio:', stremioId);
+ if (__DEV__) logger.log('â
[loadStreams] Using stored IMDb ID for Stremio:', stremioId);
} else {
// Convert TMDB ID to IMDb ID for Stremio addons (they expect IMDb format)
try {
@@ -1629,28 +1558,28 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
if (externalIds?.imdb_id) {
stremioId = externalIds.imdb_id;
- if (__DEV__) console.log('â
[loadStreams] Converted TMDB to IMDb ID for Stremio:', stremioId);
+ if (__DEV__) logger.log('â
[loadStreams] Converted TMDB to IMDb ID for Stremio:', stremioId);
} else {
- if (__DEV__) console.log('â ī¸ [loadStreams] No IMDb ID found for TMDB ID, using original:', stremioId);
+ if (__DEV__) logger.log('â ī¸ [loadStreams] No IMDb ID found for TMDB ID, using original:', stremioId);
}
} catch (error) {
- if (__DEV__) console.log('â ī¸ [loadStreams] Failed to convert TMDB to IMDb, using original ID:', error);
+ if (__DEV__) logger.log('â ī¸ [loadStreams] Failed to convert TMDB to IMDb, using original ID:', error);
}
}
} else if (id.startsWith('tt')) {
// This is already an IMDB ID, perfect for Stremio
stremioId = id;
if (settings.enrichMetadataWithTMDB) {
- if (__DEV__) console.log('đ [loadStreams] Converting IMDB ID to TMDB ID...');
+ if (__DEV__) logger.log('đ [loadStreams] Converting IMDB ID to TMDB ID...');
tmdbId = await withTimeout(tmdbService.findTMDBIdByIMDB(id), API_TIMEOUT);
- if (__DEV__) console.log('â
[loadStreams] Converted to TMDB ID:', tmdbId);
+ if (__DEV__) logger.log('â
[loadStreams] Converted to TMDB ID:', tmdbId);
} else {
- if (__DEV__) console.log('đ [loadStreams] TMDB enrichment disabled, skipping IMDB to TMDB conversion');
+ if (__DEV__) logger.log('đ [loadStreams] TMDB enrichment disabled, skipping IMDB to TMDB conversion');
}
} else {
tmdbId = id;
stremioId = id;
- if (__DEV__) console.log('âšī¸ [loadStreams] Using ID as both TMDB and Stremio ID:', tmdbId);
+ if (__DEV__) logger.log('âšī¸ [loadStreams] Using ID as both TMDB and Stremio ID:', tmdbId);
}
// Initialize scraper tracking
@@ -1710,14 +1639,14 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
if (fallback.length > 0) {
effectiveStreamType = fallbackType;
eligibleStreamAddons = fallback;
- if (__DEV__) console.log(`[useMetadata.loadStreams] No addons for '${requestedStreamType}', falling back to '${fallbackType}'`);
+ if (__DEV__) logger.log(`[useMetadata.loadStreams] No addons for '${requestedStreamType}', falling back to '${fallbackType}'`);
break;
}
}
}
const streamAddons = eligibleStreamAddons;
- if (__DEV__) console.log('[useMetadata.loadStreams] Eligible stream addons:', streamAddons.map(a => a.id), { requestedStreamType, effectiveStreamType });
+ if (__DEV__) logger.log('[useMetadata.loadStreams] Eligible stream addons:', streamAddons.map(a => a.id), { requestedStreamType, effectiveStreamType });
// Initialize scraper statuses for tracking
const initialStatuses: ScraperStatus[] = [];
@@ -1764,11 +1693,11 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
setLoadingStreams(false);
}
} catch (error) {
- if (__DEV__) console.error('Failed to initialize scraper tracking:', error);
+ if (__DEV__) logger.error('Failed to initialize scraper tracking:', error);
}
// Start Stremio request using the converted ID format
- if (__DEV__) console.log('đŦ [loadStreams] Using ID for Stremio addons:', stremioId);
+ if (__DEV__) logger.log('đŦ [loadStreams] Using ID for Stremio addons:', stremioId);
// Use the effective type we selected when building the eligible addon list.
// This stays aligned with Stremio manifest filtering rules and avoids hard-mapping non-standard types.
processStremioSource(effectiveStreamType, stremioId, false);
@@ -1807,7 +1736,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
}, 60000);
} catch (error) {
- if (__DEV__) console.error('â [loadStreams] Failed to load streams:', error);
+ if (__DEV__) logger.error('â [loadStreams] Failed to load streams:', error);
// Preserve the original error details for better error parsing
const errorMessage = error instanceof Error ? error.message : 'Failed to load streams';
setError(errorMessage);
@@ -1818,7 +1747,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
const loadEpisodeStreams = async (episodeId: string) => {
const startTime = Date.now();
try {
- if (__DEV__) console.log('đ [loadEpisodeStreams] START - Loading episode streams for:', episodeId);
+ if (__DEV__) logger.log('đ [loadEpisodeStreams] START - Loading episode streams for:', episodeId);
updateEpisodeLoadingState();
// Reset scraper tracking for episodes
@@ -1861,7 +1790,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
const fallback = pickStreamCapableAddons(fallbackType);
if (fallback.length > 0) {
streamAddons = fallback;
- if (__DEV__) console.log(`[useMetadata.loadEpisodeStreams] No addons for '${requestedEpisodeType}', falling back to '${fallbackType}'`);
+ if (__DEV__) logger.log(`[useMetadata.loadEpisodeStreams] No addons for '${requestedEpisodeType}', falling back to '${fallbackType}'`);
break;
}
}
@@ -1912,12 +1841,12 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
setLoadingEpisodeStreams(false);
}
} catch (error) {
- if (__DEV__) console.error('Failed to initialize episode scraper tracking:', error);
+ if (__DEV__) logger.error('Failed to initialize episode scraper tracking:', error);
}
// Get TMDB ID for external sources and determine the correct ID for Stremio addons
const isImdb = id.startsWith('tt');
- if (__DEV__) console.log('đ [loadEpisodeStreams] Getting TMDB ID for:', id);
+ if (__DEV__) logger.log('đ [loadEpisodeStreams] Getting TMDB ID for:', id);
let tmdbId;
let stremioEpisodeId = episodeId; // Default to original episode ID
let isCollection = false;
@@ -1965,13 +1894,13 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
showIdStr = parts.join(':');
}
- if (__DEV__) console.log(`đ [loadEpisodeStreams] Parsed ID: show=${showIdStr}, s=${seasonNum}, e=${episodeNum}`);
+ if (__DEV__) logger.log(`đ [loadEpisodeStreams] Parsed ID: show=${showIdStr}, s=${seasonNum}, e=${episodeNum}`);
} catch (e) {
- if (__DEV__) console.warn('â ī¸ [loadEpisodeStreams] Failed to parse episode ID:', episodeId);
+ if (__DEV__) logger.warn('â ī¸ [loadEpisodeStreams] Failed to parse episode ID:', episodeId);
}
if (isCollection && collectionAddon) {
- if (__DEV__) console.log(`đŦ [loadEpisodeStreams] Detected collection from addon: ${collectionAddon.name}, treating episodes as individual movies`);
+ if (__DEV__) logger.log(`đŦ [loadEpisodeStreams] Detected collection from addon: ${collectionAddon.name}, treating episodes as individual movies`);
// For collections, extract the individual movie ID from the episodeId
// episodeId format for collections: "tt7888964" (IMDb ID of individual movie)
@@ -1981,7 +1910,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
tmdbId = await withTimeout(tmdbService.findTMDBIdByIMDB(episodeId), API_TIMEOUT);
}
stremioEpisodeId = episodeId; // Use the IMDb ID directly for Stremio addons
- if (__DEV__) console.log('â
[loadEpisodeStreams] Collection movie - using IMDb ID:', episodeId, 'TMDB ID:', tmdbId);
+ if (__DEV__) logger.log('â
[loadEpisodeStreams] Collection movie - using IMDb ID:', episodeId, 'TMDB ID:', tmdbId);
} else {
// Fallback: try to verify if it's a tmdb id
const isTmdb = episodeId.startsWith('tmdb:') || !isNaN(Number(episodeId));
@@ -1992,20 +1921,20 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
} else {
stremioEpisodeId = episodeId;
}
- if (__DEV__) console.log('â ī¸ [loadEpisodeStreams] Collection movie - using episodeId as-is:', episodeId);
+ if (__DEV__) logger.log('â ī¸ [loadEpisodeStreams] Collection movie - using episodeId as-is:', episodeId);
}
} else if (id.startsWith('tmdb:')) {
tmdbId = id.split(':')[1];
- if (__DEV__) console.log('â
[loadEpisodeStreams] Using TMDB ID from ID:', tmdbId);
+ if (__DEV__) logger.log('â
[loadEpisodeStreams] Using TMDB ID from ID:', tmdbId);
// Try to get IMDb ID from metadata first, then convert if needed
if (metadata?.imdb_id) {
// Use format: imdb_id:season:episode
stremioEpisodeId = `${metadata.imdb_id}:${seasonNum}:${episodeNum}`;
- if (__DEV__) console.log('â
[loadEpisodeStreams] Using IMDb ID from metadata for Stremio episode:', stremioEpisodeId);
+ if (__DEV__) logger.log('â
[loadEpisodeStreams] Using IMDb ID from metadata for Stremio episode:', stremioEpisodeId);
} else if (imdbId) {
stremioEpisodeId = `${imdbId}:${seasonNum}:${episodeNum}`;
- if (__DEV__) console.log('â
[loadEpisodeStreams] Using stored IMDb ID for Stremio episode:', stremioEpisodeId);
+ if (__DEV__) logger.log('â
[loadEpisodeStreams] Using stored IMDb ID for Stremio episode:', stremioEpisodeId);
} else {
// Convert TMDB ID to IMDb ID for Stremio addons
try {
@@ -2013,27 +1942,27 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
if (externalIds?.imdb_id) {
stremioEpisodeId = `${externalIds.imdb_id}:${seasonNum}:${episodeNum}`;
- if (__DEV__) console.log('â
[loadEpisodeStreams] Converted TMDB to IMDb ID for Stremio episode:', stremioEpisodeId);
+ if (__DEV__) logger.log('â
[loadEpisodeStreams] Converted TMDB to IMDb ID for Stremio episode:', stremioEpisodeId);
} else {
// Fallback to TMDB format if conversions fail
// e.g. tmdb:123:1:1
stremioEpisodeId = `${id}:${seasonNum}:${episodeNum}`;
- if (__DEV__) console.log('â ī¸ [loadEpisodeStreams] No IMDb ID found for TMDB ID, using TMDB episode ID:', stremioEpisodeId);
+ if (__DEV__) logger.log('â ī¸ [loadEpisodeStreams] No IMDb ID found for TMDB ID, using TMDB episode ID:', stremioEpisodeId);
}
} catch (error) {
stremioEpisodeId = `${id}:${seasonNum}:${episodeNum}`;
- if (__DEV__) console.log('â ī¸ [loadEpisodeStreams] Failed to convert TMDB to IMDb, using TMDB episode ID:', error);
+ if (__DEV__) logger.log('â ī¸ [loadEpisodeStreams] Failed to convert TMDB to IMDb, using TMDB episode ID:', error);
}
}
} else if (isImdb) {
// This is already an IMDB ID, perfect for Stremio
if (settings.enrichMetadataWithTMDB) {
- if (__DEV__) console.log('đ [loadEpisodeStreams] Converting IMDB ID to TMDB ID...');
+ if (__DEV__) logger.log('đ [loadEpisodeStreams] Converting IMDB ID to TMDB ID...');
tmdbId = await withTimeout(tmdbService.findTMDBIdByIMDB(id), API_TIMEOUT);
} else {
- if (__DEV__) console.log('đ [loadEpisodeStreams] TMDB enrichment disabled, skipping IMDB to TMDB conversion');
+ if (__DEV__) logger.log('đ [loadEpisodeStreams] TMDB enrichment disabled, skipping IMDB to TMDB conversion');
}
- if (__DEV__) console.log('â
[loadEpisodeStreams] Converted to TMDB ID:', tmdbId);
+ if (__DEV__) logger.log('â
[loadEpisodeStreams] Converted to TMDB ID:', tmdbId);
// Ensure consistent format or fallback to episodeId if parsing failed.
// If the episode's namespace differs from the show's tt id (e.g. kitsu:48363:8
@@ -2048,7 +1977,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
const baseId = showIdStr && showIdStr !== id ? showIdStr : id;
stremioEpisodeId = `${baseId}:${seasonNum}:${episodeNum}`;
}
- if (__DEV__) console.log('đ§ [loadEpisodeStreams] Normalized episode ID for addons:', stremioEpisodeId);
+ if (__DEV__) logger.log('đ§ [loadEpisodeStreams] Normalized episode ID for addons:', stremioEpisodeId);
} else {
tmdbId = id;
// If season/episode parsing failed (empty strings), use the raw episode ID
@@ -2067,22 +1996,22 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
const baseId = showIdStr && showIdStr !== id ? showIdStr : id;
stremioEpisodeId = `${baseId}:${seasonNum}:${episodeNum}`;
}
- if (__DEV__) console.log('âšī¸ [loadEpisodeStreams] Using ID as both TMDB and Stremio ID:', tmdbId, '| stremioEpisodeId:', stremioEpisodeId);
+ if (__DEV__) logger.log('âšī¸ [loadEpisodeStreams] Using ID as both TMDB and Stremio ID:', tmdbId, '| stremioEpisodeId:', stremioEpisodeId);
}
// Extract episode info from the episodeId for logging
const episodeQuery = `?s=${seasonNum}&e=${episodeNum}`;
- if (__DEV__) console.log(`âšī¸ [loadEpisodeStreams] Episode query: ${episodeQuery}`);
+ if (__DEV__) logger.log(`âšī¸ [loadEpisodeStreams] Episode query: ${episodeQuery}`);
- if (__DEV__) console.log('đ [loadEpisodeStreams] Starting stream requests');
+ if (__DEV__) logger.log('đ [loadEpisodeStreams] Starting stream requests');
// Start Stremio request using the converted episode ID format
- if (__DEV__) console.log('đŦ [loadEpisodeStreams] Using episode ID for Stremio addons:', stremioEpisodeId);
+ if (__DEV__) logger.log('đŦ [loadEpisodeStreams] Using episode ID for Stremio addons:', stremioEpisodeId);
// For collections, treat episodes as individual movies, not series
// For other types (e.g. StreamsPPV), preserve the original type unless it's explicitly 'series' logic we want
const contentType = isCollection ? 'movie' : type;
- if (__DEV__) console.log(`đŦ [loadEpisodeStreams] Using content type: ${contentType} for ${isCollection ? 'collection' : type}`);
+ if (__DEV__) logger.log(`đŦ [loadEpisodeStreams] Using content type: ${contentType} for ${isCollection ? 'collection' : type}`);
processStremioSource(contentType, stremioEpisodeId, true);
@@ -2121,7 +2050,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
}, 60000);
} catch (error) {
- if (__DEV__) console.error('â [loadEpisodeStreams] Failed to load episode streams:', error);
+ if (__DEV__) logger.error('â [loadEpisodeStreams] Failed to load episode streams:', error);
// Preserve the original error details for better error parsing
const errorMessage = error instanceof Error ? error.message : 'Failed to load episode streams';
setError(errorMessage);
@@ -2187,7 +2116,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
// This will be handled by the StreamsScreen component
// The useMetadata hook focuses on metadata and episodes
} catch (error) {
- if (__DEV__) console.log('[useMetadata] Error checking cached streams on mount:', error);
+ if (__DEV__) logger.log('[useMetadata] Error checking cached streams on mount:', error);
}
};
@@ -2206,7 +2135,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
useEffect(() => {
if (metadata && metadata.videos && metadata.videos.length > 0) {
logger.log(`đŦ Metadata updated with ${metadata.videos.length} episodes, reloading series data`);
- loadSeriesData().catch((error) => { if (__DEV__) console.error(error); });
+ loadSeriesData().catch((error) => { if (__DEV__) logger.error(error); });
// Also extract embedded streams from metadata videos (PPV-style addons)
extractEmbeddedStreams();
}
@@ -2214,7 +2143,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
const loadRecommendations = useCallback(async () => {
if (!settings.enrichMetadataWithTMDB) {
- if (__DEV__) console.log('[useMetadata] enrichment disabled; skip recommendations');
+ if (__DEV__) logger.log('[useMetadata] enrichment disabled; skip recommendations');
return;
}
if (!tmdbId) return;
@@ -2236,7 +2165,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
setRecommendations(formattedRecommendations);
} catch (error) {
- if (__DEV__) console.error('Failed to load recommendations:', error);
+ if (__DEV__) logger.error('Failed to load recommendations:', error);
setRecommendations([]);
} finally {
setLoadingRecommendations(false);
@@ -2253,7 +2182,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
// For anime IDs we always try to resolve tmdbId and imdbId regardless of enrichment setting,
// because they're needed for Trakt scrobbling even when TMDB enrichment is disabled.
if (!settings.enrichMetadataWithTMDB && !isAnimeId) {
- if (__DEV__) console.log('[useMetadata] enrichment disabled; skip TMDB id extraction (extract path)');
+ if (__DEV__) logger.log('[useMetadata] enrichment disabled; skip TMDB id extraction (extract path)');
return;
}
@@ -2262,7 +2191,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
const tmdbSvc = TMDBService.getInstance();
const fetchedTmdbId = await tmdbSvc.extractTMDBIdFromStremioId(id);
if (fetchedTmdbId) {
- if (__DEV__) console.log('[useMetadata] extracted TMDB id from content id', { id, fetchedTmdbId });
+ if (__DEV__) logger.log('[useMetadata] extracted TMDB id from content id', { id, fetchedTmdbId });
setTmdbId(fetchedTmdbId);
// For anime IDs, also resolve the IMDb ID from TMDB external IDs so Trakt can scrobble
@@ -2270,11 +2199,11 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
try {
const externalIds = await tmdbSvc.getShowExternalIds(fetchedTmdbId);
if (externalIds?.imdb_id) {
- if (__DEV__) console.log('[useMetadata] resolved imdbId for anime via TMDB', { id, imdbId: externalIds.imdb_id });
+ if (__DEV__) logger.log('[useMetadata] resolved imdbId for anime via TMDB', { id, imdbId: externalIds.imdb_id });
setImdbId(externalIds.imdb_id);
}
} catch (e) {
- if (__DEV__) console.warn('[useMetadata] could not resolve imdbId from TMDB for anime ID', { id });
+ if (__DEV__) logger.warn('[useMetadata] could not resolve imdbId from TMDB for anime ID', { id });
}
}
@@ -2289,24 +2218,24 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
if (settings.tmdbEnrichCertification) {
const certification = await tmdbSvc.getCertification(normalizedType, fetchedTmdbId);
if (certification) {
- if (__DEV__) console.log('[useMetadata] fetched certification via TMDB id (extract path)', { type, fetchedTmdbId, certification });
+ if (__DEV__) logger.log('[useMetadata] fetched certification via TMDB id (extract path)', { type, fetchedTmdbId, certification });
setMetadata(prev => prev ? {
...prev,
tmdbId: fetchedTmdbId,
certification
} : null);
} else {
- if (__DEV__) console.warn('[useMetadata] certification not returned from TMDB (extract path)', { type, fetchedTmdbId });
+ if (__DEV__) logger.warn('[useMetadata] certification not returned from TMDB (extract path)', { type, fetchedTmdbId });
}
} else {
// Just set the TMDB ID without certification
setMetadata(prev => prev ? { ...prev, tmdbId: fetchedTmdbId } : null);
}
} else {
- if (__DEV__) console.warn('[useMetadata] Could not determine TMDB ID for recommendations / certification', { id });
+ if (__DEV__) logger.warn('[useMetadata] Could not determine TMDB ID for recommendations / certification', { id });
}
} catch (error) {
- if (__DEV__) console.error('[useMetadata] Error fetching TMDB ID (extract path):', error);
+ if (__DEV__) logger.error('[useMetadata] Error fetching TMDB ID (extract path):', error);
}
}
};
@@ -2318,7 +2247,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
if (tmdbId) {
// Check both master switch AND granular recommendations setting
if (settings.enrichMetadataWithTMDB && settings.tmdbEnrichRecommendations) {
- if (__DEV__) console.log('[useMetadata] tmdbId available; loading recommendations', { tmdbId });
+ if (__DEV__) logger.log('[useMetadata] tmdbId available; loading recommendations', { tmdbId });
loadRecommendations();
}
// Reset recommendations when tmdbId changes
@@ -2343,32 +2272,32 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
const maybeAttachCertification = async () => {
// Check both master switch AND granular certification setting
if (!settings.enrichMetadataWithTMDB || !settings.tmdbEnrichCertification) {
- if (__DEV__) console.log('[useMetadata] certification enrichment disabled; skip (attach path)');
+ if (__DEV__) logger.log('[useMetadata] certification enrichment disabled; skip (attach path)');
return;
}
try {
if (!metadata) {
- if (__DEV__) console.warn('[useMetadata] skip certification attach: metadata not ready');
+ if (__DEV__) logger.warn('[useMetadata] skip certification attach: metadata not ready');
return;
}
if (!tmdbId) {
- if (__DEV__) console.warn('[useMetadata] skip certification attach: tmdbId not available yet');
+ if (__DEV__) logger.warn('[useMetadata] skip certification attach: tmdbId not available yet');
return;
}
if ((metadata as any).certification) {
- if (__DEV__) console.log('[useMetadata] certification already present on metadata; skipping fetch');
+ if (__DEV__) logger.log('[useMetadata] certification already present on metadata; skipping fetch');
return;
}
const tmdbSvc = TMDBService.getInstance();
const cert = await tmdbSvc.getCertification(normalizedType, tmdbId);
if (cert) {
- if (__DEV__) console.log('[useMetadata] fetched certification (attach path)', { type, tmdbId, cert });
+ if (__DEV__) logger.log('[useMetadata] fetched certification (attach path)', { type, tmdbId, cert });
setMetadata(prev => prev ? { ...prev, tmdbId, certification: cert } : prev);
} else {
- if (__DEV__) console.warn('[useMetadata] TMDB returned no certification (attach path)', { type, tmdbId });
+ if (__DEV__) logger.warn('[useMetadata] TMDB returned no certification (attach path)', { type, tmdbId });
}
} catch (err) {
- if (__DEV__) console.error('[useMetadata] error attaching certification', err);
+ if (__DEV__) logger.error('[useMetadata] error attaching certification', err);
}
};
maybeAttachCertification();
@@ -2405,7 +2334,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
const tmdbService = TMDBService.getInstance();
let productionInfo: any[] = [];
- if (__DEV__) console.log('[useMetadata] fetchProductionInfo starting', {
+ if (__DEV__) logger.log('[useMetadata] fetchProductionInfo starting', {
contentKey,
type,
tmdbId,
@@ -2423,7 +2352,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
const lang = settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en';
const showDetails = await tmdbService.getTVShowDetails(tmdbId, lang);
if (showDetails) {
- if (__DEV__) console.log('[useMetadata] fetchProductionInfo got showDetails', {
+ if (__DEV__) logger.log('[useMetadata] fetchProductionInfo got showDetails', {
hasNetworks: !!showDetails.networks,
networksCount: showDetails.networks?.length || 0
});
@@ -2472,7 +2401,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
const lang = settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en';
const movieDetails = await tmdbService.getMovieDetails(String(tmdbId), lang);
if (movieDetails) {
- if (__DEV__) console.log('[useMetadata] fetchProductionInfo got movieDetails', {
+ if (__DEV__) logger.log('[useMetadata] fetchProductionInfo got movieDetails', {
hasProductionCompanies: !!movieDetails.production_companies,
productionCompaniesCount: movieDetails.production_companies?.length || 0
});
@@ -2557,7 +2486,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
}
}
} catch (error) {
- if (__DEV__) console.warn('[useMetadata] Failed to fetch movie images for:', part.id, error);
+ if (__DEV__) logger.warn('[useMetadata] Failed to fetch movie images for:', part.id, error);
}
return {
@@ -2592,7 +2521,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
}));
}
} catch (error) {
- if (__DEV__) console.error('[useMetadata] Error fetching collection:', error);
+ if (__DEV__) logger.error('[useMetadata] Error fetching collection:', error);
} finally {
setLoadingCollection(false);
}
@@ -2604,7 +2533,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
setMetadata((prev: any) => ({ ...prev, networks: productionInfo }));
}
} catch (error) {
- if (__DEV__) console.error('[useMetadata] Failed to fetch production info:', error);
+ if (__DEV__) logger.error('[useMetadata] Failed to fetch production info:', error);
}
};
@@ -2641,7 +2570,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
// Reset production info fetch tracking
productionInfoFetchedRef.current = null;
- if (__DEV__) console.log('[useMetadata] Component unmounted, memory cleaned up');
+ if (__DEV__) logger.log('[useMetadata] Component unmounted, memory cleaned up');
};
}, [cleanupStreams]);
diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts
index ca9dca76..98b74aa0 100644
--- a/src/hooks/useSettings.ts
+++ b/src/hooks/useSettings.ts
@@ -172,7 +172,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
// AI
aiChatEnabled: false,
// Metadata enrichment
- enrichMetadataWithTMDB: true,
+ enrichMetadataWithTMDB: false,
useTmdbLocalizedMetadata: false,
// Granular TMDB enrichment controls (all enabled by default for backward compatibility)
tmdbEnrichCast: true,
diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx
index 39597426..b789cbed 100644
--- a/src/navigation/AppNavigator.tsx
+++ b/src/navigation/AppNavigator.tsx
@@ -1941,10 +1941,8 @@ const ConditionalPostHogProvider: React.FC<{ children: React.ReactNode }> = ({ c
if (posthogRef.current) {
if (settings.analyticsEnabled) {
posthogRef.current.optIn();
- console.log('[Telemetry] PostHog opted in');
} else {
posthogRef.current.optOut();
- console.log('[Telemetry] PostHog opted out');
}
}
}
@@ -1996,10 +1994,8 @@ const PostHogOptController: React.FC<{
onPostHogReady(posthog);
if (enabled) {
posthog.optIn();
- console.log('[Telemetry] PostHog opted in');
} else {
posthog.optOut();
- console.log('[Telemetry] PostHog opted out');
}
}
}, [enabled, posthog, onPostHogReady]);
diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx
index 65eb0491..b7baa4c0 100644
--- a/src/screens/HomeScreen.tsx
+++ b/src/screens/HomeScreen.tsx
@@ -76,6 +76,26 @@ import { useScrollToTop } from '../contexts/ScrollToTopContext';
const CATALOG_SETTINGS_KEY = 'catalog_settings';
const MAX_CONCURRENT_CATALOG_REQUESTS = 4;
const HOME_LOADING_SCREEN_TIMEOUT_MS = 5000;
+const HOME_CATALOG_REQUEST_TIMEOUT_MS = 20000;
+
+const withTimeout = async (promise: Promise, timeoutMs: number): Promise => {
+ return new Promise((resolve, reject) => {
+ const timeoutId = setTimeout(() => {
+ reject(new Error(`Request timed out after ${timeoutMs}ms`));
+ }, timeoutMs);
+
+ promise.then(
+ (result) => {
+ clearTimeout(timeoutId);
+ resolve(result);
+ },
+ (error) => {
+ clearTimeout(timeoutId);
+ reject(error);
+ }
+ );
+ });
+};
// In-memory cache for catalog settings to avoid repeated MMKV reads
let cachedCatalogSettings: Record | null = null;
@@ -134,6 +154,7 @@ const HomeScreen = () => {
const [catalogs, setCatalogs] = useState<(CatalogContent | null)[]>([]);
const [catalogsLoading, setCatalogsLoading] = useState(true);
const [loadedCatalogCount, setLoadedCatalogCount] = useState(0);
+ const [pendingCatalogIndexes, setPendingCatalogIndexes] = useState>({});
const [hasAddons, setHasAddons] = useState(null);
const [hintVisible, setHintVisible] = useState(false);
const [loadingScreenTimedOut, setLoadingScreenTimedOut] = useState(false);
@@ -192,6 +213,7 @@ const HomeScreen = () => {
setCatalogsLoading(true);
setCatalogs([]);
setLoadedCatalogCount(0);
+ setPendingCatalogIndexes({});
try {
// Check cache first
@@ -279,13 +301,17 @@ const HomeScreen = () => {
if (isEnabled) {
const currentIndex = catalogIndex;
- const catalogLoader = async () => {
- try {
- const manifest = manifestByAddonId.get(addon.id);
- if (!manifest) return;
+ const catalogLoader = async () => {
+ try {
+ const manifest = manifestByAddonId.get(addon.id);
+ if (!manifest) return;
- const metas = await stremioService.getCatalog(manifest, catalog.type, catalog.id, 1);
- if (metas && metas.length > 0) {
+ const metas = await withTimeout(
+ stremioService.getCatalog(manifest, catalog.type, catalog.id, 1),
+ HOME_CATALOG_REQUEST_TIMEOUT_MS
+ );
+
+ if (metas && metas.length > 0) {
// Aggressively limit items per catalog on Android to reduce memory usage
const limit = Platform.OS === 'android' ? 18 : 30;
const limitedMetas = metas.slice(0, limit);
@@ -344,6 +370,15 @@ const HomeScreen = () => {
} catch (error) {
if (__DEV__) console.error(`[HomeScreen] Failed to load ${catalog.name} from ${addon.name}:`, error);
} finally {
+ setPendingCatalogIndexes((prev) => {
+ if (!prev[currentIndex]) {
+ return prev;
+ }
+ const next = { ...prev };
+ delete next[currentIndex];
+ return next;
+ });
+
// Update loading count - ensure on main thread
InteractionManager.runAfterInteractions(() => {
setLoadedCatalogCount(prev => {
@@ -362,8 +397,12 @@ const HomeScreen = () => {
}
};
- catalogQueue.push(catalogLoader);
- catalogIndex++;
+ catalogQueue.push(catalogLoader);
+ setPendingCatalogIndexes((prev) => ({
+ ...prev,
+ [currentIndex]: true,
+ }));
+ catalogIndex++;
}
}
}
@@ -722,7 +761,7 @@ const HomeScreen = () => {
catalogsToShow.forEach((catalog, index) => {
if (catalog) {
data.push({ type: 'catalog', catalog, key: `${catalog.addon}-${catalog.id}-${index}` });
- } else {
+ } else if (catalogsLoading && pendingCatalogIndexes[index]) {
// Add a key for placeholders
data.push({ type: 'placeholder', key: `placeholder-${index}` });
}
@@ -734,7 +773,7 @@ const HomeScreen = () => {
}
return data;
- }, [hasAddons, catalogs, visibleCatalogCount, settings.showThisWeekSection]);
+ }, [hasAddons, catalogs, catalogsLoading, pendingCatalogIndexes, visibleCatalogCount, settings.showThisWeekSection]);
const handleLoadMoreCatalogs = useCallback(() => {
setVisibleCatalogCount(prev => Math.min(prev + 3, catalogs.length));
diff --git a/src/screens/LibraryScreen.tsx b/src/screens/LibraryScreen.tsx
index 79b0d2ff..b22ada28 100644
--- a/src/screens/LibraryScreen.tsx
+++ b/src/screens/LibraryScreen.tsx
@@ -1782,11 +1782,8 @@ const LibraryScreen = () => {
setFilter(filterType);
}}
activeOpacity={0.7}
- >
- {iconName && (
-
- )}
-
+ {
style={styles.filtersContainer}
contentContainerStyle={styles.filtersContent}
>
- {renderFilter('trakt', 'Trakt', 'pan-tool')}
- {renderFilter('simkl', 'SIMKL', 'video-library')}
- {renderFilter('mal', 'MAL', 'book')}
- {renderFilter('movies', t('search.movies'), 'movie')}
- {renderFilter('series', t('search.tv_shows'), 'live-tv')}
+ {renderFilter('trakt', 'Trakt')}
+ {renderFilter('simkl', 'SIMKL')}
+ {renderFilter('mal', 'MAL')}
+ {renderFilter('movies', t('search.movies'))}
+ {renderFilter('series', t('search.tv_shows'))}
)}
diff --git a/src/screens/MDBListSettingsScreen.tsx b/src/screens/MDBListSettingsScreen.tsx
index ea29af37..c1c61cb9 100644
--- a/src/screens/MDBListSettingsScreen.tsx
+++ b/src/screens/MDBListSettingsScreen.tsx
@@ -26,39 +26,19 @@ import { useTheme } from '../contexts/ThemeContext';
import { logger } from '../utils/logger';
import { RATING_PROVIDERS } from '../components/metadata/RatingsSection';
import CustomAlert from '../components/CustomAlert'; // Moved CustomAlert import here
+import {
+ MDBLIST_API_KEY_STORAGE_KEY,
+ MDBLIST_ENABLED_STORAGE_KEY,
+ RATING_PROVIDERS_STORAGE_KEY,
+ isMDBListEnabled,
+ getMDBListAPIKey,
+} from '../services/mdblistConstants';
+
+// Re-export for backwards compatibility
+export { MDBLIST_API_KEY_STORAGE_KEY, RATING_PROVIDERS_STORAGE_KEY, MDBLIST_ENABLED_STORAGE_KEY, isMDBListEnabled, getMDBListAPIKey };
-export const MDBLIST_API_KEY_STORAGE_KEY = 'mdblist_api_key';
-export const RATING_PROVIDERS_STORAGE_KEY = 'rating_providers_config';
-export const MDBLIST_ENABLED_STORAGE_KEY = 'mdblist_enabled';
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
-// Function to check if MDBList is enabled
-export const isMDBListEnabled = async (): Promise => {
- try {
- const enabledSetting = await mmkvStorage.getItem(MDBLIST_ENABLED_STORAGE_KEY);
- return enabledSetting === 'true';
- } catch (error) {
- logger.error('[MDBList] Error checking if MDBList is enabled:', error);
- return false; // Default to disabled if there's an error
- }
-};
-
-// Function to get MDBList API key if enabled
-export const getMDBListAPIKey = async (): Promise => {
- try {
- const isEnabled = await isMDBListEnabled();
- if (!isEnabled) {
- logger.log('[MDBList] MDBList is disabled, not retrieving API key');
- return null;
- }
-
- return await mmkvStorage.getItem(MDBLIST_API_KEY_STORAGE_KEY);
- } catch (error) {
- logger.error('[MDBList] Error retrieving API key:', error);
- return null;
- }
-};
-
// Create a styles creator function that accepts the theme colors
const createStyles = (colors: any) => StyleSheet.create({
container: {
diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx
index c2538a04..d96ba25b 100644
--- a/src/screens/MetadataScreen.tsx
+++ b/src/screens/MetadataScreen.tsx
@@ -84,11 +84,19 @@ const BREAKPOINTS = {
const MemoizedRatingsSection = memo(RatingsSection);
const MemoizedCommentsSection = memo(CommentsSection);
const MemoizedCastDetailsModal = memo(CastDetailsModal);
+const noop = (..._args: unknown[]) => {};
+const logger = {
+ log: noop,
+ error: noop,
+ warn: noop,
+ info: noop,
+ debug: noop,
+};
// ... other imports
const MetadataScreen: React.FC = () => {
- useEffect(() => { console.log('â
MetadataScreen MOUNTED'); }, []);
+ useEffect(() => { logger.log('â
MetadataScreen MOUNTED'); }, []);
const navigation = useNavigation>();
const route = useRoute>();
const { id, type, episodeId, addonId } = route.params;
@@ -184,16 +192,16 @@ const MetadataScreen: React.FC = () => {
// Debug state changes
React.useEffect(() => {
- console.log('MetadataScreen: commentBottomSheetVisible changed to:', commentBottomSheetVisible);
+ logger.log('MetadataScreen: commentBottomSheetVisible changed to:', commentBottomSheetVisible);
}, [commentBottomSheetVisible]);
React.useEffect(() => {
- console.log('MetadataScreen: selectedComment changed to:', selectedComment?.id);
+ logger.log('MetadataScreen: selectedComment changed to:', selectedComment?.id);
}, [selectedComment]);
// Log useMetadata hook state changes for debugging
React.useEffect(() => {
- console.log('đ [MetadataScreen] useMetadata state:', {
+ logger.log('đ [MetadataScreen] useMetadata state:', {
loading,
hasMetadata: !!metadata,
metadataId: metadata?.id,
@@ -357,7 +365,7 @@ const MetadataScreen: React.FC = () => {
// Debug logging for color extraction timing
useEffect(() => {
if (__DEV__ && heroImageUri && dominantColor) {
- if (__DEV__) console.log('[MetadataScreen] Dynamic background color:', {
+ if (__DEV__) logger.log('[MetadataScreen] Dynamic background color:', {
dominantColor,
fallback: currentTheme.colors.darkBackground,
finalColor: dynamicBackgroundColor,
@@ -422,7 +430,7 @@ const MetadataScreen: React.FC = () => {
const isAuthenticated = await traktService.isAuthenticated();
if (!isAuthenticated) {
- if (__DEV__) console.log(`[MetadataScreen] Not authenticated with Trakt`);
+ if (__DEV__) logger.log(`[MetadataScreen] Not authenticated with Trakt`);
return;
}
@@ -449,7 +457,7 @@ const MetadataScreen: React.FC = () => {
if (relevantProgress.length === 0) return;
// Log only essential progress information for performance
- if (__DEV__) console.log(`[MetadataScreen] Found ${relevantProgress.length} Trakt progress items for ${type}`);
+ if (__DEV__) logger.log(`[MetadataScreen] Found ${relevantProgress.length} Trakt progress items for ${type}`);
// Find most recent progress if multiple episodes
if (Object.keys(groupedEpisodes).length > 0 && relevantProgress.length > 1) {
@@ -458,12 +466,12 @@ const MetadataScreen: React.FC = () => {
)[0];
if (mostRecent.episode && mostRecent.show) {
- if (__DEV__) console.log(`[MetadataScreen] Most recent: S${mostRecent.episode.season}E${mostRecent.episode.number} - ${mostRecent.progress.toFixed(1)}%`);
+ if (__DEV__) logger.log(`[MetadataScreen] Most recent: S${mostRecent.episode.season}E${mostRecent.episode.number} - ${mostRecent.progress.toFixed(1)}%`);
}
}
} catch (error) {
- if (__DEV__) console.error(`[MetadataScreen] Failed to fetch Trakt progress:`, error);
+ if (__DEV__) logger.error(`[MetadataScreen] Failed to fetch Trakt progress:`, error);
}
}, [shouldLoadSecondaryData, metadata, id, type]);
@@ -495,7 +503,7 @@ const MetadataScreen: React.FC = () => {
const timer = setTimeout(() => {
const renderTime = Date.now() - startTime;
if (renderTime > 100) {
- if (__DEV__) console.warn(`[MetadataScreen] Slow render detected: ${renderTime}ms for ${metadata.name}`);
+ if (__DEV__) logger.warn(`[MetadataScreen] Slow render detected: ${renderTime}ms for ${metadata.name}`);
}
}, 0);
return () => clearTimeout(timer);
@@ -513,11 +521,11 @@ const MetadataScreen: React.FC = () => {
const totalMB = Math.round(memory.totalJSHeapSize / 1048576);
const limitMB = Math.round(memory.jsHeapSizeLimit / 1048576);
- if (__DEV__) console.log(`[MetadataScreen] Memory usage: ${usedMB}MB / ${totalMB}MB (limit: ${limitMB}MB)`);
+ if (__DEV__) logger.log(`[MetadataScreen] Memory usage: ${usedMB}MB / ${totalMB}MB (limit: ${limitMB}MB)`);
// Trigger cleanup if memory usage is high
if (usedMB > limitMB * 0.8) {
- if (__DEV__) console.warn(`[MetadataScreen] High memory usage detected (${usedMB}MB), triggering cleanup`);
+ if (__DEV__) logger.warn(`[MetadataScreen] High memory usage detected (${usedMB}MB), triggering cleanup`);
// Force garbage collection if available
if (global.gc) {
global.gc();
@@ -536,16 +544,7 @@ const MetadataScreen: React.FC = () => {
// Memoized derived values for performance
const isReady = useMemo(() => !loading && metadata && !metadataError, [loading, metadata, metadataError]);
- // Log readiness state for debugging
- React.useEffect(() => {
- console.log('đ [MetadataScreen] Readiness state:', {
- isReady,
- loading,
- hasMetadata: !!metadata,
- hasError: !!metadataError,
- errorMessage: metadataError
- });
- }, [isReady, loading, metadata, metadataError]);
+
// Optimized content ready state management
useEffect(() => {
@@ -627,13 +626,13 @@ const MetadataScreen: React.FC = () => {
const nextEpisodeId = isImdb
? `${id}:${currentSeason || episodes[0]?.season_number || 1}:${currentEpisode + 1}`
: `${id}:${currentEpisode + 1}`;
- if (__DEV__) console.log(`[MetadataScreen] Created next episode ID: ${nextEpisodeId}`);
+ if (__DEV__) logger.log(`[MetadataScreen] Created next episode ID: ${nextEpisodeId}`);
const nextEpisodeExists = episodes.some(ep => ep.episode_number === (currentEpisode + 1));
if (nextEpisodeExists) {
- if (__DEV__) console.log(`[MetadataScreen] Verified next episode exists`);
+ if (__DEV__) logger.log(`[MetadataScreen] Verified next episode exists`);
} else {
- if (__DEV__) console.log(`[MetadataScreen] Warning: Next episode not found`);
+ if (__DEV__) logger.log(`[MetadataScreen] Warning: Next episode not found`);
}
targetEpisodeId = nextEpisodeId;
@@ -643,7 +642,7 @@ const MetadataScreen: React.FC = () => {
// Fallback logic: if not finished or nextEp not found
if (!targetEpisodeId) {
targetEpisodeId = watchProgress?.episodeId || episodeId || (episodes.length > 0 ? buildEpisodeId(episodes[0]) : undefined);
- if (__DEV__) console.log(`[MetadataScreen] Using fallback episode ID: ${targetEpisodeId}`);
+ if (__DEV__) logger.log(`[MetadataScreen] Using fallback episode ID: ${targetEpisodeId}`);
}
if (targetEpisodeId) {
@@ -657,7 +656,7 @@ const MetadataScreen: React.FC = () => {
else if (epParts.length === 2 && isImdb) {
normalizedEpisodeId = `${id}:${epParts[0]}:${epParts[1]}`;
}
- if (__DEV__) console.log(`[MetadataScreen] Navigating to streams with episodeId: ${normalizedEpisodeId}`);
+ if (__DEV__) logger.log(`[MetadataScreen] Navigating to streams with episodeId: ${normalizedEpisodeId}`);
navigation.navigate('Streams', { id, type, episodeId: normalizedEpisodeId });
return;
}
@@ -671,14 +670,14 @@ const MetadataScreen: React.FC = () => {
fallbackEpisodeId = isImdb ? `${id}:${p[0]}:${p[1]}` : `${id}:${p[1]}`;
}
}
- if (__DEV__) console.log(`[MetadataScreen] Navigating with fallback episodeId: ${fallbackEpisodeId}`);
+ if (__DEV__) logger.log(`[MetadataScreen] Navigating with fallback episodeId: ${fallbackEpisodeId}`);
navigation.navigate('Streams', { id, type, episodeId: fallbackEpisodeId });
}, [navigation, id, type, episodes, episodeId, watchProgressData.watchProgress]);
const handleEpisodeSelect = useCallback((episode: Episode) => {
if (!isScreenFocused) return;
- if (__DEV__) console.log('[MetadataScreen] Selected Episode:', episode.episode_number, episode.season_number);
+ if (__DEV__) logger.log('[MetadataScreen] Selected Episode:', episode.episode_number, episode.season_number);
let episodeId: string;
if (episode.stremioId) {
@@ -716,15 +715,11 @@ const MetadataScreen: React.FC = () => {
}, [isScreenFocused]);
const handleCommentPress = useCallback((comment: any) => {
- console.log('MetadataScreen: handleCommentPress called with comment:', comment?.id);
if (!isScreenFocused) {
- console.log('MetadataScreen: Screen not focused, ignoring');
return;
}
- console.log('MetadataScreen: Setting selected comment and opening bottomsheet');
setSelectedComment(comment);
setCommentBottomSheetVisible(true);
- console.log('MetadataScreen: State should be updated now');
}, [isScreenFocused]);
const handleCommentBottomSheetClose = useCallback(() => {
@@ -756,8 +751,8 @@ const MetadataScreen: React.FC = () => {
// Ultra-optimized animated styles - minimal calculations with conditional updates
const containerStyle = useAnimatedStyle(() => ({
- opacity: isScreenFocused ? animations.screenOpacity.value : 0.8,
- }), [isScreenFocused]);
+ opacity: animations.screenOpacity.value,
+ }), []);
const contentStyle = useAnimatedStyle(() => ({
opacity: animations.contentOpacity.value,
@@ -774,7 +769,6 @@ const MetadataScreen: React.FC = () => {
// Parse error to extract code and user-friendly message
const parseError = (error: string) => {
- console.log('đ Parsing error in MetadataScreen:', error);
// Check for HTTP status codes - handle multiple formats
// Match patterns like: "status code 500", "status": 500, "Request failed with status code 500"
@@ -785,7 +779,6 @@ const MetadataScreen: React.FC = () => {
if (statusCodeMatch) {
const code = parseInt(statusCodeMatch[1]);
- console.log('â
Found status code:', code);
switch (code) {
case 404:
return { code: '404', message: t('metadata.content_not_found'), userMessage: t('metadata.content_not_found_desc') };
@@ -893,13 +886,11 @@ const MetadataScreen: React.FC = () => {
// Show error if exists
if (metadataError || (!loading && !metadata)) {
- console.log('â MetadataScreen ERROR state:', { metadataError, loading, hasMetadata: !!metadata });
return ErrorComponent;
}
// Show loading screen if metadata is not yet available or exit animation hasn't completed
if (loading || !isContentReady || !loadingScreenExited) {
- console.log('âŗ MetadataScreen LOADING state:', { loading, isContentReady, loadingScreenExited, hasMetadata: !!metadata });
return (
{
{searching && results.byAddon.length === 0 ? (
-
+
+
+
) : searched && !hasResultsToShow && !searching ? (
diff --git a/src/services/campaignService.ts b/src/services/campaignService.ts
index 5b3ffe3f..5b3a9fa1 100644
--- a/src/services/campaignService.ts
+++ b/src/services/campaignService.ts
@@ -59,16 +59,12 @@ class CampaignService {
try {
const now = Date.now();
- console.log('[CampaignService] getActiveCampaign called, API URL:', CAMPAIGN_API_URL);
-
if (this.campaignQueue.length > 0 && (now - this.lastFetch) < this.CACHE_TTL) {
- console.log('[CampaignService] Using cached campaigns');
return this.getNextValidCampaign();
}
const platform = Platform.OS;
const url = `${CAMPAIGN_API_URL}/api/campaigns/queue?platform=${platform}`;
- console.log('[CampaignService] Fetching from:', url);
const response = await fetch(
`${CAMPAIGN_API_URL}/api/campaigns/queue?platform=${platform}`,
{
@@ -100,14 +96,11 @@ class CampaignService {
}
});
- console.log('[CampaignService] Fetched campaigns:', campaigns.length, 'CAMPAIGN_API_URL:', CAMPAIGN_API_URL);
-
this.campaignQueue = campaigns;
this.currentIndex = 0;
this.lastFetch = now;
const result = this.getNextValidCampaign();
- console.log('[CampaignService] Next valid campaign:', result?.id, result?.type);
return result;
} catch (error) {
console.warn('[CampaignService] Error fetching campaigns:', error);
diff --git a/src/services/catalog/catalog-utils.ts b/src/services/catalog/catalog-utils.ts
new file mode 100644
index 00000000..de273e57
--- /dev/null
+++ b/src/services/catalog/catalog-utils.ts
@@ -0,0 +1,84 @@
+import type { Manifest } from '../stremioService';
+
+import type { StreamingAddon, StreamingCatalog } from './types';
+
+export function convertManifestToStreamingAddon(manifest: Manifest): StreamingAddon {
+ return {
+ id: manifest.id,
+ name: manifest.name,
+ version: manifest.version,
+ description: manifest.description,
+ types: manifest.types || [],
+ catalogs: (manifest.catalogs || []).map(catalog => ({
+ ...catalog,
+ extraSupported: catalog.extraSupported || [],
+ extra: (catalog.extra || []).map(extra => ({
+ name: extra.name,
+ isRequired: extra.isRequired,
+ options: extra.options,
+ optionsLimit: extra.optionsLimit,
+ })),
+ })),
+ resources: manifest.resources || [],
+ url: (manifest.url || manifest.originalUrl) as any,
+ originalUrl: (manifest.originalUrl || manifest.url) as any,
+ transportUrl: manifest.url,
+ transportName: manifest.name,
+ };
+}
+
+export async function getAllAddons(getInstalledAddons: () => Promise): Promise {
+ const addons = await getInstalledAddons();
+ return addons.map(convertManifestToStreamingAddon);
+}
+
+export function catalogSupportsExtra(catalog: StreamingCatalog, extraName: string): boolean {
+ return (catalog.extraSupported || []).includes(extraName) ||
+ (catalog.extra || []).some(extra => extra.name === extraName);
+}
+
+export function getRequiredCatalogExtras(catalog: StreamingCatalog): string[] {
+ return (catalog.extra || []).filter(extra => extra.isRequired).map(extra => extra.name);
+}
+
+export function canBrowseCatalog(catalog: StreamingCatalog): boolean {
+ if (
+ (catalog.id && catalog.id.startsWith('search.')) ||
+ (catalog.type && catalog.type.startsWith('search'))
+ ) {
+ return false;
+ }
+
+ const requiredExtras = getRequiredCatalogExtras(catalog);
+ return requiredExtras.every(extraName => extraName === 'genre');
+}
+
+export function isVisibleOnHome(catalog: StreamingCatalog, addonCatalogs: StreamingCatalog[]): boolean {
+ if (
+ (catalog.id && catalog.id.startsWith('search.')) ||
+ (catalog.type && catalog.type.startsWith('search'))
+ ) {
+ return false;
+ }
+
+ const requiredExtras = getRequiredCatalogExtras(catalog);
+ if (requiredExtras.length > 0) {
+ return false;
+ }
+
+ const addonUsesShowInHome = addonCatalogs.some(addonCatalog => addonCatalog.showInHome === true);
+ if (addonUsesShowInHome) {
+ return catalog.showInHome === true;
+ }
+
+ return true;
+}
+
+export function canSearchCatalog(catalog: StreamingCatalog): boolean {
+ if (!catalogSupportsExtra(catalog, 'search')) {
+ return false;
+ }
+
+ const requiredExtras = getRequiredCatalogExtras(catalog);
+ return requiredExtras.every(extraName => extraName === 'search');
+}
diff --git a/src/services/catalog/content-details.ts b/src/services/catalog/content-details.ts
new file mode 100644
index 00000000..c30fb6e7
--- /dev/null
+++ b/src/services/catalog/content-details.ts
@@ -0,0 +1,166 @@
+import { stremioService } from '../stremioService';
+import { mmkvStorage } from '../mmkvStorage';
+import { TMDBService } from '../tmdbService';
+import { logger } from '../../utils/logger';
+
+import { convertMetaToStreamingContent, convertMetaToStreamingContentEnhanced } from './content-mappers';
+import { addToRecentContent, createLibraryKey, type CatalogLibraryState } from './library';
+import { DATA_SOURCE_KEY, DataSource, type StreamingContent } from './types';
+
+export async function getDataSourcePreference(): Promise {
+ try {
+ const dataSource = await mmkvStorage.getItem(DATA_SOURCE_KEY);
+ return (dataSource as DataSource) || DataSource.STREMIO_ADDONS;
+ } catch (error) {
+ logger.error('Failed to get data source preference:', error);
+ return DataSource.STREMIO_ADDONS;
+ }
+}
+
+export async function setDataSourcePreference(dataSource: DataSource): Promise {
+ try {
+ await mmkvStorage.setItem(DATA_SOURCE_KEY, dataSource);
+ } catch (error) {
+ logger.error('Failed to set data source preference:', error);
+ }
+}
+
+export async function getContentDetails(
+ state: CatalogLibraryState,
+ type: string,
+ id: string,
+ preferredAddonId?: string
+): Promise {
+ try {
+ let meta = null;
+ let lastError = null;
+
+ for (let attempt = 0; attempt < 2; attempt += 1) {
+ try {
+
+ const isValidId = await stremioService.isValidContentId(type, id);
+
+ if (!isValidId) {
+ break;
+ }
+ meta = await stremioService.getMetaDetails(type, id, preferredAddonId);
+
+ if (meta) {
+ break;
+ }
+
+ await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, attempt)));
+ } catch (error) {
+ lastError = error;
+ logger.error(`Attempt ${attempt + 1} failed to get content details for ${type}:${id}:`, error);
+ await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, attempt)));
+ }
+ }
+
+ if (meta) {
+ const content = convertMetaToStreamingContentEnhanced(meta, state.library);
+ addToRecentContent(state, content);
+ content.inLibrary = state.library[createLibraryKey(type, id)] !== undefined;
+
+ return content;
+ }
+
+ if (lastError) {
+ throw lastError;
+ }
+ return null;
+ } catch (error) {
+ logger.error(`Failed to get content details for ${type}:${id}:`, error);
+ return null;
+ }
+}
+
+export async function getEnhancedContentDetails(
+ state: CatalogLibraryState,
+ type: string,
+ id: string,
+ preferredAddonId?: string
+): Promise {
+
+ try {
+ const result = await getContentDetails(state, type, id, preferredAddonId);
+ return result;
+ } catch (error) {
+ throw error;
+ }
+}
+
+export async function getBasicContentDetails(
+ state: CatalogLibraryState,
+ type: string,
+ id: string,
+ preferredAddonId?: string
+): Promise {
+ try {
+ let meta = null;
+ let lastError = null;
+
+ for (let attempt = 0; attempt < 3; attempt += 1) {
+ try {
+ if (!(await stremioService.isValidContentId(type, id))) {
+ break;
+ }
+
+ meta = await stremioService.getMetaDetails(type, id, preferredAddonId);
+ if (meta) {
+ break;
+ }
+
+ await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, attempt)));
+ } catch (error) {
+ lastError = error;
+ logger.error(`Attempt ${attempt + 1} failed to get basic content details for ${type}:${id}:`, error);
+ await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, attempt)));
+ }
+ }
+
+ if (meta) {
+ const content = convertMetaToStreamingContent(meta, state.library);
+ content.inLibrary = state.library[createLibraryKey(type, id)] !== undefined;
+ return content;
+ }
+
+ if (lastError) {
+ throw lastError;
+ }
+
+ return null;
+ } catch (error) {
+ logger.error(`Failed to get basic content details for ${type}:${id}:`, error);
+ return null;
+ }
+}
+
+export async function getStremioId(type: string, tmdbId: string): Promise {
+ try {
+ if (type === 'movie') {
+ const movieDetails = await TMDBService.getInstance().getMovieDetails(tmdbId);
+
+ if (movieDetails?.imdb_id) {
+ return movieDetails.imdb_id;
+ }
+ return null;
+ }
+
+ if (type === 'tv' || type === 'series') {
+ const externalIds = await TMDBService.getInstance().getShowExternalIds(parseInt(tmdbId, 10));
+
+ if (externalIds?.imdb_id) {
+ return externalIds.imdb_id;
+ }
+
+ const fallbackId = `kitsu:${tmdbId}`;
+ return fallbackId;
+ }
+
+ return null;
+ } catch (error: any) {
+ logger.error('Error getting Stremio ID:', error);
+ return null;
+ }
+}
diff --git a/src/services/catalog/content-mappers.ts b/src/services/catalog/content-mappers.ts
new file mode 100644
index 00000000..e61dc8d9
--- /dev/null
+++ b/src/services/catalog/content-mappers.ts
@@ -0,0 +1,157 @@
+import { TMDBService } from '../tmdbService';
+import { logger } from '../../utils/logger';
+
+import type { Meta } from '../stremioService';
+
+import { createLibraryKey } from './library';
+import type { StreamingContent } from './types';
+
+const FALLBACK_POSTER_URL = 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image';
+
+export function convertMetaToStreamingContent(
+ meta: Meta,
+ library: Record
+): StreamingContent {
+ let posterUrl = meta.poster;
+ if (!posterUrl || posterUrl.trim() === '' || posterUrl === 'null' || posterUrl === 'undefined') {
+ posterUrl = FALLBACK_POSTER_URL;
+ }
+
+ let logoUrl = (meta as any).logo;
+ if (!logoUrl || logoUrl.trim() === '' || logoUrl === 'null' || logoUrl === 'undefined') {
+ logoUrl = undefined;
+ }
+
+ return {
+ id: meta.id,
+ type: meta.type,
+ name: meta.name,
+ poster: posterUrl,
+ posterShape: meta.posterShape || 'poster',
+ banner: meta.background,
+ logo: logoUrl,
+ imdbRating: meta.imdbRating,
+ year: meta.year,
+ genres: meta.genres,
+ description: meta.description,
+ runtime: meta.runtime,
+ inLibrary: library[createLibraryKey(meta.type, meta.id)] !== undefined,
+ certification: meta.certification,
+ releaseInfo: meta.releaseInfo,
+ };
+}
+
+export function convertMetaToStreamingContentEnhanced(
+ meta: Meta,
+ library: Record
+): StreamingContent {
+ const converted: StreamingContent = {
+ id: meta.id,
+ type: meta.type,
+ name: meta.name,
+ poster: meta.poster || FALLBACK_POSTER_URL,
+ posterShape: meta.posterShape || 'poster',
+ banner: meta.background,
+ logo: (meta as any).logo || undefined,
+ imdbRating: meta.imdbRating,
+ year: meta.year,
+ genres: meta.genres,
+ description: meta.description,
+ runtime: meta.runtime,
+ inLibrary: library[createLibraryKey(meta.type, meta.id)] !== undefined,
+ certification: meta.certification,
+ directors: (meta as any).director
+ ? (Array.isArray((meta as any).director) ? (meta as any).director : [(meta as any).director])
+ : undefined,
+ writer: (meta as any).writer || undefined,
+ country: (meta as any).country || undefined,
+ imdb_id: (meta as any).imdb_id || undefined,
+ slug: (meta as any).slug || undefined,
+ releaseInfo: meta.releaseInfo || (meta as any).releaseInfo || undefined,
+ trailerStreams: (meta as any).trailerStreams || undefined,
+ links: (meta as any).links || undefined,
+ behaviorHints: (meta as any).behaviorHints || undefined,
+ };
+
+ if ((meta as any).app_extras?.cast && Array.isArray((meta as any).app_extras.cast)) {
+ converted.addonCast = (meta as any).app_extras.cast.map((castMember: any, index: number) => ({
+ id: index + 1,
+ name: castMember.name || 'Unknown',
+ character: castMember.character || '',
+ profile_path: castMember.photo || null,
+ }));
+ } else if (meta.cast && Array.isArray(meta.cast)) {
+ converted.addonCast = meta.cast.map((castName: string, index: number) => ({
+ id: index + 1,
+ name: castName || 'Unknown',
+ character: '',
+ profile_path: null,
+ }));
+ }
+
+ if ((meta as any).trailerStreams?.length > 0) {
+ logger.log(`đŦ Enhanced metadata: Found ${(meta as any).trailerStreams.length} trailers for ${meta.name}`);
+ }
+
+ if ((meta as any).links?.length > 0) {
+ logger.log(`đ Enhanced metadata: Found ${(meta as any).links.length} links for ${meta.name}`);
+ }
+
+ if (converted.addonCast && converted.addonCast.length > 0) {
+ logger.log(`đ Enhanced metadata: Found ${converted.addonCast.length} cast members from addon for ${meta.name}`);
+ }
+
+ if ((meta as any).videos) {
+ converted.videos = (meta as any).videos;
+ }
+
+ return converted;
+}
+
+export async function convertTMDBToStreamingContent(
+ item: any,
+ type: 'movie' | 'tv',
+ library: Record
+): Promise {
+ const id = item.external_ids?.imdb_id || `tmdb:${item.id}`;
+ const name = type === 'movie' ? item.title : item.name;
+ const posterPath = item.poster_path;
+
+ let genres: string[] = [];
+ if (item.genre_ids && item.genre_ids.length > 0) {
+ try {
+ const tmdbService = TMDBService.getInstance();
+ const genreLists = type === 'movie'
+ ? await tmdbService.getMovieGenres()
+ : await tmdbService.getTvGenres();
+
+ genres = item.genre_ids
+ .map((genreId: number) => {
+ const genre = genreLists.find(currentGenre => currentGenre.id === genreId);
+ return genre ? genre.name : null;
+ })
+ .filter(Boolean) as string[];
+ } catch (error) {
+ logger.error('Failed to get genres for TMDB content:', error);
+ }
+ }
+
+ const contentType = type === 'movie' ? 'movie' : 'series';
+
+ return {
+ id,
+ type: contentType,
+ name: name || 'Unknown',
+ poster: posterPath
+ ? `https://image.tmdb.org/t/p/w500${posterPath}`
+ : FALLBACK_POSTER_URL,
+ posterShape: 'poster',
+ banner: item.backdrop_path ? `https://image.tmdb.org/t/p/original${item.backdrop_path}` : undefined,
+ year: type === 'movie'
+ ? (item.release_date ? new Date(item.release_date).getFullYear() : undefined)
+ : (item.first_air_date ? new Date(item.first_air_date).getFullYear() : undefined),
+ description: item.overview,
+ genres,
+ inLibrary: library[createLibraryKey(contentType, id)] !== undefined,
+ };
+}
diff --git a/src/services/catalog/discovery.ts b/src/services/catalog/discovery.ts
new file mode 100644
index 00000000..8189449f
--- /dev/null
+++ b/src/services/catalog/discovery.ts
@@ -0,0 +1,398 @@
+import { stremioService } from '../stremioService';
+import { TMDBService } from '../tmdbService';
+import { logger } from '../../utils/logger';
+import { getCatalogDisplayName } from '../../utils/catalogNameUtils';
+
+import {
+ canBrowseCatalog,
+ convertManifestToStreamingAddon,
+ getAllAddons,
+ isVisibleOnHome,
+} from './catalog-utils';
+import { convertMetaToStreamingContent, convertTMDBToStreamingContent } from './content-mappers';
+import type { CatalogContent, DataSource, StreamingAddon, StreamingCatalog, StreamingContent } from './types';
+
+export async function getAllStreamingAddons(): Promise {
+ return getAllAddons(() => stremioService.getInstalledAddonsAsync());
+}
+
+export async function resolveHomeCatalogsToFetch(
+ limitIds?: string[]
+): Promise> {
+ const addons = await getAllStreamingAddons();
+ const potentialCatalogs: Array<{ addon: StreamingAddon; catalog: StreamingCatalog }> = [];
+
+ for (const addon of addons) {
+ for (const catalog of addon.catalogs || []) {
+ if (isVisibleOnHome(catalog, addon.catalogs)) {
+ potentialCatalogs.push({ addon, catalog });
+ }
+ }
+ }
+
+ if (limitIds && limitIds.length > 0) {
+ return potentialCatalogs.filter(item => {
+ const catalogId = `${item.addon.id}:${item.catalog.type}:${item.catalog.id}`;
+ return limitIds.includes(catalogId);
+ });
+ }
+
+ return potentialCatalogs.sort(() => 0.5 - Math.random()).slice(0, 5);
+}
+
+export async function fetchHomeCatalog(
+ library: Record,
+ addon: StreamingAddon,
+ catalog: StreamingCatalog
+): Promise {
+ try {
+ const addonManifests = await stremioService.getInstalledAddonsAsync();
+ const manifest = addonManifests.find(currentAddon => currentAddon.id === addon.id);
+ if (!manifest) {
+ return null;
+ }
+
+ const metas = await stremioService.getCatalog(manifest, catalog.type, catalog.id, 1);
+ if (!metas || metas.length === 0) {
+ return null;
+ }
+
+ const items = metas.slice(0, 12).map(meta => convertMetaToStreamingContent(meta, library));
+ const originalName = catalog.name || catalog.id;
+ let displayName = await getCatalogDisplayName(addon.id, catalog.type, catalog.id, originalName);
+ const isCustom = displayName !== originalName;
+
+ if (!isCustom) {
+ const uniqueWords: string[] = [];
+ const seenWords = new Set();
+
+ for (const word of displayName.split(' ')) {
+ const normalizedWord = word.toLowerCase();
+ if (!seenWords.has(normalizedWord)) {
+ uniqueWords.push(word);
+ seenWords.add(normalizedWord);
+ }
+ }
+
+ displayName = uniqueWords.join(' ');
+
+ const contentType = catalog.type === 'movie' ? 'Movies' : 'TV Shows';
+ if (!displayName.toLowerCase().includes(contentType.toLowerCase())) {
+ displayName = `${displayName} ${contentType}`;
+ }
+ }
+
+ return {
+ addon: addon.id,
+ type: catalog.type,
+ id: catalog.id,
+ name: displayName,
+ items,
+ };
+ } catch (error) {
+ logger.error(`Failed to load ${catalog.name} from ${addon.name}:`, error);
+ return null;
+ }
+}
+
+export async function getHomeCatalogs(
+ library: Record,
+ limitIds?: string[]
+): Promise {
+ const catalogsToFetch = await resolveHomeCatalogsToFetch(limitIds);
+ const catalogResults = await Promise.all(
+ catalogsToFetch.map(({ addon, catalog }) => fetchHomeCatalog(library, addon, catalog))
+ );
+
+ return catalogResults.filter((catalog): catalog is CatalogContent => catalog !== null);
+}
+
+export async function getCatalogByType(
+ library: Record,
+ dataSourcePreference: DataSource,
+ type: string,
+ genreFilter?: string
+): Promise {
+ if (dataSourcePreference === 'tmdb') {
+ return getCatalogByTypeFromTMDB(library, type, genreFilter);
+ }
+
+ const addons = await getAllStreamingAddons();
+ const typeAddons = addons.filter(addon => addon.catalogs.some(catalog => catalog.type === type));
+ const manifests = await stremioService.getInstalledAddonsAsync();
+ const manifestMap = new Map(manifests.map(manifest => [manifest.id, manifest]));
+ const catalogPromises: Array> = [];
+
+ for (const addon of typeAddons) {
+ const typeCatalogs = addon.catalogs.filter(
+ catalog => catalog.type === type && isVisibleOnHome(catalog, addon.catalogs)
+ );
+
+ for (const catalog of typeCatalogs) {
+ catalogPromises.push(
+ (async () => {
+ try {
+ const manifest = manifestMap.get(addon.id);
+ if (!manifest) {
+ return null;
+ }
+
+ const filters = genreFilter ? [{ title: 'genre', value: genreFilter }] : [];
+ const metas = await stremioService.getCatalog(manifest, type, catalog.id, 1, filters);
+
+ if (!metas || metas.length === 0) {
+ return null;
+ }
+
+ return {
+ addon: addon.id,
+ type,
+ id: catalog.id,
+ name: await getCatalogDisplayName(addon.id, catalog.type, catalog.id, catalog.name),
+ genre: genreFilter,
+ items: metas.map(meta => convertMetaToStreamingContent(meta, library)),
+ };
+ } catch (error) {
+ logger.error(`Failed to get catalog ${catalog.id} for addon ${addon.id}:`, error);
+ return null;
+ }
+ })()
+ );
+ }
+ }
+
+ const catalogResults = await Promise.all(catalogPromises);
+ return catalogResults.filter((catalog): catalog is CatalogContent => catalog !== null);
+}
+
+async function getCatalogByTypeFromTMDB(
+ library: Record,
+ type: string,
+ genreFilter?: string
+): Promise {
+ const tmdbService = TMDBService.getInstance();
+ const tmdbType = type === 'movie' ? 'movie' : 'tv';
+
+ try {
+ if (!genreFilter || genreFilter === 'All') {
+ return Promise.all([
+ (async () => ({
+ addon: 'tmdb',
+ type,
+ id: 'trending',
+ name: `Trending ${type === 'movie' ? 'Movies' : 'TV Shows'}`,
+ items: await Promise.all(
+ (await tmdbService.getTrending(tmdbType, 'week')).map(item =>
+ convertTMDBToStreamingContent(item, tmdbType, library)
+ )
+ ),
+ }))(),
+ (async () => ({
+ addon: 'tmdb',
+ type,
+ id: 'popular',
+ name: `Popular ${type === 'movie' ? 'Movies' : 'TV Shows'}`,
+ items: await Promise.all(
+ (await tmdbService.getPopular(tmdbType, 1)).map(item =>
+ convertTMDBToStreamingContent(item, tmdbType, library)
+ )
+ ),
+ }))(),
+ (async () => ({
+ addon: 'tmdb',
+ type,
+ id: 'upcoming',
+ name: type === 'movie' ? 'Upcoming Movies' : 'On Air TV Shows',
+ items: await Promise.all(
+ (await tmdbService.getUpcoming(tmdbType, 1)).map(item =>
+ convertTMDBToStreamingContent(item, tmdbType, library)
+ )
+ ),
+ }))(),
+ ]);
+ }
+
+ return [{
+ addon: 'tmdb',
+ type,
+ id: 'discover',
+ name: `${genreFilter} ${type === 'movie' ? 'Movies' : 'TV Shows'}`,
+ genre: genreFilter,
+ items: await Promise.all(
+ (await tmdbService.discoverByGenre(tmdbType, genreFilter)).map(item =>
+ convertTMDBToStreamingContent(item, tmdbType, library)
+ )
+ ),
+ }];
+ } catch (error) {
+ logger.error(`Failed to get catalog from TMDB for type ${type}, genre ${genreFilter}:`, error);
+ return [];
+ }
+}
+
+export async function getDiscoverFilters(): Promise<{
+ genres: string[];
+ types: string[];
+ catalogsByType: Record<
+ string,
+ Array<{ addonId: string; addonName: string; catalogId: string; catalogName: string; genres: string[] }>
+ >;
+}> {
+ const addons = await getAllStreamingAddons();
+ const allGenres = new Set();
+ const allTypes = new Set();
+ const catalogsByType: Record<
+ string,
+ Array<{ addonId: string; addonName: string; catalogId: string; catalogName: string; genres: string[] }>
+ > = {};
+
+ for (const addon of addons) {
+ for (const catalog of addon.catalogs || []) {
+ if (!canBrowseCatalog(catalog)) {
+ continue;
+ }
+
+ if (catalog.type) {
+ allTypes.add(catalog.type);
+ }
+
+ const catalogGenres: string[] = [];
+ for (const extra of catalog.extra || []) {
+ if (extra.name === 'genre' && Array.isArray(extra.options)) {
+ for (const genre of extra.options) {
+ allGenres.add(genre);
+ catalogGenres.push(genre);
+ }
+ }
+ }
+
+ if (catalog.type) {
+ catalogsByType[catalog.type] ||= [];
+ catalogsByType[catalog.type].push({
+ addonId: addon.id,
+ addonName: addon.name,
+ catalogId: catalog.id,
+ catalogName: catalog.name || catalog.id,
+ genres: catalogGenres,
+ });
+ }
+ }
+ }
+
+ return {
+ genres: Array.from(allGenres).sort((left, right) => left.localeCompare(right)),
+ types: Array.from(allTypes),
+ catalogsByType,
+ };
+}
+
+export async function discoverContent(
+ library: Record,
+ type: string,
+ genre?: string,
+ limit = 20
+): Promise> {
+ const addons = await getAllStreamingAddons();
+ const manifests = await stremioService.getInstalledAddonsAsync();
+ const manifestMap = new Map(manifests.map(manifest => [manifest.id, manifest]));
+ const catalogPromises: Array> = [];
+
+ for (const addon of addons) {
+ const matchingCatalogs = addon.catalogs.filter(
+ catalog => catalog.type === type && canBrowseCatalog(catalog)
+ );
+
+ for (const catalog of matchingCatalogs) {
+ const supportsGenre = catalog.extra?.some(extra => extra.name === 'genre') ||
+ catalog.extraSupported?.includes('genre');
+
+ if (genre && !supportsGenre) {
+ continue;
+ }
+
+ const manifest = manifestMap.get(addon.id);
+ if (!manifest) {
+ continue;
+ }
+
+ catalogPromises.push(
+ (async () => {
+ try {
+ const filters = genre ? [{ title: 'genre', value: genre }] : [];
+ const metas = await stremioService.getCatalog(manifest, type, catalog.id, 1, filters);
+
+ if (!metas || metas.length === 0) {
+ return null;
+ }
+
+ return {
+ addonName: addon.name,
+ items: metas.slice(0, limit).map(meta => ({
+ ...convertMetaToStreamingContent(meta, library),
+ addonId: addon.id,
+ })),
+ };
+ } catch (error) {
+ logger.error(`Discover failed for ${catalog.id} in addon ${addon.id}:`, error);
+ return null;
+ }
+ })()
+ );
+ }
+ }
+
+ const addonMap = new Map();
+ for (const result of await Promise.all(catalogPromises)) {
+ if (!result || result.items.length === 0) {
+ continue;
+ }
+
+ const existingItems = addonMap.get(result.addonName) || [];
+ const existingIds = new Set(existingItems.map(item => `${item.type}:${item.id}`));
+ const newItems = result.items.filter(item => !existingIds.has(`${item.type}:${item.id}`));
+ addonMap.set(result.addonName, [...existingItems, ...newItems]);
+ }
+
+ return Array.from(addonMap.entries()).map(([addonName, items]) => ({
+ addonName,
+ items: items.slice(0, limit),
+ }));
+}
+
+export async function discoverContentFromCatalog(
+ library: Record,
+ addonId: string,
+ catalogId: string,
+ type: string,
+ genre?: string,
+ page = 1
+): Promise {
+ try {
+ const manifests = await stremioService.getInstalledAddonsAsync();
+ const manifest = manifests.find(currentManifest => currentManifest.id === addonId);
+
+ if (!manifest) {
+ logger.error(`Addon ${addonId} not found`);
+ return [];
+ }
+
+ const catalog = (manifest.catalogs || []).find(item => item.type === type && item.id === catalogId);
+ if (!catalog || !canBrowseCatalog(convertManifestToStreamingAddon(manifest).catalogs.find(
+ item => item.type === type && item.id === catalogId
+ ) || { ...catalog, extraSupported: catalog.extraSupported || [], extra: catalog.extra || [] })) {
+ logger.warn(`Catalog ${catalogId} in addon ${addonId} is not browseable`);
+ return [];
+ }
+
+ const filters = genre ? [{ title: 'genre', value: genre }] : [];
+ const metas = await stremioService.getCatalog(manifest, type, catalogId, page, filters);
+
+ return (metas || []).map(meta => ({
+ ...convertMetaToStreamingContent(meta, library),
+ addonId,
+ }));
+ } catch (error) {
+ logger.error(`Discover from catalog failed for ${addonId}/${catalogId}:`, error);
+ return [];
+ }
+}
diff --git a/src/services/catalog/library.ts b/src/services/catalog/library.ts
new file mode 100644
index 00000000..fe99eb3e
--- /dev/null
+++ b/src/services/catalog/library.ts
@@ -0,0 +1,330 @@
+import { mmkvStorage } from '../mmkvStorage';
+import { logger } from '../../utils/logger';
+
+import type { StreamingContent } from './types';
+
+// Lazy import to break require cycle:
+// catalogService -> content-details -> content-mappers -> library -> notificationService -> catalogService
+const getNotificationService = () =>
+ require('../notificationService').notificationService;
+
+export interface CatalogLibraryState {
+ LEGACY_LIBRARY_KEY: string;
+ RECENT_CONTENT_KEY: string;
+ MAX_RECENT_ITEMS: number;
+ library: Record;
+ recentContent: StreamingContent[];
+ librarySubscribers: Array<(items: StreamingContent[]) => void>;
+ libraryAddListeners: Array<(item: StreamingContent) => void>;
+ libraryRemoveListeners: Array<(type: string, id: string) => void>;
+ initPromise: Promise;
+ isInitialized: boolean;
+}
+
+export function createLibraryKey(type: string, id: string): string {
+ return `${type}:${id}`;
+}
+
+export async function initializeCatalogState(state: CatalogLibraryState): Promise {
+ logger.log('[CatalogService] Starting initialization...');
+
+ try {
+ logger.log('[CatalogService] Step 1: Initializing scope...');
+ await initializeScope();
+
+ logger.log('[CatalogService] Step 2: Loading library...');
+ await loadLibrary(state);
+
+ logger.log('[CatalogService] Step 3: Loading recent content...');
+ await loadRecentContent(state);
+
+ state.isInitialized = true;
+ logger.log(
+ `[CatalogService] Initialization completed successfully. Library contains ${Object.keys(state.library).length} items.`
+ );
+ } catch (error) {
+ logger.error('[CatalogService] Initialization failed:', error);
+ state.isInitialized = true;
+ }
+}
+
+export async function ensureCatalogInitialized(state: CatalogLibraryState): Promise {
+ logger.log(`[CatalogService] ensureInitialized() called. isInitialized: ${state.isInitialized}`);
+
+ try {
+ await state.initPromise;
+ logger.log(
+ `[CatalogService] ensureInitialized() completed. Library ready with ${Object.keys(state.library).length} items.`
+ );
+ } catch (error) {
+ logger.error('[CatalogService] Error waiting for initialization:', error);
+ }
+}
+
+async function initializeScope(): Promise {
+ try {
+ const currentScope = await mmkvStorage.getItem('@user:current');
+
+ if (!currentScope) {
+ await mmkvStorage.setItem('@user:current', 'local');
+ logger.log('[CatalogService] Initialized @user:current scope to "local"');
+ return;
+ }
+
+ logger.log(`[CatalogService] Using existing scope: "${currentScope}"`);
+ } catch (error) {
+ logger.error('[CatalogService] Failed to initialize scope:', error);
+ }
+}
+
+async function loadLibrary(state: CatalogLibraryState): Promise {
+ try {
+ const scope = (await mmkvStorage.getItem('@user:current')) || 'local';
+ const scopedKey = `@user:${scope}:stremio-library`;
+ let storedLibrary = await mmkvStorage.getItem(scopedKey);
+
+ if (!storedLibrary) {
+ storedLibrary = await mmkvStorage.getItem(state.LEGACY_LIBRARY_KEY);
+ if (storedLibrary) {
+ await mmkvStorage.setItem(scopedKey, storedLibrary);
+ }
+ }
+
+ if (storedLibrary) {
+ const parsedLibrary = JSON.parse(storedLibrary);
+ logger.log(
+ `[CatalogService] Raw library data type: ${Array.isArray(parsedLibrary) ? 'ARRAY' : 'OBJECT'}, keys: ${JSON.stringify(Object.keys(parsedLibrary).slice(0, 5))}`
+ );
+
+ if (Array.isArray(parsedLibrary)) {
+ logger.log('[CatalogService] WARNING: Library is stored as ARRAY format. Converting to OBJECT format.');
+ const libraryObject: Record = {};
+
+ for (const item of parsedLibrary) {
+ libraryObject[createLibraryKey(item.type, item.id)] = item;
+ }
+
+ state.library = libraryObject;
+ logger.log(`[CatalogService] Converted ${parsedLibrary.length} items from array to object format`);
+
+ const normalizedLibrary = JSON.stringify(state.library);
+ await mmkvStorage.setItem(scopedKey, normalizedLibrary);
+ await mmkvStorage.setItem(state.LEGACY_LIBRARY_KEY, normalizedLibrary);
+ logger.log('[CatalogService] Re-saved library in correct format');
+ } else {
+ state.library = parsedLibrary;
+ }
+
+ logger.log(
+ `[CatalogService] Library loaded successfully with ${Object.keys(state.library).length} items from scope: ${scope}`
+ );
+ } else {
+ logger.log(`[CatalogService] No library data found for scope: ${scope}`);
+ state.library = {};
+ }
+
+ await mmkvStorage.setItem('@user:current', scope);
+ } catch (error: any) {
+ logger.error('Failed to load library:', error);
+ state.library = {};
+ }
+}
+
+async function saveLibrary(state: CatalogLibraryState): Promise {
+ if (state.isInitialized) {
+ await ensureCatalogInitialized(state);
+ }
+
+ try {
+ const itemCount = Object.keys(state.library).length;
+ const scope = (await mmkvStorage.getItem('@user:current')) || 'local';
+ const scopedKey = `@user:${scope}:stremio-library`;
+ const libraryData = JSON.stringify(state.library);
+
+ logger.log(`[CatalogService] Saving library with ${itemCount} items to scope: "${scope}" (key: ${scopedKey})`);
+
+ await mmkvStorage.setItem(scopedKey, libraryData);
+ await mmkvStorage.setItem(state.LEGACY_LIBRARY_KEY, libraryData);
+
+ logger.log(`[CatalogService] Library saved successfully with ${itemCount} items`);
+ } catch (error: any) {
+ logger.error('Failed to save library:', error);
+ logger.error(
+ `[CatalogService] Library save failed details - scope: ${(await mmkvStorage.getItem('@user:current')) || 'unknown'}, itemCount: ${Object.keys(state.library).length}`
+ );
+ }
+}
+
+async function loadRecentContent(state: CatalogLibraryState): Promise {
+ try {
+ const storedRecentContent = await mmkvStorage.getItem(state.RECENT_CONTENT_KEY);
+ if (storedRecentContent) {
+ state.recentContent = JSON.parse(storedRecentContent);
+ }
+ } catch (error: any) {
+ logger.error('Failed to load recent content:', error);
+ }
+}
+
+async function saveRecentContent(state: CatalogLibraryState): Promise {
+ try {
+ await mmkvStorage.setItem(state.RECENT_CONTENT_KEY, JSON.stringify(state.recentContent));
+ } catch (error: any) {
+ logger.error('Failed to save recent content:', error);
+ }
+}
+
+function notifyLibrarySubscribers(state: CatalogLibraryState): void {
+ const items = Object.values(state.library);
+ state.librarySubscribers.forEach(callback => callback(items));
+}
+
+export async function getLibraryItems(state: CatalogLibraryState): Promise {
+ if (!state.isInitialized) {
+ await ensureCatalogInitialized(state);
+ }
+
+ return Object.values(state.library);
+}
+
+export function subscribeToLibraryUpdates(
+ state: CatalogLibraryState,
+ callback: (items: StreamingContent[]) => void
+): () => void {
+ state.librarySubscribers.push(callback);
+
+ Promise.resolve().then(() => {
+ getLibraryItems(state).then(items => {
+ if (state.librarySubscribers.includes(callback)) {
+ callback(items);
+ }
+ });
+ });
+
+ return () => {
+ const index = state.librarySubscribers.indexOf(callback);
+ if (index > -1) {
+ state.librarySubscribers.splice(index, 1);
+ }
+ };
+}
+
+export function onLibraryAdd(
+ state: CatalogLibraryState,
+ listener: (item: StreamingContent) => void
+): () => void {
+ state.libraryAddListeners.push(listener);
+
+ return () => {
+ state.libraryAddListeners = state.libraryAddListeners.filter(currentListener => currentListener !== listener);
+ };
+}
+
+export function onLibraryRemove(
+ state: CatalogLibraryState,
+ listener: (type: string, id: string) => void
+): () => void {
+ state.libraryRemoveListeners.push(listener);
+
+ return () => {
+ state.libraryRemoveListeners = state.libraryRemoveListeners.filter(
+ currentListener => currentListener !== listener
+ );
+ };
+}
+
+export async function addToLibrary(state: CatalogLibraryState, content: StreamingContent): Promise {
+ logger.log(`[CatalogService] addToLibrary() called for: ${content.type}:${content.id} (${content.name})`);
+
+ await ensureCatalogInitialized(state);
+
+ const key = createLibraryKey(content.type, content.id);
+ const itemCountBefore = Object.keys(state.library).length;
+ logger.log(`[CatalogService] Adding to library with key: "${key}". Current library keys: [${Object.keys(state.library).length}] items`);
+
+ state.library[key] = {
+ ...content,
+ addedToLibraryAt: Date.now(),
+ };
+
+ const itemCountAfter = Object.keys(state.library).length;
+ logger.log(
+ `[CatalogService] Library updated: ${itemCountBefore} -> ${itemCountAfter} items. New library keys: [${Object.keys(state.library).slice(0, 5).join(', ')}${Object.keys(state.library).length > 5 ? '...' : ''}]`
+ );
+
+ await saveLibrary(state);
+ logger.log(`[CatalogService] addToLibrary() completed for: ${content.type}:${content.id}`);
+
+ notifyLibrarySubscribers(state);
+
+ try {
+ state.libraryAddListeners.forEach(listener => listener(content));
+ } catch {}
+
+ if (content.type === 'series') {
+ try {
+ await getNotificationService().updateNotificationsForSeries(content.id);
+ } catch (error) {
+ logger.error(`[CatalogService] Failed to setup notifications for ${content.name}:`, error);
+ }
+ }
+}
+
+export async function removeFromLibrary(
+ state: CatalogLibraryState,
+ type: string,
+ id: string
+): Promise {
+ logger.log(`[CatalogService] removeFromLibrary() called for: ${type}:${id}`);
+
+ await ensureCatalogInitialized(state);
+
+ const key = createLibraryKey(type, id);
+ const itemCountBefore = Object.keys(state.library).length;
+ const itemExisted = key in state.library;
+ logger.log(
+ `[CatalogService] Removing key: "${key}". Currently library has ${itemCountBefore} items with keys: [${Object.keys(state.library).slice(0, 5).join(', ')}${Object.keys(state.library).length > 5 ? '...' : ''}]`
+ );
+
+ delete state.library[key];
+
+ const itemCountAfter = Object.keys(state.library).length;
+ logger.log(`[CatalogService] Library updated: ${itemCountBefore} -> ${itemCountAfter} items (existed: ${itemExisted})`);
+
+ await saveLibrary(state);
+ logger.log(`[CatalogService] removeFromLibrary() completed for: ${type}:${id}`);
+
+ notifyLibrarySubscribers(state);
+
+ try {
+ state.libraryRemoveListeners.forEach(listener => listener(type, id));
+ } catch {}
+
+ if (type === 'series') {
+ try {
+ const scheduledNotifications = getNotificationService().getScheduledNotifications();
+ const seriesToCancel = scheduledNotifications.filter((notification: any) => notification.seriesId === id);
+
+ for (const notification of seriesToCancel) {
+ await getNotificationService().cancelNotification(notification.id);
+ }
+ } catch (error) {
+ logger.error(`[CatalogService] Failed to cancel notifications for removed series ${id}:`, error);
+ }
+ }
+}
+
+export function addToRecentContent(state: CatalogLibraryState, content: StreamingContent): void {
+ state.recentContent = state.recentContent.filter(item => !(item.id === content.id && item.type === content.type));
+ state.recentContent.unshift(content);
+
+ if (state.recentContent.length > state.MAX_RECENT_ITEMS) {
+ state.recentContent = state.recentContent.slice(0, state.MAX_RECENT_ITEMS);
+ }
+
+ void saveRecentContent(state);
+}
+
+export function getRecentContent(state: CatalogLibraryState): StreamingContent[] {
+ return state.recentContent;
+}
diff --git a/src/services/catalog/search.ts b/src/services/catalog/search.ts
new file mode 100644
index 00000000..08ca0cac
--- /dev/null
+++ b/src/services/catalog/search.ts
@@ -0,0 +1,411 @@
+import axios from 'axios';
+
+import { stremioService, type Manifest } from '../stremioService';
+import { logger } from '../../utils/logger';
+import { createSafeAxiosConfig } from '../../utils/axiosConfig';
+
+import { canSearchCatalog, getAllAddons } from './catalog-utils';
+import { convertMetaToStreamingContent } from './content-mappers';
+import type { AddonSearchResults, GroupedSearchResults, StreamingContent } from './types';
+
+type PendingSection = {
+ addonId: string;
+ addonName: string;
+ sectionName: string;
+ catalogIndex: number;
+ results: StreamingContent[];
+};
+
+export async function searchContent(
+ library: Record,
+ query: string
+): Promise {
+ if (!query || query.trim().length < 2) {
+ return [];
+ }
+
+ const addons = await getAllAddons(() => stremioService.getInstalledAddonsAsync());
+ const manifests = await stremioService.getInstalledAddonsAsync();
+ const manifestMap = new Map(manifests.map(manifest => [manifest.id, manifest]));
+ const results: StreamingContent[] = [];
+
+ await Promise.all(
+ addons.flatMap(addon =>
+ (addon.catalogs || [])
+ .filter(catalog => canSearchCatalog(catalog))
+ .map(async catalog => {
+ const manifest = manifestMap.get(addon.id);
+ if (!manifest) {
+ return;
+ }
+
+ try {
+ const metas = await stremioService.getCatalog(
+ manifest,
+ catalog.type,
+ catalog.id,
+ 1,
+ [{ title: 'search', value: query }]
+ );
+
+ if (metas?.length) {
+ results.push(
+ ...metas.map(meta => ({
+ ...convertMetaToStreamingContent(meta, library),
+ addonId: addon.id,
+ }))
+ );
+ }
+ } catch (error) {
+ logger.error(`Search failed for ${catalog.id} in addon ${addon.id}:`, error);
+ }
+ })
+ )
+ );
+
+ return Array.from(new Map(results.map(item => [`${item.type}:${item.id}`, item])).values());
+}
+
+export async function searchContentCinemeta(
+ library: Record,
+ query: string
+): Promise {
+ if (!query) {
+ return { byAddon: [], allResults: [] };
+ }
+
+ const trimmedQuery = query.trim().toLowerCase();
+ logger.log('Searching across all addons for:', trimmedQuery);
+
+ const addons = await getAllAddons(() => stremioService.getInstalledAddonsAsync());
+ const manifests = await stremioService.getInstalledAddonsAsync();
+ const manifestMap = new Map(manifests.map(manifest => [manifest.id, manifest]));
+ const searchableAddons = addons.filter(addon => addon.catalogs.some(catalog => canSearchCatalog(catalog)));
+ const byAddon: AddonSearchResults[] = [];
+
+ logger.log(`Found ${searchableAddons.length} searchable addons:`, searchableAddons.map(addon => addon.name).join(', '));
+
+ for (const [addonIndex, addon] of searchableAddons.entries()) {
+ const manifest = manifestMap.get(addon.id);
+ if (!manifest) {
+ logger.warn(`Manifest not found for addon ${addon.name} (${addon.id})`);
+ continue;
+ }
+
+ const catalogResults = await Promise.allSettled(
+ addon.catalogs
+ .filter(catalog => canSearchCatalog(catalog))
+ .map(catalog => searchAddonCatalog(library, manifest, catalog.type, catalog.id, trimmedQuery))
+ );
+
+ const addonResults: StreamingContent[] = [];
+ for (const result of catalogResults) {
+ if (result.status === 'fulfilled' && result.value) {
+ addonResults.push(...result.value);
+ } else if (result.status === 'rejected') {
+ logger.error(`Search failed for ${addon.name}:`, result.reason);
+ }
+ }
+
+ if (addonResults.length > 0) {
+ const seen = new Set();
+ byAddon.push({
+ addonId: addon.id,
+ addonName: addon.name,
+ sectionName: addon.name,
+ catalogIndex: addonIndex,
+ results: addonResults.filter(item => {
+ const key = `${item.type}:${item.id}`;
+ if (seen.has(key)) {
+ return false;
+ }
+
+ seen.add(key);
+ return true;
+ }),
+ });
+ }
+ }
+
+ const allResults: StreamingContent[] = [];
+ const globalSeen = new Set();
+
+ for (const addonGroup of byAddon) {
+ for (const item of addonGroup.results) {
+ const key = `${item.type}:${item.id}`;
+ if (!globalSeen.has(key)) {
+ globalSeen.add(key);
+ allResults.push(item);
+ }
+ }
+ }
+
+ logger.log(`Search complete: ${byAddon.length} addons returned results, ${allResults.length} unique items total`);
+ return { byAddon, allResults };
+}
+
+export function startLiveSearch(
+ library: Record,
+ query: string,
+ onAddonResults: (section: AddonSearchResults) => void
+): { cancel: () => void; done: Promise } {
+ const controller = { cancelled: false };
+
+ const done = (async () => {
+ if (!query || !query.trim()) {
+ return;
+ }
+
+ const trimmedQuery = query.trim().toLowerCase();
+ logger.log('Live search across addons for:', trimmedQuery);
+
+ const addons = await getAllAddons(() => stremioService.getInstalledAddonsAsync());
+ logger.log(`Total addons available: ${addons.length}`);
+
+ const manifests = await stremioService.getInstalledAddonsAsync();
+ const manifestMap = new Map(manifests.map(manifest => [manifest.id, manifest]));
+ const searchableAddons = addons.filter(addon =>
+ (addon.catalogs || []).some(catalog => canSearchCatalog(catalog))
+ );
+
+ logger.log(
+ `Found ${searchableAddons.length} searchable addons:`,
+ searchableAddons.map(addon => `${addon.name} (${addon.id})`).join(', ')
+ );
+
+ if (searchableAddons.length === 0) {
+ logger.warn('No searchable addons found. Make sure you have addons installed that support search functionality.');
+ return;
+ }
+
+ const addonOrderRef: Record = {};
+ searchableAddons.forEach((addon, index) => {
+ addonOrderRef[addon.id] = index;
+ });
+
+ const catalogTypeLabels: Record = {
+ movie: 'Movies',
+ series: 'TV Shows',
+ 'anime.series': 'Anime Series',
+ 'anime.movie': 'Anime Movies',
+ other: 'Other',
+ tv: 'TV',
+ channel: 'Channels',
+ };
+ const genericCatalogNames = new Set(['search', 'Search']);
+ const allPendingSections: PendingSection[] = [];
+
+ await Promise.all(
+ searchableAddons.map(async addon => {
+ if (controller.cancelled) {
+ return;
+ }
+
+ try {
+ const manifest = manifestMap.get(addon.id);
+ if (!manifest) {
+ logger.warn(`Manifest not found for addon ${addon.name} (${addon.id})`);
+ return;
+ }
+
+ const searchableCatalogs = (addon.catalogs || []).filter(catalog => canSearchCatalog(catalog));
+ logger.log(`Searching ${addon.name} (${addon.id}) with ${searchableCatalogs.length} searchable catalogs`);
+
+ const settled = await Promise.allSettled(
+ searchableCatalogs.map(catalog =>
+ searchAddonCatalog(library, manifest, catalog.type, catalog.id, trimmedQuery)
+ )
+ );
+
+ if (controller.cancelled) {
+ return;
+ }
+
+ const addonRank = addonOrderRef[addon.id] ?? Number.MAX_SAFE_INTEGER;
+ if (searchableCatalogs.length > 1) {
+ searchableCatalogs.forEach((catalog, index) => {
+ const result = settled[index];
+ if (result.status === 'rejected' || !result.value?.length) {
+ if (result.status === 'rejected') {
+ logger.warn(`Search failed for ${catalog.id} in ${addon.name}:`, result.reason);
+ }
+ return;
+ }
+
+ const sectionName = buildSectionName(
+ addon.name,
+ catalog.name,
+ catalog.type,
+ genericCatalogNames,
+ catalogTypeLabels
+ );
+
+ allPendingSections.push({
+ addonId: `${addon.id}||${catalog.type}||${catalog.id}`,
+ addonName: addon.name,
+ sectionName,
+ catalogIndex: addonRank * 1000 + index,
+ results: dedupeAndStampResults(result.value, catalog.type),
+ });
+ });
+ return;
+ }
+
+ const result = settled[0];
+ const catalog = searchableCatalogs[0];
+ if (!result || result.status === 'rejected' || !result.value?.length) {
+ if (result?.status === 'rejected') {
+ logger.warn(`Search failed for ${addon.name}:`, result.reason);
+ }
+ return;
+ }
+
+ allPendingSections.push({
+ addonId: addon.id,
+ addonName: addon.name,
+ sectionName: addon.name,
+ catalogIndex: addonRank * 1000,
+ results: dedupeAndStampResults(result.value, catalog.type),
+ });
+ } catch (error) {
+ logger.error(`Error searching addon ${addon.name} (${addon.id}):`, error);
+ }
+ })
+ );
+
+ if (controller.cancelled) {
+ return;
+ }
+
+ allPendingSections.sort((left, right) => left.catalogIndex - right.catalogIndex);
+ for (const section of allPendingSections) {
+ if (controller.cancelled) {
+ return;
+ }
+
+ if (section.results.length > 0) {
+ logger.log(`Emitting ${section.results.length} results from ${section.sectionName}`);
+ onAddonResults(section);
+ }
+ }
+ })();
+
+ return {
+ cancel: () => {
+ controller.cancelled = true;
+ },
+ done,
+ };
+}
+
+async function searchAddonCatalog(
+ library: Record,
+ manifest: Manifest,
+ type: string,
+ catalogId: string,
+ query: string
+): Promise {
+ try {
+ const url = buildSearchUrl(manifest, type, catalogId, query);
+ if (!url) {
+ return [];
+ }
+
+ logger.log(`Searching ${manifest.name} (${type}/${catalogId}):`, url);
+ const response = await axios.get<{ metas: any[] }>(url, createSafeAxiosConfig(10000));
+ const metas = response.data?.metas || [];
+
+ if (metas.length === 0) {
+ return [];
+ }
+
+ const items = metas.map(meta => {
+ const content = convertMetaToStreamingContent(meta, library);
+ const addonSupportsMeta = Array.isArray(manifest.resources) && manifest.resources.some((resource: any) =>
+ resource === 'meta' || (typeof resource === 'object' && resource?.name === 'meta')
+ );
+
+ if (addonSupportsMeta) {
+ content.addonId = manifest.id;
+ }
+
+ const normalizedCatalogType = type ? type.toLowerCase() : type;
+ if (normalizedCatalogType && content.type !== normalizedCatalogType) {
+ content.type = normalizedCatalogType;
+ } else if (content.type) {
+ content.type = content.type.toLowerCase();
+ }
+ return content;
+ });
+
+ logger.log(`Found ${items.length} results from ${manifest.name}`);
+ return items;
+ } catch (error: any) {
+ const errorMessage = error?.response?.status
+ ? `HTTP ${error.response.status}`
+ : error?.message || 'Unknown error';
+ const errorUrl = error?.config?.url || 'unknown URL';
+ logger.error(`Search failed for ${manifest.name} (${type}/${catalogId}) at ${errorUrl}: ${errorMessage}`);
+ if (error?.response?.data) {
+ logger.error('Response data:', error.response.data);
+ }
+ return [];
+ }
+}
+
+function buildSearchUrl(manifest: Manifest, type: string, catalogId: string, query: string): string | null {
+ if (manifest.id === 'com.linvo.cinemeta') {
+ return `https://v3-cinemeta.strem.io/catalog/${type}/${encodeURIComponent(catalogId)}/search=${encodeURIComponent(query)}.json`;
+ }
+
+ const chosenUrl = manifest.url || manifest.originalUrl;
+ if (!chosenUrl) {
+ logger.warn(`Addon ${manifest.name} (${manifest.id}) has no URL, skipping search`);
+ return null;
+ }
+
+ const [baseUrlPart, queryParams] = chosenUrl.split('?');
+ let cleanBaseUrl = baseUrlPart.replace(/manifest\.json$/, '').replace(/\/$/, '');
+ if (!cleanBaseUrl.startsWith('http')) {
+ cleanBaseUrl = `https://${cleanBaseUrl}`;
+ }
+
+ let url = `${cleanBaseUrl}/catalog/${type}/${encodeURIComponent(catalogId)}/search=${encodeURIComponent(query)}.json`;
+ if (queryParams) {
+ url += `?${queryParams}`;
+ }
+
+ return url;
+}
+
+function dedupeAndStampResults(results: StreamingContent[], catalogType: string): StreamingContent[] {
+ const bestById = new Map();
+
+ for (const item of results) {
+ const existing = bestById.get(item.id);
+ if (!existing || (!existing.type.includes('.') && item.type.includes('.'))) {
+ bestById.set(item.id, item);
+ }
+ }
+
+ return Array.from(bestById.values()).map(item =>
+ catalogType && item.type !== catalogType ? { ...item, type: catalogType } : item
+ );
+}
+
+function buildSectionName(
+ addonName: string,
+ catalogName: string | undefined,
+ catalogType: string,
+ genericCatalogNames: Set,
+ catalogTypeLabels: Record
+): string {
+ const typeLabel = catalogTypeLabels[catalogType] ||
+ catalogType.replace(/[._]/g, ' ').replace(/\b\w/g, char => char.toUpperCase());
+
+ const catalogLabel = (!catalogName || genericCatalogNames.has(catalogName) || catalogName === addonName)
+ ? typeLabel
+ : catalogName;
+
+ return `${addonName} - ${catalogLabel}`;
+}
diff --git a/src/services/catalog/types.ts b/src/services/catalog/types.ts
new file mode 100644
index 00000000..42a2b21a
--- /dev/null
+++ b/src/services/catalog/types.ts
@@ -0,0 +1,154 @@
+export const DATA_SOURCE_KEY = 'discover_data_source';
+
+export enum DataSource {
+ STREMIO_ADDONS = 'stremio_addons',
+ TMDB = 'tmdb',
+}
+
+export interface StreamingCatalogExtra {
+ name: string;
+ isRequired?: boolean;
+ options?: string[];
+ optionsLimit?: number;
+}
+
+export interface StreamingCatalog {
+ type: string;
+ id: string;
+ name: string;
+ extraSupported?: string[];
+ extra?: StreamingCatalogExtra[];
+ showInHome?: boolean;
+}
+
+export interface StreamingAddon {
+ id: string;
+ name: string;
+ version: string;
+ description: string;
+ types: string[];
+ catalogs: StreamingCatalog[];
+ resources: {
+ name: string;
+ types: string[];
+ idPrefixes?: string[];
+ }[];
+ url?: string;
+ originalUrl?: string;
+ transportUrl?: string;
+ transportName?: string;
+}
+
+export interface StreamingContent {
+ id: string;
+ type: string;
+ name: string;
+ tmdbId?: number;
+ poster: string;
+ posterShape?: 'poster' | 'square' | 'landscape';
+ banner?: string;
+ logo?: string;
+ imdbRating?: string;
+ year?: number;
+ genres?: string[];
+ description?: string;
+ runtime?: string;
+ released?: string;
+ trailerStreams?: any[];
+ videos?: any[];
+ inLibrary?: boolean;
+ directors?: string[];
+ creators?: string[];
+ certification?: string;
+ country?: string;
+ writer?: string[];
+ links?: Array<{
+ name: string;
+ category: string;
+ url: string;
+ }>;
+ behaviorHints?: {
+ defaultVideoId?: string;
+ hasScheduledVideos?: boolean;
+ [key: string]: any;
+ };
+ imdb_id?: string;
+ mal_id?: number;
+ external_ids?: {
+ mal_id?: number;
+ imdb_id?: string;
+ tmdb_id?: number;
+ tvdb_id?: number;
+ };
+ slug?: string;
+ releaseInfo?: string;
+ traktSource?: 'watchlist' | 'continue-watching' | 'watched';
+ addonCast?: Array<{
+ id: number;
+ name: string;
+ character: string;
+ profile_path: string | null;
+ }>;
+ networks?: Array<{
+ id: number | string;
+ name: string;
+ logo?: string;
+ }>;
+ tvDetails?: {
+ status?: string;
+ firstAirDate?: string;
+ lastAirDate?: string;
+ numberOfSeasons?: number;
+ numberOfEpisodes?: number;
+ episodeRunTime?: number[];
+ type?: string;
+ originCountry?: string[];
+ originalLanguage?: string;
+ createdBy?: Array<{
+ id: number;
+ name: string;
+ profile_path?: string;
+ }>;
+ };
+ movieDetails?: {
+ status?: string;
+ releaseDate?: string;
+ runtime?: number;
+ budget?: number;
+ revenue?: number;
+ originalLanguage?: string;
+ originCountry?: string[];
+ tagline?: string;
+ };
+ collection?: {
+ id: number;
+ name: string;
+ poster_path?: string;
+ backdrop_path?: string;
+ };
+ addedToLibraryAt?: number;
+ addonId?: string;
+}
+
+export interface AddonSearchResults {
+ addonId: string;
+ addonName: string;
+ sectionName: string;
+ catalogIndex: number;
+ results: StreamingContent[];
+}
+
+export interface GroupedSearchResults {
+ byAddon: AddonSearchResults[];
+ allResults: StreamingContent[];
+}
+
+export interface CatalogContent {
+ addon: string;
+ type: string;
+ id: string;
+ name: string;
+ originalName?: string;
+ genre?: string;
+ items: StreamingContent[];
+}
diff --git a/src/services/catalogService.ts b/src/services/catalogService.ts
index f918947d..4e194767 100644
--- a/src/services/catalogService.ts
+++ b/src/services/catalogService.ts
@@ -1,1834 +1,202 @@
-import { stremioService, Meta, Manifest } from './stremioService';
-import { notificationService } from './notificationService';
-import { mmkvStorage } from './mmkvStorage';
-import axios from 'axios';
-import { TMDBService } from './tmdbService';
-import { logger } from '../utils/logger';
-import { getCatalogDisplayName } from '../utils/catalogNameUtils';
-import { createSafeAxiosConfig } from '../utils/axiosConfig';
+import {
+ getBasicContentDetails,
+ getContentDetails,
+ getDataSourcePreference,
+ getEnhancedContentDetails,
+ getStremioId,
+ setDataSourcePreference,
+} from './catalog/content-details';
+import {
+ discoverContent,
+ discoverContentFromCatalog,
+ fetchHomeCatalog,
+ getAllStreamingAddons,
+ getCatalogByType,
+ getDiscoverFilters,
+ getHomeCatalogs,
+ resolveHomeCatalogsToFetch,
+} from './catalog/discovery';
+import {
+ addToLibrary,
+ ensureCatalogInitialized,
+ getLibraryItems,
+ getRecentContent,
+ initializeCatalogState,
+ onLibraryAdd,
+ onLibraryRemove,
+ removeFromLibrary,
+ subscribeToLibraryUpdates,
+} from './catalog/library';
+import {
+ searchContent,
+ searchContentCinemeta,
+ startLiveSearch,
+} from './catalog/search';
+import type { CatalogLibraryState } from './catalog/library';
+import type {
+ AddonSearchResults,
+ CatalogContent,
+ DataSource,
+ GroupedSearchResults,
+ StreamingAddon,
+ StreamingCatalog,
+ StreamingContent,
+} from './catalog/types';
-// Add a constant for storing the data source preference
-const DATA_SOURCE_KEY = 'discover_data_source';
+export { DataSource } from './catalog/types';
+export type {
+ AddonSearchResults,
+ CatalogContent,
+ GroupedSearchResults,
+ StreamingAddon,
+ StreamingContent,
+} from './catalog/types';
-// Define data source types
-export enum DataSource {
- STREMIO_ADDONS = 'stremio_addons',
- TMDB = 'tmdb',
-}
-
-interface StreamingCatalogExtra {
- name: string;
- isRequired?: boolean;
- options?: string[];
- optionsLimit?: number;
-}
-
-interface StreamingCatalog {
- type: string;
- id: string;
- name: string;
- extraSupported?: string[];
- extra?: StreamingCatalogExtra[];
-}
-
-export interface StreamingAddon {
- id: string;
- name: string;
- version: string;
- description: string;
- types: string[];
- catalogs: StreamingCatalog[];
- resources: {
- name: string;
- types: string[];
- idPrefixes?: string[];
- }[];
- url?: string; // preferred base URL (manifest or original)
- originalUrl?: string; // original addon URL if provided
- transportUrl?: string;
- transportName?: string;
-}
-
-export interface AddonSearchResults {
- addonId: string;
- addonName: string;
- sectionName: string; // Display name â catalog name for named catalogs, addon name otherwise
- catalogIndex: number; // Position in addon manifest â used for deterministic sort within same addon
- results: StreamingContent[];
-}
-
-export interface GroupedSearchResults {
- byAddon: AddonSearchResults[];
- allResults: StreamingContent[]; // Deduplicated flat list for backwards compatibility
-}
-
-export interface StreamingContent {
- id: string;
- type: string;
- name: string;
- tmdbId?: number;
- poster: string;
- posterShape?: 'poster' | 'square' | 'landscape';
- banner?: string;
- logo?: string;
- imdbRating?: string;
- year?: number;
- genres?: string[];
- description?: string;
- runtime?: string;
- released?: string;
- trailerStreams?: any[];
- videos?: any[];
- inLibrary?: boolean;
- directors?: string[];
- creators?: string[];
- certification?: string;
- // Enhanced metadata from addons
- country?: string;
- writer?: string[];
- links?: Array<{
- name: string;
- category: string;
- url: string;
- }>;
- behaviorHints?: {
- defaultVideoId?: string;
- hasScheduledVideos?: boolean;
- [key: string]: any;
- };
- imdb_id?: string;
- mal_id?: number;
- external_ids?: {
- mal_id?: number;
- imdb_id?: string;
- tmdb_id?: number;
- tvdb_id?: number;
- };
- slug?: string;
- releaseInfo?: string;
- traktSource?: 'watchlist' | 'continue-watching' | 'watched';
- addonCast?: Array<{
- id: number;
- name: string;
- character: string;
- profile_path: string | null;
- }>;
- networks?: Array<{
- id: number | string;
- name: string;
- logo?: string;
- }>;
- tvDetails?: {
- status?: string;
- firstAirDate?: string;
- lastAirDate?: string;
- numberOfSeasons?: number;
- numberOfEpisodes?: number;
- episodeRunTime?: number[];
- type?: string;
- originCountry?: string[];
- originalLanguage?: string;
- createdBy?: Array<{
- id: number;
- name: string;
- profile_path?: string;
- }>;
- };
- movieDetails?: {
- status?: string;
- releaseDate?: string;
- runtime?: number;
- budget?: number;
- revenue?: number;
- originalLanguage?: string;
- originCountry?: string[];
- tagline?: string;
- };
- collection?: {
- id: number;
- name: string;
- poster_path?: string;
- backdrop_path?: string;
- };
- addedToLibraryAt?: number; // Timestamp when added to library
- addonId?: string; // ID of the addon that provided this content
-}
-
-export interface CatalogContent {
- addon: string;
- type: string;
- id: string;
- name: string;
- originalName?: string;
- genre?: string;
- items: StreamingContent[];
-}
-
-const CATALOG_SETTINGS_KEY = 'catalog_settings';
-
-class CatalogService {
+class CatalogService implements CatalogLibraryState {
private static instance: CatalogService;
- private readonly LEGACY_LIBRARY_KEY = 'stremio-library';
- private readonly RECENT_CONTENT_KEY = 'stremio-recent-content';
- private library: Record = {};
- private recentContent: StreamingContent[] = [];
- private readonly MAX_RECENT_ITEMS = 20;
- private librarySubscribers: ((items: StreamingContent[]) => void)[] = [];
- private libraryAddListeners: ((item: StreamingContent) => void)[] = [];
- private libraryRemoveListeners: ((type: string, id: string) => void)[] = [];
- private initPromise: Promise;
- private isInitialized: boolean = false;
+
+ readonly LEGACY_LIBRARY_KEY = 'stremio-library';
+ readonly RECENT_CONTENT_KEY = 'stremio-recent-content';
+ readonly MAX_RECENT_ITEMS = 20;
+
+ library: Record = {};
+ recentContent: StreamingContent[] = [];
+ librarySubscribers: Array<(items: StreamingContent[]) => void> = [];
+ libraryAddListeners: Array<(item: StreamingContent) => void> = [];
+ libraryRemoveListeners: Array<(type: string, id: string) => void> = [];
+ initPromise: Promise;
+ isInitialized = false;
private constructor() {
- this.initPromise = this.initialize();
- }
-
- private async initialize(): Promise {
- logger.log('[CatalogService] Starting initialization...');
- try {
- logger.log('[CatalogService] Step 1: Initializing scope...');
- await this.initializeScope();
- logger.log('[CatalogService] Step 2: Loading library...');
- await this.loadLibrary();
- logger.log('[CatalogService] Step 3: Loading recent content...');
- await this.loadRecentContent();
- this.isInitialized = true;
- logger.log(`[CatalogService] Initialization completed successfully. Library contains ${Object.keys(this.library).length} items.`);
- } catch (error) {
- logger.error('[CatalogService] Initialization failed:', error);
- // Still mark as initialized to prevent blocking forever
- this.isInitialized = true;
- }
- }
-
- private async ensureInitialized(): Promise {
- logger.log(`[CatalogService] ensureInitialized() called. isInitialized: ${this.isInitialized}`);
- try {
- await this.initPromise;
- logger.log(`[CatalogService] ensureInitialized() completed. Library ready with ${Object.keys(this.library).length} items.`);
- } catch (error) {
- logger.error('[CatalogService] Error waiting for initialization:', error);
- }
- }
-
- private async initializeScope(): Promise {
- try {
- const currentScope = await mmkvStorage.getItem('@user:current');
- if (!currentScope) {
- await mmkvStorage.setItem('@user:current', 'local');
- logger.log('[CatalogService] Initialized @user:current scope to "local"');
- } else {
- logger.log(`[CatalogService] Using existing scope: "${currentScope}"`);
- }
- } catch (error) {
- logger.error('[CatalogService] Failed to initialize scope:', error);
- }
+ this.initPromise = initializeCatalogState(this);
}
static getInstance(): CatalogService {
if (!CatalogService.instance) {
CatalogService.instance = new CatalogService();
}
+
return CatalogService.instance;
}
- private async loadLibrary(): Promise {
- try {
- const scope = (await mmkvStorage.getItem('@user:current')) || 'local';
- const scopedKey = `@user:${scope}:stremio-library`;
- let storedLibrary = (await mmkvStorage.getItem(scopedKey));
- if (!storedLibrary) {
- // Fallback: read legacy and migrate into scoped
- storedLibrary = await mmkvStorage.getItem(this.LEGACY_LIBRARY_KEY);
- if (storedLibrary) {
- await mmkvStorage.setItem(scopedKey, storedLibrary);
- }
- }
- if (storedLibrary) {
- const parsedLibrary = JSON.parse(storedLibrary);
- logger.log(`[CatalogService] Raw library data type: ${Array.isArray(parsedLibrary) ? 'ARRAY' : 'OBJECT'}, keys: ${JSON.stringify(Object.keys(parsedLibrary).slice(0, 5))}`);
-
- // Convert array format to object format if needed
- if (Array.isArray(parsedLibrary)) {
- logger.log(`[CatalogService] WARNING: Library is stored as ARRAY format. Converting to OBJECT format.`);
- const libraryObject: Record = {};
- for (const item of parsedLibrary) {
- const key = `${item.type}:${item.id}`;
- libraryObject[key] = item;
- }
- this.library = libraryObject;
- logger.log(`[CatalogService] Converted ${parsedLibrary.length} items from array to object format`);
- // Re-save in correct format (don't call ensureInitialized here since we're still initializing)
- const scope = (await mmkvStorage.getItem('@user:current')) || 'local';
- const scopedKey = `@user:${scope}:stremio-library`;
- const libraryData = JSON.stringify(this.library);
- await mmkvStorage.setItem(scopedKey, libraryData);
- await mmkvStorage.setItem(this.LEGACY_LIBRARY_KEY, libraryData);
- logger.log(`[CatalogService] Re-saved library in correct format`);
- } else {
- this.library = parsedLibrary;
- }
- logger.log(`[CatalogService] Library loaded successfully with ${Object.keys(this.library).length} items from scope: ${scope}`);
- } else {
- logger.log(`[CatalogService] No library data found for scope: ${scope}`);
- this.library = {};
- }
- // Ensure @user:current is set to prevent future scope issues
- await mmkvStorage.setItem('@user:current', scope);
- } catch (error: any) {
- logger.error('Failed to load library:', error);
- this.library = {};
- }
- }
-
- private async saveLibrary(): Promise {
- // Only wait for initialization if we're not already initializing (avoid circular dependency)
- if (this.isInitialized) {
- await this.ensureInitialized();
- }
- try {
- const itemCount = Object.keys(this.library).length;
- const scope = (await mmkvStorage.getItem('@user:current')) || 'local';
- const scopedKey = `@user:${scope}:stremio-library`;
- const libraryData = JSON.stringify(this.library);
-
- logger.log(`[CatalogService] Saving library with ${itemCount} items to scope: "${scope}" (key: ${scopedKey})`);
-
- await mmkvStorage.setItem(scopedKey, libraryData);
- await mmkvStorage.setItem(this.LEGACY_LIBRARY_KEY, libraryData);
-
- logger.log(`[CatalogService] Library saved successfully with ${itemCount} items`);
- } catch (error: any) {
- logger.error('Failed to save library:', error);
- logger.error(`[CatalogService] Library save failed details - scope: ${(await mmkvStorage.getItem('@user:current')) || 'unknown'}, itemCount: ${Object.keys(this.library).length}`);
- }
- }
-
- private async loadRecentContent(): Promise {
- try {
- const storedRecentContent = await mmkvStorage.getItem(this.RECENT_CONTENT_KEY);
- if (storedRecentContent) {
- this.recentContent = JSON.parse(storedRecentContent);
- }
- } catch (error: any) {
- logger.error('Failed to load recent content:', error);
- }
- }
-
- private async saveRecentContent(): Promise {
- try {
- await mmkvStorage.setItem(this.RECENT_CONTENT_KEY, JSON.stringify(this.recentContent));
- } catch (error: any) {
- logger.error('Failed to save recent content:', error);
- }
+ private async ensureInitialized(): Promise {
+ await ensureCatalogInitialized(this);
}
async getAllAddons(): Promise {
- const addons = await stremioService.getInstalledAddonsAsync();
- return addons.map(addon => this.convertManifestToStreamingAddon(addon));
+ return getAllStreamingAddons();
}
- private convertManifestToStreamingAddon(manifest: Manifest): StreamingAddon {
- return {
- id: manifest.id,
- name: manifest.name,
- version: manifest.version,
- description: manifest.description,
- types: manifest.types || [],
- catalogs: (manifest.catalogs || []).map(catalog => ({
- ...catalog,
- extraSupported: catalog.extraSupported || [],
- extra: (catalog.extra || []).map(extra => ({
- name: extra.name,
- isRequired: extra.isRequired,
- options: extra.options,
- optionsLimit: extra.optionsLimit,
- })),
- })),
- resources: manifest.resources || [],
- url: (manifest.url || manifest.originalUrl) as any,
- originalUrl: (manifest.originalUrl || manifest.url) as any,
- transportUrl: manifest.url,
- transportName: manifest.name
- };
+ async resolveHomeCatalogsToFetch(limitIds?: string[]) {
+ return resolveHomeCatalogsToFetch(limitIds);
}
- private catalogSupportsExtra(catalog: StreamingCatalog, extraName: string): boolean {
- return (catalog.extraSupported || []).includes(extraName) ||
- (catalog.extra || []).some(extra => extra.name === extraName);
- }
-
- private getRequiredCatalogExtras(catalog: StreamingCatalog): string[] {
- return (catalog.extra || [])
- .filter(extra => extra.isRequired)
- .map(extra => extra.name);
- }
-
- private canBrowseCatalog(catalog: StreamingCatalog): boolean {
- // Exclude search-only catalogs from discover browsing
- if (
- (catalog.id && catalog.id.startsWith('search.')) ||
- (catalog.type && catalog.type.startsWith('search'))
- ) {
- return false;
- }
- const requiredExtras = this.getRequiredCatalogExtras(catalog);
- return requiredExtras.every(extraName => extraName === 'genre');
- }
-
- /**
- * Whether a catalog should appear on the home screen, based purely on the
- * addon manifest â no user settings / mmkv involved.
- *
- * Rules (in order):
- * 1. Search catalogs (id/type starts with "search") â never on home
- * 2. Catalogs with any required extra (including required genre) â never on home
- * 3. Addon uses showInHome flag on at least one catalog:
- * â only catalogs with showInHome:true appear on home
- * 4. No showInHome flag on any catalog â all browseable catalogs appear on home
- */
- private isVisibleOnHome(catalog: StreamingCatalog, addonCatalogs: StreamingCatalog[]): boolean {
- // Rule 1: never show search catalogs
- if (
- (catalog.id && catalog.id.startsWith('search.')) ||
- (catalog.type && catalog.type.startsWith('search'))
- ) {
- return false;
- }
-
- // Rule 2: never show catalogs with any required extra (e.g. required genre, calendarVideosIds)
- const requiredExtras = this.getRequiredCatalogExtras(catalog);
- if (requiredExtras.length > 0) {
- return false;
- }
-
- // Rule 3: respect showInHome if the addon uses it on any catalog
- const addonUsesShowInHome = addonCatalogs.some((c: any) => c.showInHome === true);
- if (addonUsesShowInHome) {
- return (catalog as any).showInHome === true;
- }
-
- // Rule 4: no showInHome flag used â show all browseable catalogs
- return true;
- }
-
- private canSearchCatalog(catalog: StreamingCatalog): boolean {
- if (!this.catalogSupportsExtra(catalog, 'search')) {
- return false;
- }
-
- const requiredExtras = this.getRequiredCatalogExtras(catalog);
- return requiredExtras.every(extraName => extraName === 'search');
- }
-
- async resolveHomeCatalogsToFetch(limitIds?: string[]): Promise<{ addon: StreamingAddon; catalog: any }[]> {
- const addons = await this.getAllAddons();
-
- // Collect catalogs visible on home using manifest-only rules (no mmkv/user settings)
- const potentialCatalogs: { addon: StreamingAddon; catalog: any }[] = [];
-
- for (const addon of addons) {
- if (addon.catalogs) {
- for (const catalog of addon.catalogs) {
- if (this.isVisibleOnHome(catalog, addon.catalogs)) {
- potentialCatalogs.push({ addon, catalog });
- }
- }
- }
- }
-
- // Determine which catalogs to actually fetch
- let catalogsToFetch: { addon: StreamingAddon; catalog: any }[] = [];
-
- if (limitIds && limitIds.length > 0) {
- // User selected specific catalogs - strict filtering
- catalogsToFetch = potentialCatalogs.filter(item => {
- const catalogId = `${item.addon.id}:${item.catalog.type}:${item.catalog.id}`;
- return limitIds.includes(catalogId);
- });
- } else {
- // "All" mode - Smart Sample: Pick 5 random catalogs to avoid waterfall
- catalogsToFetch = potentialCatalogs.sort(() => 0.5 - Math.random()).slice(0, 5);
- }
-
- return catalogsToFetch;
- }
-
- async fetchHomeCatalog(addon: StreamingAddon, catalog: any): Promise {
- try {
- // Hoist manifest list retrieval and find once
- const addonManifests = await stremioService.getInstalledAddonsAsync();
- const manifest = addonManifests.find(a => a.id === addon.id);
- if (!manifest) return null;
-
- const metas = await stremioService.getCatalog(manifest, catalog.type, catalog.id, 1);
- if (metas && metas.length > 0) {
- // Cap items per catalog to reduce memory and rendering load
- const limited = metas.slice(0, 12);
- const items = limited.map(meta => this.convertMetaToStreamingContent(meta));
-
- // Get potentially custom display name; if customized, respect it as-is
- const originalName = catalog.name || catalog.id;
- let displayName = await getCatalogDisplayName(addon.id, catalog.type, catalog.id, originalName);
- const isCustom = displayName !== originalName;
-
- if (!isCustom) {
- // Remove duplicate words and clean up the name (case-insensitive)
- const words = displayName.split(' ');
- const uniqueWords: string[] = [];
- const seenWords = new Set();
- for (const word of words) {
- const lowerWord = word.toLowerCase();
- if (!seenWords.has(lowerWord)) {
- uniqueWords.push(word);
- seenWords.add(lowerWord);
- }
- }
- displayName = uniqueWords.join(' ');
-
- // Add content type if not present
- const contentType = catalog.type === 'movie' ? 'Movies' : 'TV Shows';
- if (!displayName.toLowerCase().includes(contentType.toLowerCase())) {
- displayName = `${displayName} ${contentType}`;
- }
- }
-
- return {
- addon: addon.id,
- type: catalog.type,
- id: catalog.id,
- name: displayName,
- items
- };
- }
- return null;
- } catch (error) {
- logger.error(`Failed to load ${catalog.name} from ${addon.name}:`, error);
- return null;
- }
+ async fetchHomeCatalog(addon: StreamingAddon, catalog: StreamingCatalog): Promise {
+ return fetchHomeCatalog(this.library, addon, catalog);
}
async getHomeCatalogs(limitIds?: string[]): Promise {
- // Determine which catalogs to actually fetch
- const catalogsToFetch = await this.resolveHomeCatalogsToFetch(limitIds);
-
- // Create promises for the selected catalogs
- const catalogPromises = catalogsToFetch.map(async ({ addon, catalog }) => {
- return this.fetchHomeCatalog(addon, catalog);
- });
-
- // Wait for all selected catalog fetch promises to resolve in parallel
- const catalogResults = await Promise.all(catalogPromises);
-
- // Filter out null results
- return catalogResults.filter(catalog => catalog !== null) as CatalogContent[];
+ return getHomeCatalogs(this.library, limitIds);
}
async getCatalogByType(type: string, genreFilter?: string): Promise {
- // Get the data source preference (default to Stremio addons)
- const dataSourcePreference = await this.getDataSourcePreference();
-
- // If TMDB is selected as the data source, use TMDB API
- if (dataSourcePreference === DataSource.TMDB) {
- return this.getCatalogByTypeFromTMDB(type, genreFilter);
- }
-
- // Otherwise use the original Stremio addons method
- const addons = await this.getAllAddons();
-
- const typeAddons = addons.filter(addon =>
- addon.catalogs && addon.catalogs.some(catalog => catalog.type === type)
- );
-
- // Create an array of promises for all catalog fetches
- const catalogPromises: Promise[] = [];
-
- for (const addon of typeAddons) {
- const typeCatalogs = addon.catalogs.filter((catalog: StreamingCatalog) =>
- catalog.type === type && this.isVisibleOnHome(catalog, addon.catalogs)
- );
-
- for (const catalog of typeCatalogs) {
- const catalogPromise = (async () => {
- try {
- const addonManifest = await stremioService.getInstalledAddonsAsync();
- const manifest = addonManifest.find(a => a.id === addon.id);
- if (!manifest) return null;
-
- const filters = genreFilter ? [{ title: 'genre', value: genreFilter }] : [];
- const metas = await stremioService.getCatalog(manifest, type, catalog.id, 1, filters);
-
- if (metas && metas.length > 0) {
- const items = metas.map(meta => this.convertMetaToStreamingContent(meta));
-
- // Get potentially custom display name
- const displayName = await getCatalogDisplayName(addon.id, catalog.type, catalog.id, catalog.name);
-
- return {
- addon: addon.id,
- type,
- id: catalog.id,
- name: displayName,
- genre: genreFilter,
- items
- };
- }
- return null;
- } catch (error) {
- logger.error(`Failed to get catalog ${catalog.id} for addon ${addon.id}:`, error);
- return null;
- }
- })();
-
- catalogPromises.push(catalogPromise);
- }
- }
-
- // Wait for all catalog fetch promises to resolve in parallel
- const catalogResults = await Promise.all(catalogPromises);
-
- // Filter out null results
- return catalogResults.filter(catalog => catalog !== null) as CatalogContent[];
+ const dataSourcePreference = await getDataSourcePreference();
+ return getCatalogByType(this.library, dataSourcePreference, type, genreFilter);
}
- /**
- * Get catalog content from TMDB by type and genre
- */
- private async getCatalogByTypeFromTMDB(type: string, genreFilter?: string): Promise {
- const tmdbService = TMDBService.getInstance();
- const catalogs: CatalogContent[] = [];
-
- try {
- // Map Stremio content type to TMDB content type
- const tmdbType = type === 'movie' ? 'movie' : 'tv';
-
- // If no genre filter or All is selected, get multiple catalogs
- if (!genreFilter || genreFilter === 'All') {
- // Create an array of promises for all catalog fetches
- const catalogFetchPromises = [
- // Trending catalog
- (async () => {
- const trendingItems = await tmdbService.getTrending(tmdbType, 'week');
- const trendingItemsPromises = trendingItems.map(item => this.convertTMDBToStreamingContent(item, tmdbType));
- const trendingStreamingItems = await Promise.all(trendingItemsPromises);
-
- return {
- addon: 'tmdb',
- type,
- id: 'trending',
- name: `Trending ${type === 'movie' ? 'Movies' : 'TV Shows'}`,
- items: trendingStreamingItems
- };
- })(),
-
- // Popular catalog
- (async () => {
- const popularItems = await tmdbService.getPopular(tmdbType, 1);
- const popularItemsPromises = popularItems.map(item => this.convertTMDBToStreamingContent(item, tmdbType));
- const popularStreamingItems = await Promise.all(popularItemsPromises);
-
- return {
- addon: 'tmdb',
- type,
- id: 'popular',
- name: `Popular ${type === 'movie' ? 'Movies' : 'TV Shows'}`,
- items: popularStreamingItems
- };
- })(),
-
- // Upcoming/on air catalog
- (async () => {
- const upcomingItems = await tmdbService.getUpcoming(tmdbType, 1);
- const upcomingItemsPromises = upcomingItems.map(item => this.convertTMDBToStreamingContent(item, tmdbType));
- const upcomingStreamingItems = await Promise.all(upcomingItemsPromises);
-
- return {
- addon: 'tmdb',
- type,
- id: 'upcoming',
- name: type === 'movie' ? 'Upcoming Movies' : 'On Air TV Shows',
- items: upcomingStreamingItems
- };
- })()
- ];
-
- // Wait for all catalog fetches to complete in parallel
- return await Promise.all(catalogFetchPromises);
- } else {
- // Get content by genre
- const genreItems = await tmdbService.discoverByGenre(tmdbType, genreFilter);
- const streamingItemsPromises = genreItems.map(item => this.convertTMDBToStreamingContent(item, tmdbType));
- const streamingItems = await Promise.all(streamingItemsPromises);
-
- return [{
- addon: 'tmdb',
- type,
- id: 'discover',
- name: `${genreFilter} ${type === 'movie' ? 'Movies' : 'TV Shows'}`,
- genre: genreFilter,
- items: streamingItems
- }];
- }
- } catch (error) {
- logger.error(`Failed to get catalog from TMDB for type ${type}, genre ${genreFilter}:`, error);
- return [];
- }
- }
-
- /**
- * Convert TMDB trending/discover result to StreamingContent format
- */
- private async convertTMDBToStreamingContent(item: any, type: 'movie' | 'tv'): Promise {
- const id = item.external_ids?.imdb_id || `tmdb:${item.id}`;
- const name = type === 'movie' ? item.title : item.name;
- const posterPath = item.poster_path;
-
- // Get genres from genre_ids
- let genres: string[] = [];
- if (item.genre_ids && item.genre_ids.length > 0) {
- try {
- const tmdbService = TMDBService.getInstance();
- const genreLists = type === 'movie'
- ? await tmdbService.getMovieGenres()
- : await tmdbService.getTvGenres();
-
- const genreIds: number[] = item.genre_ids;
- genres = genreIds
- .map(genreId => {
- const genre = genreLists.find(g => g.id === genreId);
- return genre ? genre.name : null;
- })
- .filter(Boolean) as string[];
- } catch (error) {
- logger.error('Failed to get genres for TMDB content:', error);
- }
- }
-
- return {
- id,
- type: type === 'movie' ? 'movie' : 'series',
- name: name || 'Unknown',
- poster: posterPath ? `https://image.tmdb.org/t/p/w500${posterPath}` : 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image',
- posterShape: 'poster',
- banner: item.backdrop_path ? `https://image.tmdb.org/t/p/original${item.backdrop_path}` : undefined,
- year: type === 'movie'
- ? (item.release_date ? new Date(item.release_date).getFullYear() : undefined)
- : (item.first_air_date ? new Date(item.first_air_date).getFullYear() : undefined),
- description: item.overview,
- genres,
- inLibrary: this.library[`${type === 'movie' ? 'movie' : 'series'}:${id}`] !== undefined,
- };
- }
-
- /**
- * Get the current data source preference
- */
async getDataSourcePreference(): Promise {
- try {
- const dataSource = await mmkvStorage.getItem(DATA_SOURCE_KEY);
- return dataSource as DataSource || DataSource.STREMIO_ADDONS;
- } catch (error) {
- logger.error('Failed to get data source preference:', error);
- return DataSource.STREMIO_ADDONS;
- }
+ return getDataSourcePreference();
}
- /**
- * Set the data source preference
- */
async setDataSourcePreference(dataSource: DataSource): Promise {
- try {
- await mmkvStorage.setItem(DATA_SOURCE_KEY, dataSource);
- } catch (error) {
- logger.error('Failed to set data source preference:', error);
- }
+ await setDataSourcePreference(dataSource);
}
async getContentDetails(type: string, id: string, preferredAddonId?: string): Promise {
- console.log(`đ [CatalogService] getContentDetails called:`, { type, id, preferredAddonId });
- try {
- // Try up to 2 times with increasing delays to reduce CPU load
- let meta = null;
- let lastError = null;
-
- for (let i = 0; i < 2; i++) {
- try {
- console.log(`đ [CatalogService] Attempt ${i + 1}/2 for getContentDetails:`, { type, id, preferredAddonId });
-
- // Skip meta requests for non-content ids (e.g., provider slugs)
- const isValidId = await stremioService.isValidContentId(type, id);
- console.log(`đ [CatalogService] Content ID validation:`, { type, id, isValidId });
-
- if (!isValidId) {
- console.log(`đ [CatalogService] Invalid content ID, breaking retry loop`);
- break;
- }
-
- console.log(`đ [CatalogService] Calling stremioService.getMetaDetails:`, { type, id, preferredAddonId });
- meta = await stremioService.getMetaDetails(type, id, preferredAddonId);
- console.log(`đ [CatalogService] stremioService.getMetaDetails result:`, {
- hasMeta: !!meta,
- metaId: meta?.id,
- metaName: meta?.name,
- metaType: meta?.type
- });
-
- if (meta) break;
- await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i)));
- } catch (error) {
- lastError = error;
- console.log(`đ [CatalogService] Attempt ${i + 1} failed:`, {
- errorMessage: error instanceof Error ? error.message : String(error),
- isAxiosError: (error as any)?.isAxiosError,
- responseStatus: (error as any)?.response?.status,
- responseData: (error as any)?.response?.data
- });
- logger.error(`Attempt ${i + 1} failed to get content details for ${type}:${id}:`, error);
- await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i)));
- }
- }
-
- if (meta) {
- console.log(`đ [CatalogService] Meta found, converting to StreamingContent:`, {
- metaId: meta.id,
- metaName: meta.name,
- metaType: meta.type
- });
-
- // Add to recent content using enhanced conversion for full metadata
- const content = this.convertMetaToStreamingContentEnhanced(meta);
- this.addToRecentContent(content);
-
- // Check if it's in the library
- content.inLibrary = this.library[`${type}:${id}`] !== undefined;
-
- console.log(`đ [CatalogService] Successfully converted meta to StreamingContent:`, {
- contentId: content.id,
- contentName: content.name,
- contentType: content.type,
- inLibrary: content.inLibrary
- });
-
- return content;
- }
-
- console.log(`đ [CatalogService] No meta found, checking lastError:`, {
- hasLastError: !!lastError,
- lastErrorMessage: lastError instanceof Error ? lastError.message : String(lastError)
- });
-
- if (lastError) {
- console.log(`đ [CatalogService] Throwing lastError:`, {
- errorMessage: lastError instanceof Error ? lastError.message : String(lastError),
- isAxiosError: (lastError as any)?.isAxiosError,
- responseStatus: (lastError as any)?.response?.status
- });
- throw lastError;
- }
-
- console.log(`đ [CatalogService] No meta and no error, returning null`);
- return null;
- } catch (error) {
- console.log(`đ [CatalogService] getContentDetails caught error:`, {
- errorMessage: error instanceof Error ? error.message : String(error),
- isAxiosError: (error as any)?.isAxiosError,
- responseStatus: (error as any)?.response?.status,
- responseData: (error as any)?.response?.data
- });
- logger.error(`Failed to get content details for ${type}:${id}:`, error);
- return null;
- }
+ return getContentDetails(this, type, id, preferredAddonId);
}
- // Public method for getting enhanced metadata details (used by MetadataScreen)
- async getEnhancedContentDetails(type: string, id: string, preferredAddonId?: string): Promise {
- console.log(`đ [CatalogService] getEnhancedContentDetails called:`, { type, id, preferredAddonId });
- logger.log(`đ [MetadataScreen] Fetching enhanced metadata for ${type}:${id} ${preferredAddonId ? `from addon ${preferredAddonId}` : ''}`);
-
- try {
- const result = await this.getContentDetails(type, id, preferredAddonId);
- console.log(`đ [CatalogService] getEnhancedContentDetails result:`, {
- hasResult: !!result,
- resultId: result?.id,
- resultName: result?.name,
- resultType: result?.type
- });
- return result;
- } catch (error) {
- console.log(`đ [CatalogService] getEnhancedContentDetails error:`, {
- errorMessage: error instanceof Error ? error.message : String(error),
- isAxiosError: (error as any)?.isAxiosError,
- responseStatus: (error as any)?.response?.status,
- responseData: (error as any)?.response?.data
- });
- throw error;
- }
+ async getEnhancedContentDetails(
+ type: string,
+ id: string,
+ preferredAddonId?: string
+ ): Promise {
+ return getEnhancedContentDetails(this, type, id, preferredAddonId);
}
- // Public method for getting basic content details without enhanced processing (used by ContinueWatching, etc.)
- async getBasicContentDetails(type: string, id: string, preferredAddonId?: string): Promise {
- try {
- // Try up to 3 times with increasing delays
- let meta = null;
- let lastError = null;
-
- for (let i = 0; i < 3; i++) {
- try {
- // Skip meta requests for non-content ids (e.g., provider slugs)
- if (!(await stremioService.isValidContentId(type, id))) {
- break;
- }
- meta = await stremioService.getMetaDetails(type, id, preferredAddonId);
- if (meta) break;
- await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i)));
- } catch (error) {
- lastError = error;
- logger.error(`Attempt ${i + 1} failed to get basic content details for ${type}:${id}:`, error);
- await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i)));
- }
- }
-
- if (meta) {
- // Use basic conversion without enhanced metadata processing
- const content = this.convertMetaToStreamingContent(meta);
-
- // Check if it's in the library
- content.inLibrary = this.library[`${type}:${id}`] !== undefined;
-
- return content;
- }
-
- if (lastError) {
- throw lastError;
- }
-
- return null;
- } catch (error) {
- logger.error(`Failed to get basic content details for ${type}:${id}:`, error);
- return null;
- }
+ async getBasicContentDetails(
+ type: string,
+ id: string,
+ preferredAddonId?: string
+ ): Promise {
+ return getBasicContentDetails(this, type, id, preferredAddonId);
}
- private convertMetaToStreamingContent(meta: Meta): StreamingContent {
- // Basic conversion for catalog display - no enhanced metadata processing
- // Use addon's poster if available, otherwise use placeholder
- let posterUrl = meta.poster;
- if (!posterUrl || posterUrl.trim() === '' || posterUrl === 'null' || posterUrl === 'undefined') {
- posterUrl = 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image';
- }
-
- // Use addon's logo if available, otherwise undefined
- let logoUrl = (meta as any).logo;
- if (!logoUrl || logoUrl.trim() === '' || logoUrl === 'null' || logoUrl === 'undefined') {
- logoUrl = undefined;
- }
-
- return {
- id: meta.id,
- type: meta.type,
- name: meta.name,
- poster: posterUrl,
- posterShape: meta.posterShape || 'poster', // Use addon's shape or default to poster type
- banner: meta.background,
- logo: logoUrl,
- imdbRating: meta.imdbRating,
- year: meta.year,
- genres: meta.genres,
- description: meta.description,
- runtime: meta.runtime,
- inLibrary: this.library[`${meta.type}:${meta.id}`] !== undefined,
- certification: meta.certification,
- releaseInfo: meta.releaseInfo,
- };
+ onLibraryAdd(listener: (item: StreamingContent) => void): () => void {
+ return onLibraryAdd(this, listener);
}
- // Enhanced conversion for detailed metadata (used only when fetching individual content details)
- private convertMetaToStreamingContentEnhanced(meta: Meta): StreamingContent {
- // Enhanced conversion to utilize all available metadata from addons
- const converted: StreamingContent = {
- id: meta.id,
- type: meta.type,
- name: meta.name,
- poster: meta.poster || 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image',
- posterShape: meta.posterShape || 'poster',
- banner: meta.background,
- // Use addon's logo if available, otherwise undefined
- logo: (meta as any).logo || undefined,
- imdbRating: meta.imdbRating,
- year: meta.year,
- genres: meta.genres,
- description: meta.description,
- runtime: meta.runtime,
- inLibrary: this.library[`${meta.type}:${meta.id}`] !== undefined,
- certification: meta.certification,
- // Enhanced fields from addon metadata
- directors: (meta as any).director ?
- (Array.isArray((meta as any).director) ? (meta as any).director : [(meta as any).director])
- : undefined,
- writer: (meta as any).writer || undefined,
- country: (meta as any).country || undefined,
- imdb_id: (meta as any).imdb_id || undefined,
- slug: (meta as any).slug || undefined,
- releaseInfo: meta.releaseInfo || (meta as any).releaseInfo || undefined,
- trailerStreams: (meta as any).trailerStreams || undefined,
- links: (meta as any).links || undefined,
- behaviorHints: (meta as any).behaviorHints || undefined,
- };
-
- // Extract addon cast data if available
- // Check for both app_extras.cast (structured) and cast (simple array) formats
- if ((meta as any).app_extras?.cast && Array.isArray((meta as any).app_extras.cast)) {
- // Structured format with name, character, photo
- converted.addonCast = (meta as any).app_extras.cast.map((castMember: any, index: number) => ({
- id: index + 1, // Use index as numeric ID
- name: castMember.name || 'Unknown',
- character: castMember.character || '',
- profile_path: castMember.photo || null
- }));
- } else if (meta.cast && Array.isArray(meta.cast)) {
- // Simple array format with just names
- converted.addonCast = meta.cast.map((castName: string, index: number) => ({
- id: index + 1, // Use index as numeric ID
- name: castName || 'Unknown',
- character: '', // No character info available in simple format
- profile_path: null // No profile images available in simple format
- }));
- }
-
- // Log if rich metadata is found
- if ((meta as any).trailerStreams?.length > 0) {
- logger.log(`đŦ Enhanced metadata: Found ${(meta as any).trailerStreams.length} trailers for ${meta.name}`);
- }
-
- if ((meta as any).links?.length > 0) {
- logger.log(`đ Enhanced metadata: Found ${(meta as any).links.length} links for ${meta.name}`);
- }
-
- if (converted.addonCast && converted.addonCast.length > 0) {
- logger.log(`đ Enhanced metadata: Found ${converted.addonCast.length} cast members from addon for ${meta.name}`);
- }
-
- // Handle videos/episodes if available
- if ((meta as any).videos) {
- converted.videos = (meta as any).videos;
- }
-
- return converted;
+ onLibraryRemove(listener: (type: string, id: string) => void): () => void {
+ return onLibraryRemove(this, listener);
}
- private notifyLibrarySubscribers(): void {
- const items = Object.values(this.library);
- this.librarySubscribers.forEach(callback => callback(items));
+ async getLibraryItems(): Promise {
+ return getLibraryItems(this);
}
- public onLibraryAdd(listener: (item: StreamingContent) => void): () => void {
- this.libraryAddListeners.push(listener);
- return () => {
- this.libraryAddListeners = this.libraryAddListeners.filter(l => l !== listener);
- };
+ subscribeToLibraryUpdates(callback: (items: StreamingContent[]) => void): () => void {
+ return subscribeToLibraryUpdates(this, callback);
}
- public onLibraryRemove(listener: (type: string, id: string) => void): () => void {
- this.libraryRemoveListeners.push(listener);
- return () => {
- this.libraryRemoveListeners = this.libraryRemoveListeners.filter(l => l !== listener);
- };
+ async addToLibrary(content: StreamingContent): Promise {
+ await addToLibrary(this, content);
}
- public async getLibraryItems(): Promise {
- // Only ensure initialization if not already done to avoid redundant calls
- if (!this.isInitialized) {
- await this.ensureInitialized();
- }
- return Object.values(this.library);
- }
-
- public subscribeToLibraryUpdates(callback: (items: StreamingContent[]) => void): () => void {
- this.librarySubscribers.push(callback);
- // Defer initial callback to next tick to avoid synchronous state updates during render
- // This prevents infinite loops when the callback triggers setState in useEffect
- Promise.resolve().then(() => {
- this.getLibraryItems().then(items => {
- // Only call if still subscribed (callback might have been unsubscribed)
- if (this.librarySubscribers.includes(callback)) {
- callback(items);
- }
- });
- });
-
- // Return unsubscribe function
- return () => {
- const index = this.librarySubscribers.indexOf(callback);
- if (index > -1) {
- this.librarySubscribers.splice(index, 1);
- }
- };
- }
-
- public async addToLibrary(content: StreamingContent): Promise {
- logger.log(`[CatalogService] addToLibrary() called for: ${content.type}:${content.id} (${content.name})`);
- await this.ensureInitialized();
- const key = `${content.type}:${content.id}`;
- const itemCountBefore = Object.keys(this.library).length;
- logger.log(`[CatalogService] Adding to library with key: "${key}". Current library keys: [${Object.keys(this.library).length}] items`);
- this.library[key] = {
- ...content,
- addedToLibraryAt: Date.now() // Add timestamp
- };
- const itemCountAfter = Object.keys(this.library).length;
- logger.log(`[CatalogService] Library updated: ${itemCountBefore} -> ${itemCountAfter} items. New library keys: [${Object.keys(this.library).slice(0, 5).join(', ')}${Object.keys(this.library).length > 5 ? '...' : ''}]`);
- await this.saveLibrary();
- logger.log(`[CatalogService] addToLibrary() completed for: ${content.type}:${content.id}`);
- this.notifyLibrarySubscribers();
- try { this.libraryAddListeners.forEach(l => l(content)); } catch { }
-
- // Auto-setup notifications for series when added to library
- if (content.type === 'series') {
- try {
- await notificationService.updateNotificationsForSeries(content.id);
- console.log(`[CatalogService] Auto-setup notifications for series: ${content.name}`);
- } catch (error) {
- console.error(`[CatalogService] Failed to setup notifications for ${content.name}:`, error);
- }
- }
- }
-
- public async removeFromLibrary(type: string, id: string): Promise {
- logger.log(`[CatalogService] removeFromLibrary() called for: ${type}:${id}`);
- await this.ensureInitialized();
- const key = `${type}:${id}`;
- const itemCountBefore = Object.keys(this.library).length;
- const itemExisted = key in this.library;
- logger.log(`[CatalogService] Removing key: "${key}". Currently library has ${itemCountBefore} items with keys: [${Object.keys(this.library).slice(0, 5).join(', ')}${Object.keys(this.library).length > 5 ? '...' : ''}]`);
- delete this.library[key];
- const itemCountAfter = Object.keys(this.library).length;
- logger.log(`[CatalogService] Library updated: ${itemCountBefore} -> ${itemCountAfter} items (existed: ${itemExisted})`);
- await this.saveLibrary();
- logger.log(`[CatalogService] removeFromLibrary() completed for: ${type}:${id}`);
- this.notifyLibrarySubscribers();
- try { this.libraryRemoveListeners.forEach(l => l(type, id)); } catch { }
-
- // Cancel notifications for series when removed from library
- if (type === 'series') {
- try {
- // Cancel all notifications for this series
- const scheduledNotifications = notificationService.getScheduledNotifications();
- const seriesToCancel = scheduledNotifications.filter(notification => notification.seriesId === id);
- for (const notification of seriesToCancel) {
- await notificationService.cancelNotification(notification.id);
- }
- console.log(`[CatalogService] Cancelled ${seriesToCancel.length} notifications for removed series: ${id}`);
- } catch (error) {
- console.error(`[CatalogService] Failed to cancel notifications for removed series ${id}:`, error);
- }
- }
- }
-
- private addToRecentContent(content: StreamingContent): void {
- // Remove if it already exists to prevent duplicates
- this.recentContent = this.recentContent.filter(item =>
- !(item.id === content.id && item.type === content.type)
- );
-
- // Add to the beginning of the array
- this.recentContent.unshift(content);
-
- // Trim the array if it exceeds the maximum
- if (this.recentContent.length > this.MAX_RECENT_ITEMS) {
- this.recentContent = this.recentContent.slice(0, this.MAX_RECENT_ITEMS);
- }
-
- this.saveRecentContent();
+ async removeFromLibrary(type: string, id: string): Promise {
+ await removeFromLibrary(this, type, id);
}
getRecentContent(): StreamingContent[] {
- return this.recentContent;
+ return getRecentContent(this);
}
- /**
- * Get all available discover filters (genres, etc.) from installed addon catalogs
- * This aggregates genre options from all addons that have catalog extras with options
- */
- async getDiscoverFilters(): Promise<{
- genres: string[];
- types: string[];
- catalogsByType: Record;
- }> {
- const addons = await this.getAllAddons();
- const allGenres = new Set();
- const allTypes = new Set();
- const catalogsByType: Record = {};
-
- for (const addon of addons) {
- if (!addon.catalogs) continue;
-
- for (const catalog of addon.catalogs) {
- if (!this.canBrowseCatalog(catalog)) {
- continue;
- }
-
- // Track content types
- if (catalog.type) {
- allTypes.add(catalog.type);
- }
-
- // Get genres from catalog extras
- const catalogGenres: string[] = [];
- if (catalog.extra && Array.isArray(catalog.extra)) {
- for (const extra of catalog.extra) {
- if (extra.name === 'genre' && extra.options && Array.isArray(extra.options)) {
- for (const genre of extra.options) {
- allGenres.add(genre);
- catalogGenres.push(genre);
- }
- }
- }
- }
-
- // Track catalogs by type for filtering
- if (catalog.type) {
- if (!catalogsByType[catalog.type]) {
- catalogsByType[catalog.type] = [];
- }
- catalogsByType[catalog.type].push({
- addonId: addon.id,
- addonName: addon.name,
- catalogId: catalog.id,
- catalogName: catalog.name || catalog.id,
- genres: catalogGenres
- });
- }
- }
- }
-
- // Sort genres alphabetically
- const sortedGenres = Array.from(allGenres).sort((a, b) => a.localeCompare(b));
- const sortedTypes = Array.from(allTypes);
-
- return {
- genres: sortedGenres,
- types: sortedTypes,
- catalogsByType
- };
+ async getDiscoverFilters() {
+ return getDiscoverFilters();
}
- /**
- * Discover content by type and optional genre filter
- * Fetches from all installed addons that have catalogs matching the criteria
- */
- async discoverContent(
- type: string,
- genre?: string,
- limit: number = 20
- ): Promise<{ addonName: string; items: StreamingContent[] }[]> {
- const addons = await this.getAllAddons();
- const results: { addonName: string; items: StreamingContent[] }[] = [];
- const manifests = await stremioService.getInstalledAddonsAsync();
-
- // Find catalogs that match the type
- const catalogPromises: Promise<{ addonName: string; items: StreamingContent[] } | null>[] = [];
-
- for (const addon of addons) {
- if (!addon.catalogs) continue;
-
- // Find catalogs matching the type
- const matchingCatalogs = addon.catalogs.filter(catalog =>
- catalog.type === type && this.canBrowseCatalog(catalog)
- );
-
- for (const catalog of matchingCatalogs) {
- // Check if this catalog supports the genre filter
- const supportsGenre = catalog.extra?.some(e => e.name === 'genre') ||
- catalog.extraSupported?.includes('genre');
-
- // If genre is specified, only use catalogs that support genre OR have no filter restrictions
- // If genre is specified but catalog doesn't support genre filter, skip it
- if (genre && !supportsGenre) {
- continue;
- }
-
- const manifest = manifests.find(m => m.id === addon.id);
- if (!manifest) continue;
-
- const fetchPromise = (async () => {
- try {
- const filters = genre ? [{ title: 'genre', value: genre }] : [];
- const metas = await stremioService.getCatalog(manifest, type, catalog.id, 1, filters);
-
- if (metas && metas.length > 0) {
- const items = metas.slice(0, limit).map(meta => {
- const content = this.convertMetaToStreamingContent(meta);
- content.addonId = addon.id;
- return content;
- });
- return {
- addonName: addon.name,
- items
- };
- }
- return null;
- } catch (error) {
- logger.error(`Discover failed for ${catalog.id} in addon ${addon.id}:`, error);
- return null;
- }
- })();
-
- catalogPromises.push(fetchPromise);
- }
- }
-
- const catalogResults = await Promise.all(catalogPromises);
-
- // Filter out null results and deduplicate by addon
- const addonMap = new Map();
- for (const result of catalogResults) {
- if (result && result.items.length > 0) {
- const existing = addonMap.get(result.addonName) || [];
- // Merge items, avoiding duplicates
- const existingIds = new Set(existing.map(item => `${item.type}:${item.id}`));
- const newItems = result.items.filter(item => !existingIds.has(`${item.type}:${item.id}`));
- addonMap.set(result.addonName, [...existing, ...newItems]);
- }
- }
-
- // Convert map to array
- for (const [addonName, items] of addonMap) {
- results.push({ addonName, items: items.slice(0, limit) });
- }
-
- return results;
+ async discoverContent(type: string, genre?: string, limit = 20) {
+ return discoverContent(this.library, type, genre, limit);
}
- /**
- * Discover content from a specific catalog with optional genre filter
- * @param addonId - The addon ID
- * @param catalogId - The catalog ID
- * @param type - Content type (movie/series)
- * @param genre - Optional genre filter
- * @param page - Page number for pagination (default 1)
- */
async discoverContentFromCatalog(
addonId: string,
catalogId: string,
type: string,
genre?: string,
- page: number = 1
+ page = 1
): Promise {
- try {
- const manifests = await stremioService.getInstalledAddonsAsync();
- const manifest = manifests.find(m => m.id === addonId);
-
- if (!manifest) {
- logger.error(`Addon ${addonId} not found`);
- return [];
- }
-
- const catalog = (manifest.catalogs || []).find(item => item.type === type && item.id === catalogId);
- if (!catalog || !this.canBrowseCatalog(catalog)) {
- logger.warn(`Catalog ${catalogId} in addon ${addonId} is not browseable`);
- return [];
- }
-
- const filters = genre ? [{ title: 'genre', value: genre }] : [];
- const metas = await stremioService.getCatalog(manifest, type, catalogId, page, filters);
-
- if (metas && metas.length > 0) {
- return metas.map(meta => {
- const content = this.convertMetaToStreamingContent(meta);
- content.addonId = addonId;
- return content;
- });
- }
- return [];
- } catch (error) {
- logger.error(`Discover from catalog failed for ${addonId}/${catalogId}:`, error);
- return [];
- }
+ return discoverContentFromCatalog(this.library, addonId, catalogId, type, genre, page);
}
async searchContent(query: string): Promise {
- if (!query || query.trim().length < 2) {
- return [];
- }
-
- const addons = await this.getAllAddons();
- const results: StreamingContent[] = [];
- const searchPromises: Promise[] = [];
-
- for (const addon of addons) {
- if (addon.catalogs && addon.catalogs.length > 0) {
- for (const catalog of addon.catalogs) {
- if (!this.canSearchCatalog(catalog)) {
- continue;
- }
-
- const addonManifest = await stremioService.getInstalledAddonsAsync();
- const manifest = addonManifest.find(a => a.id === addon.id);
- if (!manifest) continue;
-
- const searchPromise = (async () => {
- try {
- const filters = [{ title: 'search', value: query }];
- const metas = await stremioService.getCatalog(manifest, catalog.type, catalog.id, 1, filters);
-
- if (metas && metas.length > 0) {
- const items = metas.map(meta => {
- const content = this.convertMetaToStreamingContent(meta);
- content.addonId = addon.id;
- return content;
- });
- results.push(...items);
- }
- } catch (error) {
- logger.error(`Search failed for ${catalog.id} in addon ${addon.id}:`, error);
- }
- })();
-
- searchPromises.push(searchPromise);
- }
- }
- }
-
- await Promise.all(searchPromises);
-
- // Remove duplicates based on id and type
- const uniqueResults = Array.from(
- new Map(results.map(item => [`${item.type}:${item.id}`, item])).values()
- );
-
- return uniqueResults;
+ return searchContent(this.library, query);
}
- /**
- * Search across all installed addons that support search functionality.
- * This dynamically queries any addon with catalogs that have 'search' in their extraSupported or extra fields.
- * Results are grouped by addon source with headers.
- *
- * @param query - The search query string
- * @returns Promise - Search results grouped by addon with headers
- */
async searchContentCinemeta(query: string): Promise {
- if (!query) {
- return { byAddon: [], allResults: [] };
- }
-
- const trimmedQuery = query.trim().toLowerCase();
- logger.log('Searching across all addons for:', trimmedQuery);
-
- const addons = await this.getAllAddons();
- const byAddon: AddonSearchResults[] = [];
-
- // Get manifests separately to ensure we have correct URLs
- const manifests = await stremioService.getInstalledAddonsAsync();
- const manifestMap = new Map(manifests.map(m => [m.id, m]));
-
- // Find all addons that support search
- const searchableAddons = addons.filter(addon => {
- if (!addon.catalogs) return false;
-
- return addon.catalogs.some(catalog => this.canSearchCatalog(catalog));
- });
-
- logger.log(`Found ${searchableAddons.length} searchable addons:`, searchableAddons.map(a => a.name).join(', '));
-
- // Search each addon and keep results grouped
- for (const addon of searchableAddons) {
- // Get the manifest to ensure we have the correct URL
- const manifest = manifestMap.get(addon.id);
- if (!manifest) {
- logger.warn(`Manifest not found for addon ${addon.name} (${addon.id})`);
- continue;
- }
-
- const searchableCatalogs = (addon.catalogs || []).filter(catalog => this.canSearchCatalog(catalog));
-
- // Search all catalogs for this addon in parallel
- const catalogPromises = searchableCatalogs.map(catalog =>
- this.searchAddonCatalog(manifest, catalog.type, catalog.id, trimmedQuery)
- );
-
- const catalogResults = await Promise.allSettled(catalogPromises);
-
- // Collect all results for this addon
- const addonResults: StreamingContent[] = [];
- catalogResults.forEach((result) => {
- if (result.status === 'fulfilled' && result.value) {
- addonResults.push(...result.value);
- } else if (result.status === 'rejected') {
- logger.error(`Search failed for ${addon.name}:`, result.reason);
- }
- });
-
- // Only add addon section if it has results
- if (addonResults.length > 0) {
- // Deduplicate within this addon's results
- const seen = new Set();
- const uniqueAddonResults = addonResults.filter(item => {
- const key = `${item.type}:${item.id}`;
- if (seen.has(key)) return false;
- seen.add(key);
- return true;
- });
-
- byAddon.push({
- addonId: addon.id,
- addonName: addon.name,
- results: uniqueAddonResults,
- });
- }
- }
-
- // Create deduplicated flat list for backwards compatibility
- const allResults: StreamingContent[] = [];
- const globalSeen = new Set();
-
- byAddon.forEach(addonGroup => {
- addonGroup.results.forEach(item => {
- const key = `${item.type}:${item.id}`;
- if (!globalSeen.has(key)) {
- globalSeen.add(key);
- allResults.push(item);
- }
- });
- });
-
- logger.log(`Search complete: ${byAddon.length} addons returned results, ${allResults.length} unique items total`);
-
- return { byAddon, allResults };
+ return searchContentCinemeta(this.library, query);
}
- /**
- * Live search that emits results per-addon as they arrive.
- * Returns a handle with cancel() and a done promise.
- */
startLiveSearch(
query: string,
onAddonResults: (section: AddonSearchResults) => void
): { cancel: () => void; done: Promise } {
- const controller = { cancelled: false } as { cancelled: boolean };
-
- const done = (async () => {
- if (!query || !query.trim()) return;
-
- const trimmedQuery = query.trim().toLowerCase();
- logger.log('Live search across addons for:', trimmedQuery);
-
- const addons = await this.getAllAddons();
- logger.log(`Total addons available: ${addons.length}`);
-
- // Get manifests separately to ensure we have correct URLs
- const manifests = await stremioService.getInstalledAddonsAsync();
- const manifestMap = new Map(manifests.map(m => [m.id, m]));
-
- // Determine searchable addons
- const searchableAddons = addons.filter(addon =>
- (addon.catalogs || []).some(catalog => this.canSearchCatalog(catalog))
- );
-
- logger.log(`Found ${searchableAddons.length} searchable addons:`, searchableAddons.map(a => `${a.name} (${a.id})`).join(', '));
-
- if (searchableAddons.length === 0) {
- logger.warn('No searchable addons found. Make sure you have addons installed that support search functionality.');
- return;
- }
-
- // Build addon order map for deterministic section sorting
- const addonOrderRef: Record = {};
- searchableAddons.forEach((addon, i) => { addonOrderRef[addon.id] = i; });
-
- // Human-readable labels for known content types
- const CATALOG_TYPE_LABELS: Record = {
- 'movie': 'Movies',
- 'series': 'TV Shows',
- 'anime.series': 'Anime Series',
- 'anime.movie': 'Anime Movies',
- 'other': 'Other',
- 'tv': 'TV',
- 'channel': 'Channels',
- };
- const GENERIC_CATALOG_NAMES = new Set(['search', 'Search']);
-
- // Collect all sections from all addons first, then sort and dedup before emitting.
- // This avoids race conditions where concurrent addon workers steal each other's IDs
- // from a shared globalSeen set before they get a chance to emit.
- type PendingSection = {
- addonId: string;
- addonName: string;
- sectionName: string;
- catalogIndex: number;
- results: StreamingContent[];
- };
- const allPendingSections: PendingSection[] = [];
-
- await Promise.all(
- searchableAddons.map(async (addon) => {
- if (controller.cancelled) return;
- try {
- const manifest = manifestMap.get(addon.id);
- if (!manifest) {
- logger.warn(`Manifest not found for addon ${addon.name} (${addon.id})`);
- return;
- }
-
- const searchableCatalogs = (addon.catalogs || []).filter(catalog => this.canSearchCatalog(catalog));
- logger.log(`Searching ${addon.name} (${addon.id}) with ${searchableCatalogs.length} searchable catalogs`);
-
- const settled = await Promise.allSettled(
- searchableCatalogs.map(c => this.searchAddonCatalog(manifest, c.type, c.id, trimmedQuery))
- );
- if (controller.cancelled) return;
-
- const hasMultipleCatalogs = searchableCatalogs.length > 1;
- const addonRank = addonOrderRef[addon.id] ?? Number.MAX_SAFE_INTEGER;
-
- if (hasMultipleCatalogs) {
- for (let ci = 0; ci < searchableCatalogs.length; ci++) {
- const s = settled[ci];
- const catalog = searchableCatalogs[ci];
- if (s.status === 'rejected' || !(s as PromiseFulfilledResult).value?.length) {
- if (s.status === 'rejected') logger.warn(`Search failed for ${catalog.id} in ${addon.name}:`, s.reason);
- continue;
- }
-
- const results = (s as PromiseFulfilledResult).value;
-
- // Within-catalog dedup: prefer dot-type over generic for same ID
- const bestById = new Map();
- for (const item of results) {
- const existing = bestById.get(item.id);
- if (!existing || (!existing.type.includes('.') && item.type.includes('.'))) {
- bestById.set(item.id, item);
- }
- }
-
- // Stamp catalog type onto results
- const stamped = Array.from(bestById.values()).map(item =>
- catalog.type && item.type !== catalog.type ? { ...item, type: catalog.type } : item
- );
-
- // Build section name â use type label if catalog name is generic
- const typeLabel = CATALOG_TYPE_LABELS[catalog.type]
- || catalog.type.replace(/[._]/g, ' ').replace(/\w/g, (c: string) => c.toUpperCase());
- const catalogLabel = (!catalog.name || GENERIC_CATALOG_NAMES.has(catalog.name) || catalog.name === addon.name)
- ? typeLabel
- : catalog.name;
- const sectionName = `${addon.name} - ${catalogLabel}`;
- const catalogIndex = addonRank * 1000 + ci;
-
- allPendingSections.push({ addonId: `${addon.id}||${catalog.type}||${catalog.id}`, addonName: addon.name, sectionName, catalogIndex, results: stamped });
- }
- } else {
- const s = settled[0];
- const catalog = searchableCatalogs[0];
- if (!s || s.status === 'rejected' || !(s as PromiseFulfilledResult).value?.length) {
- if (s?.status === 'rejected') logger.warn(`Search failed for ${addon.name}:`, s.reason);
- return;
- }
-
- const results = (s as PromiseFulfilledResult).value;
- const bestById = new Map();
- for (const item of results) {
- const existing = bestById.get(item.id);
- if (!existing || (!existing.type.includes('.') && item.type.includes('.'))) {
- bestById.set(item.id, item);
- }
- }
- const stamped = Array.from(bestById.values()).map(item =>
- catalog.type && item.type !== catalog.type ? { ...item, type: catalog.type } : item
- );
-
- allPendingSections.push({ addonId: addon.id, addonName: addon.name, sectionName: addon.name, catalogIndex: addonRank * 1000, results: stamped });
- }
- } catch (e) {
- logger.error(`Error searching addon ${addon.name} (${addon.id}):`, e);
- }
- })
- );
-
- if (controller.cancelled) return;
-
- // Sort by catalogIndex (addon manifest order + position within addon) then emit.
- // No cross-section dedup â each section is shown separately so duplicates across
- // sections are intentional (e.g. same movie in Cinemeta and People Search).
- allPendingSections.sort((a, b) => a.catalogIndex - b.catalogIndex);
-
- for (const section of allPendingSections) {
- if (controller.cancelled) return;
- if (section.results.length > 0) {
- logger.log(`Emitting ${section.results.length} results from ${section.sectionName}`);
- onAddonResults({ addonId: section.addonId, addonName: section.addonName, sectionName: section.sectionName, catalogIndex: section.catalogIndex, results: section.results });
- }
- }
- })();
-
- return {
- cancel: () => { controller.cancelled = true; },
- done,
- };
- }
-
- /**
- * Search a specific catalog from a specific addon.
- * Handles URL construction for both Cinemeta (hardcoded) and other addons (dynamic).
- *
- * @param manifest - The addon manifest containing id, name, and url
- * @param type - Content type (movie, series, anime, etc.)
- * @param catalogId - The catalog ID to search within
- * @param query - The search query string
- * @returns Promise - Search results from this specific addon catalog
- */
- private async searchAddonCatalog(
- manifest: Manifest,
- type: string,
- catalogId: string,
- query: string
- ): Promise {
- try {
- let url: string;
-
- // Special handling for Cinemeta (hardcoded URL)
- if (manifest.id === 'com.linvo.cinemeta') {
- const encodedCatalogId = encodeURIComponent(catalogId);
- const encodedQuery = encodeURIComponent(query);
- url = `https://v3-cinemeta.strem.io/catalog/${type}/${encodedCatalogId}/search=${encodedQuery}.json`;
- }
- // Handle other addons
- else {
- // Choose best available URL
- const chosenUrl: string | undefined = manifest.url || manifest.originalUrl;
- if (!chosenUrl) {
- logger.warn(`Addon ${manifest.name} (${manifest.id}) has no URL, skipping search`);
- return [];
- }
-
- // Extract base URL and preserve query params (same logic as stremioService.getAddonBaseURL)
- const [baseUrlPart, queryParams] = chosenUrl.split('?');
- let cleanBaseUrl = baseUrlPart.replace(/manifest\.json$/, '').replace(/\/$/, '');
-
- // Ensure URL has protocol
- if (!cleanBaseUrl.startsWith('http')) {
- cleanBaseUrl = `https://${cleanBaseUrl}`;
- }
-
- const encodedCatalogId = encodeURIComponent(catalogId);
- const encodedQuery = encodeURIComponent(query);
-
- // Try path-style URL first (per Stremio protocol)
- url = `${cleanBaseUrl}/catalog/${type}/${encodedCatalogId}/search=${encodedQuery}.json`;
-
- // Append original query params if they existed
- if (queryParams) {
- url += `?${queryParams}`;
- }
- }
-
- logger.log(`Searching ${manifest.name} (${type}/${catalogId}):`, url);
-
- const response = await axios.get<{ metas: any[] }>(url, createSafeAxiosConfig(10000));
-
- const metas = response.data?.metas || [];
-
- if (metas.length > 0) {
- const items = metas.map(meta => {
- const content = this.convertMetaToStreamingContent(meta);
- content.addonId = manifest.id;
- // The meta's own type field may be generic (e.g. "series") even when
- // the catalog it came from is more specific (e.g. "anime.series").
- // Stamp the catalog type so grouping in the UI is correct.
- if (type && content.type !== type) {
- content.type = type;
- }
- return content;
- });
- logger.log(`Found ${items.length} results from ${manifest.name}`);
- return items;
- }
-
- return [];
- } catch (error: any) {
- // Don't throw, just log and return empty
- const errorMsg = error?.response?.status
- ? `HTTP ${error.response.status}`
- : error?.message || 'Unknown error';
- const errorUrl = error?.config?.url || 'unknown URL';
- logger.error(`Search failed for ${manifest.name} (${type}/${catalogId}) at ${errorUrl}: ${errorMsg}`);
- if (error?.response?.data) {
- logger.error(`Response data:`, error.response.data);
- }
- return [];
- }
+ return startLiveSearch(this.library, query, onAddonResults);
}
async getStremioId(type: string, tmdbId: string): Promise {
- if (__DEV__) {
- console.log('=== CatalogService.getStremioId ===');
- console.log('Input type:', type);
- console.log('Input tmdbId:', tmdbId);
- }
-
- try {
- // For movies, use the tt prefix with IMDb ID
- if (type === 'movie') {
- if (__DEV__) console.log('Processing movie - fetching TMDB details...');
- const tmdbService = TMDBService.getInstance();
- const movieDetails = await tmdbService.getMovieDetails(tmdbId);
-
- if (__DEV__) console.log('Movie details result:', {
- id: movieDetails?.id,
- title: movieDetails?.title,
- imdb_id: movieDetails?.imdb_id,
- hasImdbId: !!movieDetails?.imdb_id
- });
-
- if (movieDetails?.imdb_id) {
- if (__DEV__) console.log('Successfully found IMDb ID:', movieDetails.imdb_id);
- return movieDetails.imdb_id;
- } else {
- console.warn('No IMDb ID found for movie:', tmdbId);
- return null;
- }
- }
- // For TV shows, get the IMDb ID like movies
- else if (type === 'tv' || type === 'series') {
- if (__DEV__) console.log('Processing TV show - fetching TMDB details for IMDb ID...');
- const tmdbService = TMDBService.getInstance();
-
- // Get TV show external IDs to find IMDb ID
- const externalIds = await tmdbService.getShowExternalIds(parseInt(tmdbId));
-
- if (__DEV__) console.log('TV show external IDs result:', {
- tmdbId: tmdbId,
- imdb_id: externalIds?.imdb_id,
- hasImdbId: !!externalIds?.imdb_id
- });
-
- if (externalIds?.imdb_id) {
- if (__DEV__) console.log('Successfully found IMDb ID for TV show:', externalIds.imdb_id);
- return externalIds.imdb_id;
- } else {
- console.warn('No IMDb ID found for TV show, falling back to kitsu format:', tmdbId);
- const fallbackId = `kitsu:${tmdbId}`;
- if (__DEV__) console.log('Generated fallback Stremio ID for TV:', fallbackId);
- return fallbackId;
- }
- }
- else {
- console.warn('Unknown type provided:', type);
- return null;
- }
- } catch (error: any) {
- if (__DEV__) {
- console.error('=== Error in getStremioId ===');
- console.error('Type:', type);
- console.error('TMDB ID:', tmdbId);
- console.error('Error details:', error);
- console.error('Error message:', error.message);
- }
- logger.error('Error getting Stremio ID:', error);
- return null;
- }
+ return getStremioId(type, tmdbId);
}
}
export const catalogService = CatalogService.getInstance();
-export default catalogService;
+export default catalogService;
diff --git a/src/services/mdblistConstants.ts b/src/services/mdblistConstants.ts
new file mode 100644
index 00000000..94949a5d
--- /dev/null
+++ b/src/services/mdblistConstants.ts
@@ -0,0 +1,31 @@
+import { mmkvStorage } from './mmkvStorage';
+import { logger } from '../utils/logger';
+
+export const MDBLIST_API_KEY_STORAGE_KEY = 'mdblist_api_key';
+export const MDBLIST_ENABLED_STORAGE_KEY = 'mdblist_enabled';
+export const RATING_PROVIDERS_STORAGE_KEY = 'rating_providers_config';
+
+// Function to check if MDBList is enabled
+export const isMDBListEnabled = async (): Promise => {
+ try {
+ const enabledSetting = await mmkvStorage.getItem(MDBLIST_ENABLED_STORAGE_KEY);
+ return enabledSetting === 'true';
+ } catch (error) {
+ logger.error('[MDBList] Error checking if MDBList is enabled:', error);
+ return false;
+ }
+};
+
+// Function to get MDBList API key if enabled
+export const getMDBListAPIKey = async (): Promise => {
+ try {
+ const isEnabled = await isMDBListEnabled();
+ if (!isEnabled) {
+ return null;
+ }
+ return await mmkvStorage.getItem(MDBLIST_API_KEY_STORAGE_KEY);
+ } catch (error) {
+ logger.error('[MDBList] Error getting API key:', error);
+ return null;
+ }
+};
diff --git a/src/services/mdblistService.ts b/src/services/mdblistService.ts
index e6959e38..9b867bbe 100644
--- a/src/services/mdblistService.ts
+++ b/src/services/mdblistService.ts
@@ -3,8 +3,7 @@ import { logger } from '../utils/logger';
import {
MDBLIST_API_KEY_STORAGE_KEY,
MDBLIST_ENABLED_STORAGE_KEY,
- isMDBListEnabled
-} from '../screens/MDBListSettingsScreen';
+} from './mdblistConstants';
export interface MDBListRatings {
trakt?: number;
diff --git a/src/services/stremio/StremioService.ts b/src/services/stremio/StremioService.ts
new file mode 100644
index 00000000..2cfa043c
--- /dev/null
+++ b/src/services/stremio/StremioService.ts
@@ -0,0 +1,456 @@
+import { mmkvStorage } from '../mmkvStorage';
+import { logger } from '../../utils/logger';
+
+import type { StremioServiceContext } from './context';
+import {
+ getAllSupportedIdPrefixes as getAllSupportedIdPrefixesImpl,
+ getAllSupportedTypes as getAllSupportedTypesImpl,
+ getInstalledAddons as getInstalledAddonsImpl,
+ getInstalledAddonsAsync as getInstalledAddonsAsyncImpl,
+ getManifest as getManifestImpl,
+ hasUserRemovedAddon as hasUserRemovedAddonImpl,
+ initializeAddons,
+ installAddon as installAddonImpl,
+ isCollectionContent as isCollectionContentImpl,
+ isPreInstalledAddon as isPreInstalledAddonImpl,
+ removeAddon as removeAddonImpl,
+ unmarkAddonAsRemovedByUser as unmarkAddonAsRemovedByUserImpl,
+} from './addon-management';
+import {
+ applyAddonOrderFromManifestUrls as applyAddonOrderFromManifestUrlsImpl,
+ moveAddonDown as moveAddonDownImpl,
+ moveAddonUp as moveAddonUpImpl,
+} from './addon-order';
+import {
+ getAddonCapabilities as getAddonCapabilitiesImpl,
+ getAddonCatalogs as getAddonCatalogsImpl,
+ getAllCatalogs as getAllCatalogsImpl,
+ getCatalog as getCatalogImpl,
+ getCatalogHasMore as getCatalogHasMoreImpl,
+ getCatalogPreview as getCatalogPreviewImpl,
+ getMetaDetails as getMetaDetailsImpl,
+ getUpcomingEpisodes as getUpcomingEpisodesImpl,
+ isValidContentId as isValidContentIdImpl,
+} from './catalog-operations';
+import { getStreams as getStreamsImpl, hasStreamProviders as hasStreamProvidersImpl } from './stream-operations';
+import { getSubtitles as getSubtitlesImpl } from './subtitle-operations';
+import type {
+ AddonCapabilities,
+ AddonCatalogItem,
+ CatalogExtra,
+ CatalogFilter,
+ Manifest,
+ Meta,
+ MetaDetails,
+ MetaLink,
+ ResourceObject,
+ SourceObject,
+ Stream,
+ StreamCallback,
+ StreamResponse,
+ Subtitle,
+ SubtitleResponse,
+} from './types';
+
+class StremioService implements StremioServiceContext {
+ private static instance: StremioService;
+
+ installedAddons: Map = new Map();
+ addonOrder: string[] = [];
+ readonly STORAGE_KEY = 'stremio-addons';
+ readonly ADDON_ORDER_KEY = 'stremio-addon-order';
+ readonly DEFAULT_PAGE_SIZE = 100;
+ initialized = false;
+ initializationPromise: Promise | null = null;
+ catalogHasMore: Map = new Map();
+
+ private constructor() {
+ this.initializationPromise = this.initialize();
+ }
+
+ static getInstance(): StremioService {
+ if (!StremioService.instance) {
+ StremioService.instance = new StremioService();
+ }
+
+ return StremioService.instance;
+ }
+
+ private async initialize(): Promise {
+ await initializeAddons(this);
+ }
+
+ async ensureInitialized(): Promise {
+ if (!this.initialized && this.initializationPromise) {
+ await this.initializationPromise;
+ }
+ }
+
+ async retryRequest(request: () => Promise, retries = 1, delay = 1000): Promise {
+ let lastError: any;
+
+ for (let attempt = 0; attempt < retries + 1; attempt += 1) {
+ try {
+ return await request();
+ } catch (error: any) {
+ lastError = error;
+
+ if (error.response?.status === 404) {
+ throw error;
+ }
+
+ if (error.response?.status !== 404) {
+ logger.warn(`Request failed (attempt ${attempt + 1}/${retries + 1}):`, {
+ message: error.message,
+ code: error.code,
+ isAxiosError: error.isAxiosError,
+ status: error.response?.status,
+ });
+ }
+
+ if (attempt < retries) {
+ const backoffDelay = delay * Math.pow(2, attempt);
+ logger.log(`Retrying in ${backoffDelay}ms...`);
+ await new Promise(resolve => setTimeout(resolve, backoffDelay));
+ }
+ }
+ }
+
+ throw lastError;
+ }
+
+ async saveInstalledAddons(): Promise {
+ try {
+ const addonsArray = Array.from(this.installedAddons.values());
+ const scope = (await mmkvStorage.getItem('@user:current')) || 'local';
+ await Promise.all([
+ mmkvStorage.setItem(`@user:${scope}:${this.STORAGE_KEY}`, JSON.stringify(addonsArray)),
+ mmkvStorage.setItem(this.STORAGE_KEY, JSON.stringify(addonsArray)),
+ ]);
+ } catch {
+ // Storage writes are best-effort.
+ }
+ }
+
+ async saveAddonOrder(): Promise {
+ try {
+ const scope = (await mmkvStorage.getItem('@user:current')) || 'local';
+ await Promise.all([
+ mmkvStorage.setItem(`@user:${scope}:${this.ADDON_ORDER_KEY}`, JSON.stringify(this.addonOrder)),
+ mmkvStorage.setItem(this.ADDON_ORDER_KEY, JSON.stringify(this.addonOrder)),
+ ]);
+ } catch {
+ // Storage writes are best-effort.
+ }
+ }
+
+ generateInstallationId(addonId: string): string {
+ const timestamp = Date.now();
+ const random = Math.random().toString(36).substring(2, 9);
+ return `${addonId}-${timestamp}-${random}`;
+ }
+
+ addonProvidesStreams(manifest: Manifest): boolean {
+ return (manifest.resources || []).some(resource => {
+ if (typeof resource === 'string') {
+ return resource === 'stream';
+ }
+
+ return resource !== null && typeof resource === 'object' && 'name' in resource
+ ? (resource as ResourceObject).name === 'stream'
+ : false;
+ });
+ }
+
+ formatId(id: string): string {
+ return id.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase();
+ }
+
+ getAddonBaseURL(url: string): { baseUrl: string; queryParams?: string } {
+ const [baseUrl, queryString] = url.split('?');
+ let cleanBaseUrl = baseUrl.replace(/manifest\.json$/, '').replace(/\/$/, '');
+
+ if (!cleanBaseUrl.startsWith('http')) {
+ cleanBaseUrl = `https://${cleanBaseUrl}`;
+ }
+
+ return { baseUrl: cleanBaseUrl, queryParams: queryString };
+ }
+
+ private isDirectStreamingUrl(url?: string): boolean {
+ return typeof url === 'string' && (url.startsWith('http://') || url.startsWith('https://'));
+ }
+
+ private getStreamUrl(stream: any): string {
+ if (typeof stream?.url === 'string') {
+ return stream.url;
+ }
+
+ if (stream?.url && typeof stream.url === 'object' && typeof stream.url.url === 'string') {
+ return stream.url.url;
+ }
+
+ if (stream.ytId) {
+ return `https://www.youtube.com/watch?v=${stream.ytId}`;
+ }
+
+ if (stream.infoHash) {
+ const trackers = [
+ 'udp://tracker.opentrackr.org:1337/announce',
+ 'udp://9.rarbg.com:2810/announce',
+ 'udp://tracker.openbittorrent.com:6969/announce',
+ 'udp://tracker.torrent.eu.org:451/announce',
+ 'udp://open.stealth.si:80/announce',
+ 'udp://tracker.leechers-paradise.org:6969/announce',
+ 'udp://tracker.coppersurfer.tk:6969/announce',
+ 'udp://tracker.internetwarriors.net:1337/announce',
+ ];
+ const additionalTrackers = (stream.sources || [])
+ .filter((source: string) => source.startsWith('tracker:'))
+ .map((source: string) => source.replace('tracker:', ''));
+ const trackersString = [...trackers, ...additionalTrackers]
+ .map(tracker => `&tr=${encodeURIComponent(tracker)}`)
+ .join('');
+ const encodedTitle = encodeURIComponent(stream.title || stream.name || 'Unknown');
+ return `magnet:?xt=urn:btih:${stream.infoHash}&dn=${encodedTitle}${trackersString}`;
+ }
+
+ return '';
+ }
+
+ processStreams(streams: any[], addon: Manifest): Stream[] {
+ return streams
+ .filter(stream => {
+ const hasPlayableLink = Boolean(
+ stream.url ||
+ stream.infoHash ||
+ stream.ytId ||
+ stream.externalUrl ||
+ stream.nzbUrl ||
+ stream.rarUrls?.length ||
+ stream.zipUrls?.length ||
+ stream['7zipUrls']?.length ||
+ stream.tgzUrls?.length ||
+ stream.tarUrls?.length
+ );
+ const hasIdentifier = Boolean(stream.title || stream.name);
+ return stream && hasPlayableLink && hasIdentifier;
+ })
+ .map(stream => {
+ const streamUrl = this.getStreamUrl(stream);
+ const isDirectStreamingUrl = this.isDirectStreamingUrl(streamUrl);
+ const isMagnetStream = streamUrl.startsWith('magnet:');
+ const isExternalUrl = Boolean(stream.externalUrl);
+
+ let displayTitle = stream.title || stream.name || 'Unnamed Stream';
+ if (
+ stream.description &&
+ stream.description.includes('\n') &&
+ stream.description.length > (stream.title?.length || 0)
+ ) {
+ displayTitle = stream.description;
+ }
+
+ const sizeInBytes = stream.behaviorHints?.videoSize || stream.size || undefined;
+ const behaviorHints: Stream['behaviorHints'] = {
+ notWebReady: !isDirectStreamingUrl || isExternalUrl,
+ cached: stream.behaviorHints?.cached || undefined,
+ bingeGroup: stream.behaviorHints?.bingeGroup || undefined,
+ countryWhitelist: stream.behaviorHints?.countryWhitelist || undefined,
+ proxyHeaders: stream.behaviorHints?.proxyHeaders || undefined,
+ videoHash: stream.behaviorHints?.videoHash || undefined,
+ videoSize: stream.behaviorHints?.videoSize || undefined,
+ filename: stream.behaviorHints?.filename || undefined,
+ ...(isMagnetStream
+ ? {
+ infoHash: stream.infoHash || streamUrl.match(/btih:([a-zA-Z0-9]+)/)?.[1],
+ fileIdx: stream.fileIdx,
+ type: 'torrent',
+ }
+ : {}),
+ };
+
+ return {
+ url: streamUrl || undefined,
+ name: stream.name || stream.title || 'Unnamed Stream',
+ title: displayTitle,
+ addonName: addon.name,
+ addonId: addon.id,
+ description: stream.description,
+ ytId: stream.ytId || undefined,
+ externalUrl: stream.externalUrl || undefined,
+ nzbUrl: stream.nzbUrl || undefined,
+ rarUrls: stream.rarUrls || undefined,
+ zipUrls: stream.zipUrls || undefined,
+ '7zipUrls': stream['7zipUrls'] || undefined,
+ tgzUrls: stream.tgzUrls || undefined,
+ tarUrls: stream.tarUrls || undefined,
+ servers: stream.servers || undefined,
+ infoHash: stream.infoHash || undefined,
+ fileIdx: stream.fileIdx,
+ fileMustInclude: stream.fileMustInclude || undefined,
+ size: sizeInBytes,
+ isFree: stream.isFree,
+ isDebrid: Boolean(stream.behaviorHints?.cached),
+ subtitles:
+ stream.subtitles?.map((subtitle: any, index: number) => ({
+ id: subtitle.id || `${addon.id}-${subtitle.lang || 'unknown'}-${index}`,
+ ...subtitle,
+ })) || undefined,
+ sources: stream.sources || undefined,
+ behaviorHints,
+ };
+ });
+ }
+
+ getAllSupportedTypes(): string[] {
+ return getAllSupportedTypesImpl(this);
+ }
+
+ getAllSupportedIdPrefixes(type: string): string[] {
+ return getAllSupportedIdPrefixesImpl(this, type);
+ }
+
+ isCollectionContent(id: string): { isCollection: boolean; addon?: Manifest } {
+ return isCollectionContentImpl(this, id);
+ }
+
+ async isValidContentId(type: string, id: string | null | undefined): Promise {
+ return isValidContentIdImpl(
+ this,
+ type,
+ id,
+ () => this.getAllSupportedTypes(),
+ value => this.getAllSupportedIdPrefixes(value)
+ );
+ }
+
+ async getManifest(url: string): Promise {
+ return getManifestImpl(this, url);
+ }
+
+ async installAddon(url: string): Promise {
+ await installAddonImpl(this, url);
+ }
+
+ async removeAddon(installationId: string): Promise {
+ await removeAddonImpl(this, installationId);
+ }
+
+ getInstalledAddons(): Manifest[] {
+ return getInstalledAddonsImpl(this);
+ }
+
+ async getInstalledAddonsAsync(): Promise {
+ return getInstalledAddonsAsyncImpl(this);
+ }
+
+ isPreInstalledAddon(id: string): boolean {
+ void id;
+ return isPreInstalledAddonImpl();
+ }
+
+ async hasUserRemovedAddon(addonId: string): Promise {
+ return hasUserRemovedAddonImpl(addonId);
+ }
+
+ async unmarkAddonAsRemovedByUser(addonId: string): Promise {
+ await unmarkAddonAsRemovedByUserImpl(addonId);
+ }
+
+ async getAllCatalogs(): Promise> {
+ return getAllCatalogsImpl(this);
+ }
+
+ async getCatalog(
+ manifest: Manifest,
+ type: string,
+ id: string,
+ page = 1,
+ filters: CatalogFilter[] = []
+ ): Promise {
+ return getCatalogImpl(this, manifest, type, id, page, filters);
+ }
+
+ getCatalogHasMore(manifestId: string, type: string, id: string): boolean | undefined {
+ return getCatalogHasMoreImpl(this, manifestId, type, id);
+ }
+
+ async getMetaDetails(type: string, id: string, preferredAddonId?: string): Promise {
+ return getMetaDetailsImpl(this, type, id, preferredAddonId);
+ }
+
+ async getUpcomingEpisodes(
+ type: string,
+ id: string,
+ options: {
+ daysBack?: number;
+ daysAhead?: number;
+ maxEpisodes?: number;
+ preferredAddonId?: string;
+ } = {}
+ ): Promise<{ seriesName: string; poster: string; episodes: any[] } | null> {
+ return getUpcomingEpisodesImpl(this, type, id, options);
+ }
+
+ async getStreams(type: string, id: string, callback?: StreamCallback): Promise {
+ await getStreamsImpl(this, type, id, callback);
+ }
+
+ getAddonCapabilities(): AddonCapabilities[] {
+ return getAddonCapabilitiesImpl(this);
+ }
+
+ async getCatalogPreview(
+ addonId: string,
+ type: string,
+ id: string,
+ limit = 5
+ ): Promise<{ addon: string; type: string; id: string; items: Meta[] }> {
+ return getCatalogPreviewImpl(this, addonId, type, id, limit);
+ }
+
+ async getSubtitles(type: string, id: string, videoId?: string): Promise {
+ return getSubtitlesImpl(this, type, id, videoId);
+ }
+
+ moveAddonUp(installationId: string): boolean {
+ return moveAddonUpImpl(this, installationId);
+ }
+
+ moveAddonDown(installationId: string): boolean {
+ return moveAddonDownImpl(this, installationId);
+ }
+
+ async applyAddonOrderFromManifestUrls(manifestUrls: string[]): Promise {
+ return applyAddonOrderFromManifestUrlsImpl(this, manifestUrls);
+ }
+
+ async hasStreamProviders(type?: string): Promise {
+ return hasStreamProvidersImpl(this, type);
+ }
+
+ async getAddonCatalogs(type: string, id: string): Promise {
+ return getAddonCatalogsImpl(this, type, id);
+ }
+}
+
+export const stremioService = StremioService.getInstance();
+
+export type {
+ AddonCapabilities,
+ AddonCatalogItem,
+ CatalogExtra,
+ Manifest,
+ Meta,
+ MetaDetails,
+ MetaLink,
+ SourceObject,
+ Stream,
+ StreamResponse,
+ Subtitle,
+ SubtitleResponse,
+};
+
+export { StremioService };
+export default stremioService;
diff --git a/src/services/stremio/addon-management.ts b/src/services/stremio/addon-management.ts
new file mode 100644
index 00000000..77b4674c
--- /dev/null
+++ b/src/services/stremio/addon-management.ts
@@ -0,0 +1,449 @@
+import axios from 'axios';
+
+import { mmkvStorage } from '../mmkvStorage';
+import { logger } from '../../utils/logger';
+import { safeAxiosConfig } from '../../utils/axiosConfig';
+
+import { ADDON_EVENTS, addonEmitter } from './events';
+import type { StremioServiceContext } from './context';
+import type { Manifest, ResourceObject } from './types';
+
+const CINEMETA_ID = 'com.linvo.cinemeta';
+const CINEMETA_URL = 'https://v3-cinemeta.strem.io/manifest.json';
+const OPENSUBTITLES_ID = 'org.stremio.opensubtitlesv3';
+const OPENSUBTITLES_URL = 'https://opensubtitles-v3.strem.io/manifest.json';
+
+function createFallbackCinemetaManifest(ctx: StremioServiceContext): Manifest {
+ return {
+ id: CINEMETA_ID,
+ installationId: ctx.generateInstallationId(CINEMETA_ID),
+ name: 'Cinemeta',
+ version: '3.0.13',
+ description: 'Provides metadata for movies and series from TheTVDB, TheMovieDB, etc.',
+ url: 'https://v3-cinemeta.strem.io',
+ originalUrl: CINEMETA_URL,
+ types: ['movie', 'series'],
+ catalogs: [
+ {
+ type: 'movie',
+ id: 'top',
+ name: 'Popular',
+ extraSupported: ['search', 'genre', 'skip'],
+ },
+ {
+ type: 'series',
+ id: 'top',
+ name: 'Popular',
+ extraSupported: ['search', 'genre', 'skip'],
+ },
+ ],
+ resources: [
+ {
+ name: 'catalog',
+ types: ['movie', 'series'],
+ idPrefixes: ['tt'],
+ },
+ {
+ name: 'meta',
+ types: ['movie', 'series'],
+ idPrefixes: ['tt'],
+ },
+ ],
+ behaviorHints: {
+ configurable: false,
+ },
+ };
+}
+
+function createFallbackOpenSubtitlesManifest(ctx: StremioServiceContext): Manifest {
+ return {
+ id: OPENSUBTITLES_ID,
+ installationId: ctx.generateInstallationId(OPENSUBTITLES_ID),
+ name: 'OpenSubtitles v3',
+ version: '1.0.0',
+ description: 'OpenSubtitles v3 Addon for Stremio',
+ url: 'https://opensubtitles-v3.strem.io',
+ originalUrl: OPENSUBTITLES_URL,
+ types: ['movie', 'series'],
+ catalogs: [],
+ resources: [
+ {
+ name: 'subtitles',
+ types: ['movie', 'series'],
+ idPrefixes: ['tt'],
+ },
+ ],
+ behaviorHints: {
+ configurable: false,
+ },
+ };
+}
+
+async function getCurrentScope(): Promise {
+ return (await mmkvStorage.getItem('@user:current')) || 'local';
+}
+
+export async function initializeAddons(ctx: StremioServiceContext): Promise {
+ if (ctx.initialized) {
+ return;
+ }
+
+ try {
+ const scope = await getCurrentScope();
+ let storedAddons = await mmkvStorage.getItem(`@user:${scope}:${ctx.STORAGE_KEY}`);
+ if (!storedAddons) {
+ storedAddons = await mmkvStorage.getItem(ctx.STORAGE_KEY);
+ }
+ if (!storedAddons) {
+ storedAddons = await mmkvStorage.getItem(`@user:local:${ctx.STORAGE_KEY}`);
+ }
+
+ if (storedAddons) {
+ const parsed = JSON.parse(storedAddons) as Manifest[];
+ ctx.installedAddons = new Map();
+
+ for (const addon of parsed) {
+ if (!addon?.id) {
+ continue;
+ }
+
+ if (!addon.installationId) {
+ addon.installationId = ctx.generateInstallationId(addon.id);
+ }
+
+ ctx.installedAddons.set(addon.installationId, addon);
+ }
+ }
+
+ const hasUserRemovedCinemeta = await ctx.hasUserRemovedAddon(CINEMETA_ID);
+ const hasCinemeta = Array.from(ctx.installedAddons.values()).some(addon => addon.id === CINEMETA_ID);
+
+ if (!hasCinemeta && !hasUserRemovedCinemeta) {
+ try {
+ const cinemetaManifest = await getManifest(ctx, CINEMETA_URL);
+ cinemetaManifest.installationId = ctx.generateInstallationId(CINEMETA_ID);
+ ctx.installedAddons.set(cinemetaManifest.installationId, cinemetaManifest);
+ } catch {
+ const fallbackManifest = createFallbackCinemetaManifest(ctx);
+ ctx.installedAddons.set(fallbackManifest.installationId!, fallbackManifest);
+ }
+ }
+
+ const hasUserRemovedOpenSubtitles = await ctx.hasUserRemovedAddon(OPENSUBTITLES_ID);
+ const hasOpenSubtitles = Array.from(ctx.installedAddons.values()).some(
+ addon => addon.id === OPENSUBTITLES_ID
+ );
+
+ if (!hasOpenSubtitles && !hasUserRemovedOpenSubtitles) {
+ try {
+ const openSubsManifest = await getManifest(ctx, OPENSUBTITLES_URL);
+ openSubsManifest.installationId = ctx.generateInstallationId(OPENSUBTITLES_ID);
+ ctx.installedAddons.set(openSubsManifest.installationId, openSubsManifest);
+ } catch {
+ const fallbackManifest = createFallbackOpenSubtitlesManifest(ctx);
+ ctx.installedAddons.set(fallbackManifest.installationId!, fallbackManifest);
+ }
+ }
+
+ let storedOrder = await mmkvStorage.getItem(`@user:${scope}:${ctx.ADDON_ORDER_KEY}`);
+ if (!storedOrder) {
+ storedOrder = await mmkvStorage.getItem(ctx.ADDON_ORDER_KEY);
+ }
+ if (!storedOrder) {
+ storedOrder = await mmkvStorage.getItem(`@user:local:${ctx.ADDON_ORDER_KEY}`);
+ }
+
+ if (storedOrder) {
+ ctx.addonOrder = JSON.parse(storedOrder).filter((installationId: string) =>
+ ctx.installedAddons.has(installationId)
+ );
+ }
+
+ const cinemetaInstallation = Array.from(ctx.installedAddons.values()).find(
+ addon => addon.id === CINEMETA_ID
+ );
+ if (
+ cinemetaInstallation?.installationId &&
+ !ctx.addonOrder.includes(cinemetaInstallation.installationId) &&
+ !(await ctx.hasUserRemovedAddon(CINEMETA_ID))
+ ) {
+ ctx.addonOrder.push(cinemetaInstallation.installationId);
+ }
+
+ const openSubtitlesInstallation = Array.from(ctx.installedAddons.values()).find(
+ addon => addon.id === OPENSUBTITLES_ID
+ );
+ if (
+ openSubtitlesInstallation?.installationId &&
+ !ctx.addonOrder.includes(openSubtitlesInstallation.installationId) &&
+ !(await ctx.hasUserRemovedAddon(OPENSUBTITLES_ID))
+ ) {
+ ctx.addonOrder.push(openSubtitlesInstallation.installationId);
+ }
+
+ const missingInstallationIds = Array.from(ctx.installedAddons.keys()).filter(
+ installationId => !ctx.addonOrder.includes(installationId)
+ );
+ ctx.addonOrder = [...ctx.addonOrder, ...missingInstallationIds];
+
+ await ctx.saveAddonOrder();
+ await ctx.saveInstalledAddons();
+ ctx.initialized = true;
+ } catch {
+ ctx.installedAddons = new Map();
+ ctx.addonOrder = [];
+ ctx.initialized = true;
+ }
+}
+
+export function getAllSupportedTypes(ctx: StremioServiceContext): string[] {
+ const types = new Set();
+
+ for (const addon of ctx.getInstalledAddons()) {
+ addon.types?.forEach(type => types.add(type));
+
+ for (const resource of addon.resources || []) {
+ if (typeof resource === 'object' && resource !== null && 'name' in resource) {
+ (resource as ResourceObject).types?.forEach(type => types.add(type));
+ }
+ }
+
+ for (const catalog of addon.catalogs || []) {
+ if (catalog.type) {
+ types.add(catalog.type);
+ }
+ }
+ }
+
+ return Array.from(types);
+}
+
+export function getAllSupportedIdPrefixes(ctx: StremioServiceContext, type: string): string[] {
+ const prefixes = new Set();
+
+ for (const addon of ctx.getInstalledAddons()) {
+ addon.idPrefixes?.forEach(prefix => prefixes.add(prefix));
+
+ for (const resource of addon.resources || []) {
+ if (typeof resource !== 'object' || resource === null || !('name' in resource)) {
+ continue;
+ }
+
+ const typedResource = resource as ResourceObject;
+ if (!typedResource.types?.includes(type)) {
+ continue;
+ }
+
+ typedResource.idPrefixes?.forEach(prefix => prefixes.add(prefix));
+ }
+ }
+
+ return Array.from(prefixes);
+}
+
+export function isCollectionContent(
+ ctx: StremioServiceContext,
+ id: string
+): { isCollection: boolean; addon?: Manifest } {
+ for (const addon of ctx.getInstalledAddons()) {
+ const supportsCollections =
+ addon.types?.includes('collections') ||
+ addon.catalogs?.some(catalog => catalog.type === 'collections');
+
+ if (!supportsCollections) {
+ continue;
+ }
+
+ const addonPrefixes = addon.idPrefixes || [];
+ const resourcePrefixes =
+ addon.resources
+ ?.filter(
+ resource =>
+ typeof resource === 'object' &&
+ resource !== null &&
+ 'name' in resource &&
+ (((resource as ResourceObject).name === 'meta') ||
+ (resource as ResourceObject).name === 'catalog')
+ )
+ .flatMap(resource => (resource as ResourceObject).idPrefixes || []) || [];
+
+ if ([...addonPrefixes, ...resourcePrefixes].some(prefix => id.startsWith(prefix))) {
+ return { isCollection: true, addon };
+ }
+ }
+
+ return { isCollection: false };
+}
+
+export async function getManifest(ctx: StremioServiceContext, url: string): Promise {
+ try {
+ const manifestUrl = url.endsWith('manifest.json') ? url : `${url.replace(/\/$/, '')}/manifest.json`;
+ const response = await ctx.retryRequest(() => axios.get(manifestUrl, safeAxiosConfig));
+ const manifest = response.data as Manifest;
+
+ manifest.originalUrl = url;
+ manifest.url = url.replace(/manifest\.json$/, '');
+
+ if (!manifest.id) {
+ manifest.id = ctx.formatId(url);
+ }
+
+ return manifest;
+ } catch (error) {
+ logger.error(`Failed to fetch manifest from ${url}:`, error);
+ throw new Error(`Failed to fetch addon manifest from ${url}`);
+ }
+}
+
+export async function installAddon(ctx: StremioServiceContext, url: string): Promise {
+ const manifest = await getManifest(ctx, url);
+ if (!manifest?.id) {
+ throw new Error('Invalid addon manifest');
+ }
+
+ const existingInstallations = Array.from(ctx.installedAddons.values()).filter(
+ addon => addon.id === manifest.id
+ );
+ if (existingInstallations.length > 0 && !ctx.addonProvidesStreams(manifest)) {
+ throw new Error(
+ 'This addon is already installed. Multiple installations are only allowed for stream providers.'
+ );
+ }
+
+ manifest.installationId = ctx.generateInstallationId(manifest.id);
+ ctx.installedAddons.set(manifest.installationId, manifest);
+
+ await ctx.unmarkAddonAsRemovedByUser(manifest.id);
+ await cleanupRemovedAddonFromStorage(ctx, manifest.id);
+
+ if (!ctx.addonOrder.includes(manifest.installationId)) {
+ ctx.addonOrder.push(manifest.installationId);
+ }
+
+ await ctx.saveInstalledAddons();
+ await ctx.saveAddonOrder();
+ addonEmitter.emit(ADDON_EVENTS.ADDON_ADDED, {
+ installationId: manifest.installationId,
+ addonId: manifest.id,
+ });
+}
+
+export async function removeAddon(ctx: StremioServiceContext, installationId: string): Promise {
+ if (!ctx.installedAddons.has(installationId)) {
+ return;
+ }
+
+ const addon = ctx.installedAddons.get(installationId);
+ ctx.installedAddons.delete(installationId);
+ ctx.addonOrder = ctx.addonOrder.filter(id => id !== installationId);
+
+ if (addon) {
+ const remainingInstallations = Array.from(ctx.installedAddons.values()).filter(
+ entry => entry.id === addon.id
+ );
+ if (remainingInstallations.length === 0) {
+ await markAddonAsRemovedByUser(addon.id);
+ await cleanupRemovedAddonFromStorage(ctx, addon.id);
+ }
+ }
+
+ await ctx.saveInstalledAddons();
+ await ctx.saveAddonOrder();
+ addonEmitter.emit(ADDON_EVENTS.ADDON_REMOVED, installationId);
+}
+
+export function getInstalledAddons(ctx: StremioServiceContext): Manifest[] {
+ return ctx.addonOrder
+ .filter(installationId => ctx.installedAddons.has(installationId))
+ .map(installationId => ctx.installedAddons.get(installationId) as Manifest);
+}
+
+export async function getInstalledAddonsAsync(ctx: StremioServiceContext): Promise {
+ await ctx.ensureInitialized();
+ return getInstalledAddons(ctx);
+}
+
+export function isPreInstalledAddon(): boolean {
+ return false;
+}
+
+export async function hasUserRemovedAddon(addonId: string): Promise {
+ try {
+ const removedAddons = await mmkvStorage.getItem('user_removed_addons');
+ if (!removedAddons) {
+ return false;
+ }
+
+ const removedList = JSON.parse(removedAddons);
+ return Array.isArray(removedList) && removedList.includes(addonId);
+ } catch {
+ return false;
+ }
+}
+
+async function markAddonAsRemovedByUser(addonId: string): Promise {
+ try {
+ const removedAddons = await mmkvStorage.getItem('user_removed_addons');
+ let removedList = removedAddons ? JSON.parse(removedAddons) : [];
+ if (!Array.isArray(removedList)) {
+ removedList = [];
+ }
+
+ if (!removedList.includes(addonId)) {
+ removedList.push(addonId);
+ await mmkvStorage.setItem('user_removed_addons', JSON.stringify(removedList));
+ }
+ } catch {
+ // Best-effort cleanup only.
+ }
+}
+
+export async function unmarkAddonAsRemovedByUser(addonId: string): Promise {
+ try {
+ const removedAddons = await mmkvStorage.getItem('user_removed_addons');
+ if (!removedAddons) {
+ return;
+ }
+
+ const removedList = JSON.parse(removedAddons);
+ if (!Array.isArray(removedList)) {
+ return;
+ }
+
+ const updatedList = removedList.filter(id => id !== addonId);
+ await mmkvStorage.setItem('user_removed_addons', JSON.stringify(updatedList));
+ } catch {
+ // Best-effort cleanup only.
+ }
+}
+
+async function cleanupRemovedAddonFromStorage(
+ ctx: StremioServiceContext,
+ addonId: string
+): Promise {
+ try {
+ const scope = await getCurrentScope();
+ const keys = [
+ `@user:${scope}:${ctx.ADDON_ORDER_KEY}`,
+ ctx.ADDON_ORDER_KEY,
+ `@user:local:${ctx.ADDON_ORDER_KEY}`,
+ ];
+
+ for (const key of keys) {
+ const storedOrder = await mmkvStorage.getItem(key);
+ if (!storedOrder) {
+ continue;
+ }
+
+ const order = JSON.parse(storedOrder);
+ if (!Array.isArray(order)) {
+ continue;
+ }
+
+ const updatedOrder = order.filter(id => id !== addonId);
+ await mmkvStorage.setItem(key, JSON.stringify(updatedOrder));
+ }
+ } catch {
+ // Best-effort cleanup only.
+ }
+}
diff --git a/src/services/stremio/addon-order.ts b/src/services/stremio/addon-order.ts
new file mode 100644
index 00000000..fac3eca5
--- /dev/null
+++ b/src/services/stremio/addon-order.ts
@@ -0,0 +1,112 @@
+import { ADDON_EVENTS, addonEmitter } from './events';
+import type { StremioServiceContext } from './context';
+
+export function moveAddonUp(ctx: StremioServiceContext, installationId: string): boolean {
+ const index = ctx.addonOrder.indexOf(installationId);
+ if (index <= 0) {
+ return false;
+ }
+
+ [ctx.addonOrder[index - 1], ctx.addonOrder[index]] = [
+ ctx.addonOrder[index],
+ ctx.addonOrder[index - 1],
+ ];
+ void ctx.saveAddonOrder();
+ addonEmitter.emit(ADDON_EVENTS.ORDER_CHANGED);
+ return true;
+}
+
+export function moveAddonDown(ctx: StremioServiceContext, installationId: string): boolean {
+ const index = ctx.addonOrder.indexOf(installationId);
+ if (index < 0 || index >= ctx.addonOrder.length - 1) {
+ return false;
+ }
+
+ [ctx.addonOrder[index], ctx.addonOrder[index + 1]] = [
+ ctx.addonOrder[index + 1],
+ ctx.addonOrder[index],
+ ];
+ void ctx.saveAddonOrder();
+ addonEmitter.emit(ADDON_EVENTS.ORDER_CHANGED);
+ return true;
+}
+
+export async function applyAddonOrderFromManifestUrls(
+ ctx: StremioServiceContext,
+ manifestUrls: string[]
+): Promise {
+ await ctx.ensureInitialized();
+ if (!Array.isArray(manifestUrls) || manifestUrls.length === 0) {
+ return false;
+ }
+
+ const normalizeManifestUrl = (raw: string): string => {
+ const value = (raw || '').trim();
+ if (!value) {
+ return '';
+ }
+
+ const withManifest = value.includes('manifest.json')
+ ? value
+ : `${value.replace(/\/$/, '')}/manifest.json`;
+ return withManifest.toLowerCase();
+ };
+
+ const localByNormalizedUrl = new Map();
+ for (const installationId of ctx.addonOrder) {
+ const addon = ctx.installedAddons.get(installationId);
+ if (!addon) {
+ continue;
+ }
+
+ const normalized = normalizeManifestUrl(addon.originalUrl || addon.url || '');
+ if (!normalized) {
+ continue;
+ }
+
+ const matches = localByNormalizedUrl.get(normalized) || [];
+ matches.push(installationId);
+ localByNormalizedUrl.set(normalized, matches);
+ }
+
+ const nextOrder: string[] = [];
+ const seenInstallations = new Set();
+
+ for (const remoteUrl of manifestUrls) {
+ const normalizedRemote = normalizeManifestUrl(remoteUrl);
+ const candidates = localByNormalizedUrl.get(normalizedRemote);
+ if (!normalizedRemote || !candidates?.length) {
+ continue;
+ }
+
+ const installationId = candidates.shift();
+ if (!installationId || seenInstallations.has(installationId)) {
+ continue;
+ }
+
+ nextOrder.push(installationId);
+ seenInstallations.add(installationId);
+ }
+
+ for (const installationId of ctx.addonOrder) {
+ if (!ctx.installedAddons.has(installationId) || seenInstallations.has(installationId)) {
+ continue;
+ }
+
+ nextOrder.push(installationId);
+ seenInstallations.add(installationId);
+ }
+
+ const changed =
+ nextOrder.length !== ctx.addonOrder.length ||
+ nextOrder.some((id, index) => id !== ctx.addonOrder[index]);
+
+ if (!changed) {
+ return false;
+ }
+
+ ctx.addonOrder = nextOrder;
+ await ctx.saveAddonOrder();
+ addonEmitter.emit(ADDON_EVENTS.ORDER_CHANGED);
+ return true;
+}
diff --git a/src/services/stremio/catalog-operations.ts b/src/services/stremio/catalog-operations.ts
new file mode 100644
index 00000000..8dba5f7e
--- /dev/null
+++ b/src/services/stremio/catalog-operations.ts
@@ -0,0 +1,423 @@
+import axios from 'axios';
+
+import { logger } from '../../utils/logger';
+import { createSafeAxiosConfig, safeAxiosConfig } from '../../utils/axiosConfig';
+
+import type { StremioServiceContext } from './context';
+import type {
+ AddonCapabilities,
+ AddonCatalogItem,
+ CatalogFilter,
+ Manifest,
+ Meta,
+ MetaDetails,
+ ResourceObject,
+} from './types';
+
+export async function isValidContentId(
+ ctx: StremioServiceContext,
+ type: string,
+ id: string | null | undefined,
+ getAllSupportedTypes: () => string[],
+ getAllSupportedIdPrefixes: (type: string) => string[]
+): Promise {
+ await ctx.ensureInitialized();
+
+ const supportedTypes = getAllSupportedTypes();
+ const isValidType = supportedTypes.includes(type);
+ const lowerId = (id || '').toLowerCase();
+ const isNullishId = !id || lowerId === 'null' || lowerId === 'undefined';
+ const providerLikeIds = new Set(['moviebox', 'torbox']);
+ const isProviderSlug = providerLikeIds.has(lowerId);
+
+ if (!isValidType || isNullishId || isProviderSlug) {
+ return false;
+ }
+
+ const supportedPrefixes = getAllSupportedIdPrefixes(type);
+ if (supportedPrefixes.length === 0) {
+ return true;
+ }
+
+ return supportedPrefixes.some(prefix => {
+ const lowerPrefix = prefix.toLowerCase();
+ if (!lowerId.startsWith(lowerPrefix)) {
+ return false;
+ }
+
+ if (lowerPrefix.endsWith(':') || lowerPrefix.endsWith('_')) {
+ return true;
+ }
+
+ return lowerId.length > lowerPrefix.length;
+ });
+}
+
+export async function getAllCatalogs(
+ ctx: StremioServiceContext
+): Promise> {
+ const result: Record = {};
+ const promises = ctx.getInstalledAddons().map(async addon => {
+ const catalog = addon.catalogs?.[0];
+ if (!catalog) {
+ return;
+ }
+
+ try {
+ const items = await getCatalog(ctx, addon, catalog.type, catalog.id);
+ if (items.length > 0) {
+ result[addon.id] = items;
+ }
+ } catch (error) {
+ logger.error(`Failed to fetch catalog from ${addon.name}:`, error);
+ }
+ });
+
+ await Promise.all(promises);
+ return result;
+}
+
+export async function getCatalog(
+ ctx: StremioServiceContext,
+ manifest: Manifest,
+ type: string,
+ id: string,
+ page = 1,
+ filters: CatalogFilter[] = []
+): Promise {
+ const encodedId = encodeURIComponent(id);
+ const pageSkip = (page - 1) * ctx.DEFAULT_PAGE_SIZE;
+
+ if (!manifest.url) {
+ throw new Error('Addon URL is missing');
+ }
+
+ try {
+ const { baseUrl, queryParams } = ctx.getAddonBaseURL(manifest.url);
+ const extraParts: string[] = [];
+
+ if (filters.length > 0) {
+ filters
+ .filter(filter => filter && filter.value)
+ .forEach(filter => {
+ extraParts.push(
+ `${encodeURIComponent(filter.title)}=${encodeURIComponent(filter.value)}`
+ );
+ });
+ }
+
+ if (pageSkip > 0) {
+ extraParts.push(`skip=${pageSkip}`);
+ }
+
+ const extraArgsPath = extraParts.length > 0 ? `/${extraParts.join('&')}` : '';
+ const urlPathStyle =
+ `${baseUrl}/catalog/${type}/${encodedId}${extraArgsPath}.json` +
+ `${queryParams ? `?${queryParams}` : ''}`;
+ const urlSimple = `${baseUrl}/catalog/${type}/${encodedId}.json${queryParams ? `?${queryParams}` : ''}`;
+
+ const legacyFilterQuery = filters
+ .filter(filter => filter && filter.value)
+ .map(filter => `&${encodeURIComponent(filter.title)}=${encodeURIComponent(filter.value)}`)
+ .join('');
+
+ let urlQueryStyle =
+ `${baseUrl}/catalog/${type}/${encodedId}.json` +
+ `?skip=${pageSkip}&limit=${ctx.DEFAULT_PAGE_SIZE}`;
+ if (queryParams) {
+ urlQueryStyle += `&${queryParams}`;
+ }
+ urlQueryStyle += legacyFilterQuery;
+
+ let response;
+
+ try {
+ if (pageSkip === 0 && extraParts.length === 0) {
+ response = await ctx.retryRequest(() => axios.get(urlSimple, safeAxiosConfig));
+ if (!response?.data?.metas?.length) {
+ throw new Error('Empty response from simple URL');
+ }
+ } else {
+ throw new Error('Has extra args, use path-style');
+ }
+ } catch {
+ try {
+ response = await ctx.retryRequest(() => axios.get(urlPathStyle, safeAxiosConfig));
+ if (!response?.data?.metas?.length) {
+ throw new Error('Empty response from path-style URL');
+ }
+ } catch {
+ response = await ctx.retryRequest(() => axios.get(urlQueryStyle, safeAxiosConfig));
+ }
+ }
+
+ if (!response?.data) {
+ return [];
+ }
+
+ const hasMore = typeof response.data.hasMore === 'boolean' ? response.data.hasMore : undefined;
+ const key = `${manifest.id}|${type}|${id}`;
+ if (typeof hasMore === 'boolean') {
+ ctx.catalogHasMore.set(key, hasMore);
+ }
+
+ return Array.isArray(response.data.metas) ? response.data.metas : [];
+ } catch (error) {
+ logger.error(`Failed to fetch catalog from ${manifest.name}:`, error);
+ throw error;
+ }
+}
+
+export function getCatalogHasMore(
+ ctx: StremioServiceContext,
+ manifestId: string,
+ type: string,
+ id: string
+): boolean | undefined {
+ return ctx.catalogHasMore.get(`${manifestId}|${type}|${id}`);
+}
+
+function addonSupportsMetaResource(addon: Manifest, type: string, id: string): boolean {
+ let hasMetaSupport = false;
+ let supportsIdPrefix = false;
+
+ for (const resource of addon.resources || []) {
+ if (typeof resource === 'object' && resource !== null && 'name' in resource) {
+ const typedResource = resource as ResourceObject;
+ if (typedResource.name === 'meta' && typedResource.types?.includes(type)) {
+ hasMetaSupport = true;
+ supportsIdPrefix =
+ !typedResource.idPrefixes?.length ||
+ typedResource.idPrefixes.some(prefix => id.startsWith(prefix));
+ break;
+ }
+ } else if (resource === 'meta' && addon.types?.includes(type)) {
+ hasMetaSupport = true;
+ supportsIdPrefix =
+ !addon.idPrefixes?.length || addon.idPrefixes.some(prefix => id.startsWith(prefix));
+ break;
+ }
+ }
+
+ const requiresIdPrefix = !!addon.idPrefixes?.length;
+ return hasMetaSupport && (!requiresIdPrefix || supportsIdPrefix);
+}
+
+async function fetchMetaFromAddon(
+ ctx: StremioServiceContext,
+ addon: Manifest,
+ type: string,
+ id: string
+): Promise {
+ const { baseUrl, queryParams } = ctx.getAddonBaseURL(addon.url || '');
+ const encodedId = encodeURIComponent(id);
+ const url = queryParams
+ ? `${baseUrl}/meta/${type}/${encodedId}.json?${queryParams}`
+ : `${baseUrl}/meta/${type}/${encodedId}.json`;
+
+ const response = await ctx.retryRequest(() => axios.get(url, createSafeAxiosConfig(10000)));
+ return response.data?.meta?.id ? response.data.meta : null;
+}
+
+export async function getMetaDetails(
+ ctx: StremioServiceContext,
+ type: string,
+ id: string,
+ preferredAddonId?: string
+): Promise {
+ try {
+ if (!(await ctx.isValidContentId(type, id))) {
+ return null;
+ }
+
+ const addons = ctx.getInstalledAddons();
+
+ if (preferredAddonId) {
+ const preferredAddon = addons.find(addon => addon.id === preferredAddonId);
+ if (preferredAddon?.resources && addonSupportsMetaResource(preferredAddon, type, id)) {
+ try {
+ const meta = await fetchMetaFromAddon(ctx, preferredAddon, type, id);
+ if (meta) {
+ return meta;
+ }
+ } catch {
+ // Fall through to other addons.
+ }
+ }
+ }
+
+ for (const baseUrl of ['https://v3-cinemeta.strem.io', 'http://v3-cinemeta.strem.io']) {
+ try {
+ const encodedId = encodeURIComponent(id);
+ const url = `${baseUrl}/meta/${type}/${encodedId}.json`;
+ const response = await ctx.retryRequest(() => axios.get(url, createSafeAxiosConfig(10000)));
+ if (response.data?.meta?.id) {
+ return response.data.meta;
+ }
+ } catch {
+ // Try next Cinemeta URL.
+ }
+ }
+
+ for (const addon of addons) {
+ if (!addon.resources || addon.id === 'com.linvo.cinemeta' || addon.id === preferredAddonId) {
+ continue;
+ }
+
+ if (!addonSupportsMetaResource(addon, type, id)) {
+ continue;
+ }
+
+ try {
+ const meta = await fetchMetaFromAddon(ctx, addon, type, id);
+ if (meta) {
+ return meta;
+ }
+ } catch {
+ // Try next addon.
+ }
+ }
+
+ return null;
+ } catch (error) {
+ logger.error('Error in getMetaDetails:', error);
+ return null;
+ }
+}
+
+export async function getUpcomingEpisodes(
+ ctx: StremioServiceContext,
+ type: string,
+ id: string,
+ options: {
+ daysBack?: number;
+ daysAhead?: number;
+ maxEpisodes?: number;
+ preferredAddonId?: string;
+ } = {}
+): Promise<{ seriesName: string; poster: string; episodes: any[] } | null> {
+ const { daysBack = 14, daysAhead = 28, maxEpisodes = 50, preferredAddonId } = options;
+
+ try {
+ const metadata = await ctx.getMetaDetails(type, id, preferredAddonId);
+ if (!metadata) {
+ return null;
+ }
+
+ if (!metadata.videos?.length) {
+ return {
+ seriesName: metadata.name,
+ poster: metadata.poster || '',
+ episodes: [],
+ };
+ }
+
+ const now = new Date();
+ const startDate = new Date(now.getTime() - daysBack * 24 * 60 * 60 * 1000);
+ const endDate = new Date(now.getTime() + daysAhead * 24 * 60 * 60 * 1000);
+
+ const episodes = metadata.videos
+ .filter(video => {
+ if (!video.released) {
+ logger.log(`[StremioService] Episode ${video.id} has no release date`);
+ return false;
+ }
+
+ const releaseDate = new Date(video.released);
+ return releaseDate >= startDate && releaseDate <= endDate;
+ })
+ .sort((left, right) => new Date(left.released).getTime() - new Date(right.released).getTime())
+ .slice(0, maxEpisodes);
+
+ return {
+ seriesName: metadata.name,
+ poster: metadata.poster || '',
+ episodes,
+ };
+ } catch (error) {
+ logger.error(`[StremioService] Error fetching upcoming episodes for ${id}:`, error);
+ return null;
+ }
+}
+
+export function getAddonCapabilities(ctx: StremioServiceContext): AddonCapabilities[] {
+ return ctx.getInstalledAddons().map(addon => ({
+ name: addon.name,
+ id: addon.id,
+ version: addon.version,
+ catalogs: addon.catalogs || [],
+ resources: (addon.resources || []).filter(
+ (resource): resource is ResourceObject => typeof resource === 'object' && resource !== null
+ ),
+ types: addon.types || [],
+ }));
+}
+
+export async function getCatalogPreview(
+ ctx: StremioServiceContext,
+ addonId: string,
+ type: string,
+ id: string,
+ limit = 5
+): Promise<{
+ addon: string;
+ type: string;
+ id: string;
+ items: Meta[];
+}> {
+ const addon = ctx.getInstalledAddons().find(entry => entry.id === addonId);
+ if (!addon) {
+ throw new Error(`Addon ${addonId} not found`);
+ }
+
+ const items = await ctx.getCatalog(addon, type, id);
+ return {
+ addon: addonId,
+ type,
+ id,
+ items: items.slice(0, limit),
+ };
+}
+
+export async function getAddonCatalogs(
+ ctx: StremioServiceContext,
+ type: string,
+ id: string
+): Promise {
+ await ctx.ensureInitialized();
+
+ const addons = ctx.getInstalledAddons().filter(addon =>
+ addon.resources?.some(resource =>
+ typeof resource === 'string'
+ ? resource === 'addon_catalog'
+ : (resource as ResourceObject).name === 'addon_catalog'
+ )
+ );
+
+ if (addons.length === 0) {
+ logger.log('[getAddonCatalogs] No addons provide addon_catalog resource');
+ return [];
+ }
+
+ const results: AddonCatalogItem[] = [];
+
+ for (const addon of addons) {
+ try {
+ const { baseUrl, queryParams } = ctx.getAddonBaseURL(addon.url || '');
+ const url =
+ `${baseUrl}/addon_catalog/${type}/${encodeURIComponent(id)}.json` +
+ `${queryParams ? `?${queryParams}` : ''}`;
+
+ logger.log(`[getAddonCatalogs] Fetching from ${addon.name}: ${url}`);
+ const response = await ctx.retryRequest(() => axios.get(url, createSafeAxiosConfig(10000)));
+
+ if (Array.isArray(response.data?.addons)) {
+ results.push(...response.data.addons);
+ }
+ } catch (error) {
+ logger.warn(`[getAddonCatalogs] Failed to fetch from ${addon.name}:`, error);
+ }
+ }
+
+ return results;
+}
diff --git a/src/services/stremio/context.ts b/src/services/stremio/context.ts
new file mode 100644
index 00000000..2279376d
--- /dev/null
+++ b/src/services/stremio/context.ts
@@ -0,0 +1,39 @@
+import type {
+ CatalogFilter,
+ Manifest,
+ Meta,
+ MetaDetails,
+ Stream,
+} from './types';
+
+export interface StremioServiceContext {
+ installedAddons: Map;
+ addonOrder: string[];
+ STORAGE_KEY: string;
+ ADDON_ORDER_KEY: string;
+ DEFAULT_PAGE_SIZE: number;
+ initialized: boolean;
+ initializationPromise: Promise | null;
+ catalogHasMore: Map;
+ ensureInitialized(): Promise;
+ retryRequest(request: () => Promise, retries?: number, delay?: number): Promise;
+ saveInstalledAddons(): Promise;
+ saveAddonOrder(): Promise;
+ generateInstallationId(addonId: string): string;
+ addonProvidesStreams(manifest: Manifest): boolean;
+ formatId(id: string): string;
+ getInstalledAddons(): Manifest[];
+ getAddonBaseURL(url: string): { baseUrl: string; queryParams?: string };
+ processStreams(streams: any[], addon: Manifest): Stream[];
+ isValidContentId(type: string, id: string | null | undefined): Promise;
+ getCatalog(
+ manifest: Manifest,
+ type: string,
+ id: string,
+ page?: number,
+ filters?: CatalogFilter[]
+ ): Promise;
+ getMetaDetails(type: string, id: string, preferredAddonId?: string): Promise;
+ hasUserRemovedAddon(addonId: string): Promise;
+ unmarkAddonAsRemovedByUser(addonId: string): Promise;
+}
diff --git a/src/services/stremio/events.ts b/src/services/stremio/events.ts
new file mode 100644
index 00000000..eacc6c1c
--- /dev/null
+++ b/src/services/stremio/events.ts
@@ -0,0 +1,9 @@
+import EventEmitter from 'eventemitter3';
+
+export const addonEmitter = new EventEmitter();
+
+export const ADDON_EVENTS = {
+ ORDER_CHANGED: 'order_changed',
+ ADDON_ADDED: 'addon_added',
+ ADDON_REMOVED: 'addon_removed',
+} as const;
diff --git a/src/services/stremio/stream-operations.ts b/src/services/stremio/stream-operations.ts
new file mode 100644
index 00000000..6163fb6d
--- /dev/null
+++ b/src/services/stremio/stream-operations.ts
@@ -0,0 +1,391 @@
+import axios from 'axios';
+
+import { mmkvStorage } from '../mmkvStorage';
+import { localScraperService } from '../pluginService';
+import { DEFAULT_SETTINGS, type AppSettings } from '../../hooks/useSettings';
+import { TMDBService } from '../tmdbService';
+import { logger } from '../../utils/logger';
+import { safeAxiosConfig } from '../../utils/axiosConfig';
+
+import type { StremioServiceContext } from './context';
+import type { Manifest, ResourceObject, StreamCallback } from './types';
+
+function pickStreamAddons(ctx: StremioServiceContext, requestType: string, id: string): Manifest[] {
+ return ctx.getInstalledAddons().filter(addon => {
+ if (!Array.isArray(addon.resources)) {
+ logger.log(`â ī¸ [getStreams] Addon ${addon.id} has no valid resources array`);
+ return false;
+ }
+
+ let hasStreamResource = false;
+ let supportsIdPrefix = false;
+
+ for (const resource of addon.resources) {
+ if (typeof resource === 'object' && resource !== null && 'name' in resource) {
+ const typedResource = resource as ResourceObject;
+ if (typedResource.name === 'stream' && typedResource.types?.includes(requestType)) {
+ hasStreamResource = true;
+ supportsIdPrefix =
+ !typedResource.idPrefixes?.length ||
+ typedResource.idPrefixes.some(prefix => id.startsWith(prefix));
+ break;
+ }
+ } else if (resource === 'stream' && addon.types?.includes(requestType)) {
+ hasStreamResource = true;
+ supportsIdPrefix =
+ !addon.idPrefixes?.length || addon.idPrefixes.some(prefix => id.startsWith(prefix));
+ break;
+ }
+ }
+
+ return hasStreamResource && supportsIdPrefix;
+ });
+}
+
+async function runLocalScrapers(
+ type: string,
+ id: string,
+ callback?: StreamCallback
+): Promise {
+ try {
+ const scope = (await mmkvStorage.getItem('@user:current')) || 'local';
+ const settingsJson =
+ (await mmkvStorage.getItem(`@user:${scope}:app_settings`)) ||
+ (await mmkvStorage.getItem('app_settings'));
+ const rawSettings = settingsJson ? JSON.parse(settingsJson) : {};
+ const settings: AppSettings = { ...DEFAULT_SETTINGS, ...rawSettings };
+
+ if (!settings.enableLocalScrapers || !(await localScraperService.hasScrapers())) {
+ return;
+ }
+
+ logger.log('đ§ [getStreams] Executing local scrapers for', type, id);
+
+ const scraperType = type === 'series' ? 'tv' : type;
+ let tmdbId: string | null = null;
+ let season: number | undefined;
+ let episode: number | undefined;
+ let idType: 'imdb' | 'kitsu' | 'tmdb' = 'imdb';
+
+ try {
+ const idParts = id.split(':');
+ let baseId: string;
+
+ if (idParts[0] === 'series') {
+ baseId = idParts[1];
+ if (scraperType === 'tv' && idParts.length >= 4) {
+ season = parseInt(idParts[2], 10);
+ episode = parseInt(idParts[3], 10);
+ }
+
+ if (idParts[1] === 'kitsu') {
+ idType = 'kitsu';
+ baseId = idParts[2];
+ if (scraperType === 'tv' && idParts.length >= 5) {
+ season = parseInt(idParts[3], 10);
+ episode = parseInt(idParts[4], 10);
+ }
+ }
+ } else if (idParts[0].startsWith('tt')) {
+ baseId = idParts[0];
+ if (scraperType === 'tv' && idParts.length >= 3) {
+ season = parseInt(idParts[1], 10);
+ episode = parseInt(idParts[2], 10);
+ }
+ } else if (idParts[0] === 'kitsu') {
+ idType = 'kitsu';
+ baseId = idParts[1];
+ if (scraperType === 'tv' && idParts.length >= 4) {
+ season = parseInt(idParts[2], 10);
+ episode = parseInt(idParts[3], 10);
+ }
+ } else if (idParts[0] === 'tmdb') {
+ idType = 'tmdb';
+ baseId = idParts[1];
+ if (scraperType === 'tv' && idParts.length >= 4) {
+ season = parseInt(idParts[2], 10);
+ episode = parseInt(idParts[3], 10);
+ }
+ } else {
+ baseId = idParts[0];
+ if (scraperType === 'tv' && idParts.length >= 3) {
+ season = parseInt(idParts[1], 10);
+ episode = parseInt(idParts[2], 10);
+ }
+ }
+
+ if (idType === 'imdb') {
+ const tmdbIdNumber = await TMDBService.getInstance().findTMDBIdByIMDB(baseId);
+ if (tmdbIdNumber) {
+ tmdbId = tmdbIdNumber.toString();
+ } else {
+ logger.log(
+ 'đ§ [getStreams] Skipping local scrapers: could not convert IMDb to TMDB for',
+ baseId
+ );
+ }
+ } else if (idType === 'tmdb') {
+ tmdbId = baseId;
+ logger.log('đ§ [getStreams] Using TMDB ID directly for local scrapers:', tmdbId);
+ } else if (idType === 'kitsu') {
+ logger.log('đ§ [getStreams] Skipping local scrapers for kitsu ID:', baseId);
+ } else {
+ tmdbId = baseId;
+ logger.log('đ§ [getStreams] Using base ID as TMDB ID for local scrapers:', tmdbId);
+ }
+ } catch (error) {
+ logger.warn('đ§ [getStreams] Skipping local scrapers due to ID parsing error:', error);
+ }
+
+ if (!tmdbId) {
+ logger.log('đ§ [getStreams] Local scrapers not executed - no TMDB ID available');
+ try {
+ const installedScrapers = await localScraperService.getInstalledScrapers();
+ installedScrapers
+ .filter(scraper => scraper.enabled)
+ .forEach(scraper => callback?.([], scraper.id, scraper.name, null));
+ } catch (error) {
+ logger.warn('đ§ [getStreams] Failed to notify UI about skipped local scrapers:', error);
+ }
+ return;
+ }
+
+ localScraperService.getStreams(scraperType, tmdbId, season, episode, (streams, scraperId, scraperName, error) => {
+ if (!callback) {
+ return;
+ }
+
+ if (error) {
+ callback(null, scraperId, scraperName, error);
+ return;
+ }
+
+ callback(streams || [], scraperId, scraperName, null);
+ });
+ } catch {
+ // Local scrapers are best-effort.
+ }
+}
+
+function logUnmatchedStreamAddons(
+ ctx: StremioServiceContext,
+ addons: Manifest[],
+ effectiveType: string,
+ requestedType: string,
+ id: string
+): void {
+ logger.warn('â ī¸ [getStreams] No addons found that can provide streams');
+
+ const encodedId = encodeURIComponent(id);
+ logger.log(`đĢ [getStreams] No stream addons matched. Would have requested: /stream/${effectiveType}/${encodedId}.json`);
+ logger.log(
+ `đĢ [getStreams] Details: requestedType='${requestedType}' effectiveType='${effectiveType}' id='${id}'`
+ );
+
+ const streamCapableAddons = addons.filter(addon =>
+ addon.resources?.some(resource =>
+ typeof resource === 'object' && resource !== null && 'name' in resource
+ ? (resource as ResourceObject).name === 'stream'
+ : resource === 'stream'
+ )
+ );
+
+ if (streamCapableAddons.length === 0) {
+ logger.log('đĢ [getStreams] No stream-capable addons installed');
+ return;
+ }
+
+ logger.log(`đĢ [getStreams] Found ${streamCapableAddons.length} stream-capable addon(s) that didn't match:`);
+
+ for (const addon of streamCapableAddons) {
+ const streamResources = addon.resources?.filter(resource =>
+ typeof resource === 'object' && resource !== null && 'name' in resource
+ ? (resource as ResourceObject).name === 'stream'
+ : resource === 'stream'
+ );
+
+ for (const resource of streamResources || []) {
+ if (typeof resource === 'object' && resource !== null) {
+ const typedResource = resource as ResourceObject;
+ const types = typedResource.types || [];
+ const prefixes = typedResource.idPrefixes || [];
+ const typeMatch = types.includes(effectiveType);
+ const prefixMatch = prefixes.length === 0 || prefixes.some(prefix => id.startsWith(prefix));
+
+ if (addon.url) {
+ const { baseUrl, queryParams } = ctx.getAddonBaseURL(addon.url);
+ const wouldBeUrl = queryParams
+ ? `${baseUrl}/stream/${effectiveType}/${encodedId}.json?${queryParams}`
+ : `${baseUrl}/stream/${effectiveType}/${encodedId}.json`;
+
+ console.log(
+ ` â ${addon.name} (${addon.id}):\n` +
+ ` types=[${types.join(',')}] typeMatch=${typeMatch}\n` +
+ ` prefixes=[${prefixes.join(',')}] prefixMatch=${prefixMatch}\n` +
+ ` url=${wouldBeUrl}`
+ );
+ }
+ } else if (resource === 'stream' && addon.url) {
+ const addonTypes = addon.types || [];
+ const addonPrefixes = addon.idPrefixes || [];
+ const typeMatch = addonTypes.includes(effectiveType);
+ const prefixMatch =
+ addonPrefixes.length === 0 || addonPrefixes.some(prefix => id.startsWith(prefix));
+ const { baseUrl, queryParams } = ctx.getAddonBaseURL(addon.url);
+ const wouldBeUrl = queryParams
+ ? `${baseUrl}/stream/${effectiveType}/${encodedId}.json?${queryParams}`
+ : `${baseUrl}/stream/${effectiveType}/${encodedId}.json`;
+
+ console.log(
+ ` â ${addon.name} (${addon.id}) [addon-level]:\n` +
+ ` types=[${addonTypes.join(',')}] typeMatch=${typeMatch}\n` +
+ ` prefixes=[${addonPrefixes.join(',')}] prefixMatch=${prefixMatch}\n` +
+ ` url=${wouldBeUrl}`
+ );
+ }
+ }
+ }
+}
+
+export async function getStreams(
+ ctx: StremioServiceContext,
+ type: string,
+ id: string,
+ callback?: StreamCallback
+): Promise {
+ await ctx.ensureInitialized();
+
+ const addons = ctx.getInstalledAddons();
+ await runLocalScrapers(type, id, callback);
+
+ let effectiveType = type;
+ let streamAddons = pickStreamAddons(ctx, type, id);
+
+ logger.log(
+ `đ§ [getStreams] Resolving stream addons for type='${type}' id='${id}' (matched=${streamAddons.length})`
+ );
+
+ if (streamAddons.length === 0) {
+ const fallbackTypes = ['series', 'movie', 'tv', 'channel'].filter(candidate => candidate !== type);
+ for (const fallbackType of fallbackTypes) {
+ const fallbackAddons = pickStreamAddons(ctx, fallbackType, id);
+ if (fallbackAddons.length === 0) {
+ continue;
+ }
+
+ effectiveType = fallbackType;
+ streamAddons = fallbackAddons;
+ logger.log(
+ `đ [getStreams] No stream addons for type '${type}', falling back to '${effectiveType}' for id '${id}'`
+ );
+ break;
+ }
+ }
+
+ if (effectiveType !== type) {
+ logger.log(
+ `đ§ [getStreams] Using effectiveType='${effectiveType}' (requested='${type}') for id='${id}'`
+ );
+ }
+
+ if (streamAddons.length === 0) {
+ logUnmatchedStreamAddons(ctx, addons, effectiveType, type, id);
+ return;
+ }
+
+ streamAddons.forEach(addon => {
+ void (async () => {
+ try {
+ if (!addon.url) {
+ logger.warn(`â ī¸ [getStreams] Addon ${addon.id} has no URL`);
+ callback?.(null, addon.id, addon.name, new Error('Addon has no URL'), addon.installationId);
+ return;
+ }
+
+ const { baseUrl, queryParams } = ctx.getAddonBaseURL(addon.url);
+ const encodedId = encodeURIComponent(id);
+ const url = queryParams
+ ? `${baseUrl}/stream/${effectiveType}/${encodedId}.json?${queryParams}`
+ : `${baseUrl}/stream/${effectiveType}/${encodedId}.json`;
+
+ logger.log(
+ `đ [getStreams] GET ${url} (addon='${addon.name}' id='${addon.id}' install='${addon.installationId}' requestedType='${type}' effectiveType='${effectiveType}' rawId='${id}')`
+ );
+
+ const response = await ctx.retryRequest(() => axios.get(url, safeAxiosConfig));
+ const processedStreams = Array.isArray(response.data?.streams)
+ ? ctx.processStreams(response.data.streams, addon)
+ : [];
+
+ if (Array.isArray(response.data?.streams)) {
+ logger.log(
+ `â
[getStreams] Processed ${processedStreams.length} valid streams from ${addon.name} (${addon.id}) [${addon.installationId}]`
+ );
+ } else {
+ logger.log(
+ `â ī¸ [getStreams] No streams found in response from ${addon.name} (${addon.id}) [${addon.installationId}]`
+ );
+ }
+
+ callback?.(processedStreams, addon.id, addon.name, null, addon.installationId);
+ } catch (error) {
+ callback?.(null, addon.id, addon.name, error as Error, addon.installationId);
+ }
+ })();
+ });
+}
+
+export async function hasStreamProviders(
+ ctx: StremioServiceContext,
+ type?: string
+): Promise {
+ await ctx.ensureInitialized();
+
+ for (const addon of Array.from(ctx.installedAddons.values())) {
+ if (!Array.isArray(addon.resources)) {
+ continue;
+ }
+
+ const hasStreamResource = addon.resources.some(resource =>
+ typeof resource === 'string'
+ ? resource === 'stream'
+ : (resource as ResourceObject).name === 'stream'
+ );
+
+ if (hasStreamResource) {
+ if (!type) {
+ return true;
+ }
+
+ const supportsType =
+ addon.types?.includes(type) ||
+ addon.resources.some(
+ resource =>
+ typeof resource === 'object' &&
+ resource !== null &&
+ (resource as ResourceObject).name === 'stream' &&
+ (resource as ResourceObject).types?.includes(type)
+ );
+
+ if (supportsType) {
+ return true;
+ }
+ }
+
+ if (!type) {
+ continue;
+ }
+
+ const hasMetaResource = addon.resources.some(resource =>
+ typeof resource === 'string'
+ ? resource === 'meta'
+ : (resource as ResourceObject).name === 'meta'
+ );
+
+ if (hasMetaResource && addon.types?.includes(type)) {
+ return true;
+ }
+ }
+
+ return false;
+}
diff --git a/src/services/stremio/subtitle-operations.ts b/src/services/stremio/subtitle-operations.ts
new file mode 100644
index 00000000..fad18a95
--- /dev/null
+++ b/src/services/stremio/subtitle-operations.ts
@@ -0,0 +1,126 @@
+import axios from 'axios';
+
+import { logger } from '../../utils/logger';
+import { createSafeAxiosConfig } from '../../utils/axiosConfig';
+
+import type { StremioServiceContext } from './context';
+import type { ResourceObject, Subtitle } from './types';
+
+export async function getSubtitles(
+ ctx: StremioServiceContext,
+ type: string,
+ id: string,
+ videoId?: string
+): Promise {
+ await ctx.ensureInitialized();
+
+ const idForChecking = type === 'series' && videoId ? videoId.replace('series:', '') : id;
+ const subtitleAddons = ctx.getInstalledAddons().filter(addon => {
+ if (!addon.resources) {
+ return false;
+ }
+
+ const subtitlesResource = addon.resources.find(resource =>
+ typeof resource === 'string'
+ ? resource === 'subtitles'
+ : (resource as ResourceObject).name === 'subtitles'
+ );
+
+ if (!subtitlesResource) {
+ return false;
+ }
+
+ let supportsType = true;
+ if (typeof subtitlesResource === 'object' && subtitlesResource.types) {
+ supportsType = subtitlesResource.types.includes(type);
+ } else if (addon.types) {
+ supportsType = addon.types.includes(type);
+ }
+
+ if (!supportsType) {
+ logger.log(`[getSubtitles] Addon ${addon.name} does not support type ${type}`);
+ return false;
+ }
+
+ let idPrefixes: string[] | undefined;
+ if (typeof subtitlesResource === 'object' && subtitlesResource.idPrefixes) {
+ idPrefixes = subtitlesResource.idPrefixes;
+ } else if (addon.idPrefixes) {
+ idPrefixes = addon.idPrefixes;
+ }
+
+ const supportsIdPrefix =
+ !idPrefixes?.length || idPrefixes.some(prefix => idForChecking.startsWith(prefix));
+
+ if (!supportsIdPrefix) {
+ logger.log(
+ `[getSubtitles] Addon ${addon.name} does not support ID prefix for ${idForChecking} (requires: ${idPrefixes?.join(', ')})`
+ );
+ }
+
+ return supportsIdPrefix;
+ });
+
+ if (subtitleAddons.length === 0) {
+ logger.warn('No subtitle-capable addons installed that support the requested type/id');
+ return [];
+ }
+
+ logger.log(
+ `[getSubtitles] Found ${subtitleAddons.length} subtitle addons for ${type}/${id}: ${subtitleAddons.map(addon => addon.name).join(', ')}`
+ );
+
+ const requests = subtitleAddons.map(async addon => {
+ if (!addon.url) {
+ return [] as Subtitle[];
+ }
+
+ try {
+ const { baseUrl, queryParams } = ctx.getAddonBaseURL(addon.url);
+ const targetId =
+ type === 'series' && videoId
+ ? encodeURIComponent(videoId.replace('series:', ''))
+ : encodeURIComponent(id);
+ const targetType = type === 'series' && videoId ? 'series' : type;
+ const url = queryParams
+ ? `${baseUrl}/subtitles/${targetType}/${targetId}.json?${queryParams}`
+ : `${baseUrl}/subtitles/${targetType}/${targetId}.json`;
+
+ logger.log(`[getSubtitles] Fetching subtitles from ${addon.name}: ${url}`);
+ const response = await ctx.retryRequest(() =>
+ axios.get(url, createSafeAxiosConfig(10000))
+ );
+
+ if (!Array.isArray(response.data?.subtitles)) {
+ logger.log(`[getSubtitles] No subtitles array in response from ${addon.name}`);
+ return [] as Subtitle[];
+ }
+
+ logger.log(`[getSubtitles] Got ${response.data.subtitles.length} subtitles from ${addon.name}`);
+ return response.data.subtitles.map((subtitle: any, index: number) => ({
+ id: subtitle.id || `${addon.id}-${subtitle.lang || 'unknown'}-${index}`,
+ ...subtitle,
+ addon: addon.id,
+ addonName: addon.name,
+ })) as Subtitle[];
+ } catch (error: any) {
+ logger.error(`[getSubtitles] Failed to fetch subtitles from ${addon.name}:`, error?.message || error);
+ return [] as Subtitle[];
+ }
+ });
+
+ const merged = ([] as Subtitle[]).concat(...(await Promise.all(requests)));
+ const seen = new Set();
+
+ const deduped = merged.filter(subtitle => {
+ if (!subtitle.url || seen.has(subtitle.url)) {
+ return false;
+ }
+
+ seen.add(subtitle.url);
+ return true;
+ });
+
+ logger.log(`[getSubtitles] Total: ${deduped.length} unique subtitles from all addons`);
+ return deduped;
+}
diff --git a/src/services/stremio/types.ts b/src/services/stremio/types.ts
new file mode 100644
index 00000000..4789f166
--- /dev/null
+++ b/src/services/stremio/types.ts
@@ -0,0 +1,236 @@
+export interface Meta {
+ id: string;
+ type: string;
+ name: string;
+ poster?: string;
+ posterShape?: 'poster' | 'square' | 'landscape';
+ background?: string;
+ logo?: string;
+ description?: string;
+ releaseInfo?: string;
+ imdbRating?: string;
+ year?: number;
+ genres?: string[];
+ runtime?: string;
+ cast?: string[];
+ director?: string | string[];
+ writer?: string | string[];
+ certification?: string;
+ country?: string;
+ imdb_id?: string;
+ slug?: string;
+ released?: string;
+ trailerStreams?: Array<{
+ title: string;
+ ytId: string;
+ }>;
+ links?: Array<{
+ name: string;
+ category: string;
+ url: string;
+ }>;
+ behaviorHints?: {
+ defaultVideoId?: string;
+ hasScheduledVideos?: boolean;
+ [key: string]: any;
+ };
+ app_extras?: {
+ cast?: Array<{
+ name: string;
+ character?: string;
+ photo?: string;
+ }>;
+ };
+}
+
+export interface Subtitle {
+ id: string;
+ url: string;
+ lang: string;
+ fps?: number;
+ addon?: string;
+ addonName?: string;
+ format?: 'srt' | 'vtt' | 'ass' | 'ssa';
+}
+
+export interface SourceObject {
+ url: string;
+ bytes?: number;
+}
+
+export interface Stream {
+ url?: string;
+ ytId?: string;
+ infoHash?: string;
+ externalUrl?: string;
+ nzbUrl?: string;
+ rarUrls?: SourceObject[];
+ zipUrls?: SourceObject[];
+ '7zipUrls'?: SourceObject[];
+ tgzUrls?: SourceObject[];
+ tarUrls?: SourceObject[];
+ fileIdx?: number;
+ fileMustInclude?: string;
+ servers?: string[];
+ name?: string;
+ title?: string;
+ description?: string;
+ addon?: string;
+ addonId?: string;
+ addonName?: string;
+ size?: number;
+ isFree?: boolean;
+ isDebrid?: boolean;
+ quality?: string;
+ headers?: Record;
+ subtitles?: Subtitle[];
+ sources?: string[];
+ behaviorHints?: {
+ bingeGroup?: string;
+ notWebReady?: boolean;
+ countryWhitelist?: string[];
+ cached?: boolean;
+ proxyHeaders?: {
+ request?: Record;
+ response?: Record;
+ };
+ videoHash?: string;
+ videoSize?: number;
+ filename?: string;
+ [key: string]: any;
+ };
+}
+
+export interface StreamResponse {
+ streams: Stream[];
+ addon: string;
+ addonName: string;
+}
+
+export interface SubtitleResponse {
+ subtitles: Subtitle[];
+ addon: string;
+ addonName: string;
+}
+
+export interface StreamCallback {
+ (
+ streams: Stream[] | null,
+ addonId: string | null,
+ addonName: string | null,
+ error: Error | null,
+ installationId?: string | null
+ ): void;
+}
+
+export interface CatalogFilter {
+ title: string;
+ value: any;
+}
+
+interface Catalog {
+ type: string;
+ id: string;
+ name: string;
+ extraSupported?: string[];
+ extraRequired?: string[];
+ itemCount?: number;
+ extra?: CatalogExtra[];
+}
+
+export interface CatalogExtra {
+ name: string;
+ isRequired?: boolean;
+ options?: string[];
+ optionsLimit?: number;
+}
+
+interface ResourceObject {
+ name: string;
+ types: string[];
+ idPrefixes?: string[];
+ idPrefix?: string[];
+}
+
+export interface Manifest {
+ id: string;
+ installationId?: string;
+ name: string;
+ version: string;
+ description: string;
+ url?: string;
+ originalUrl?: string;
+ catalogs?: Catalog[];
+ resources?: any[];
+ types?: string[];
+ idPrefixes?: string[];
+ manifestVersion?: string;
+ queryParams?: string;
+ behaviorHints?: {
+ configurable?: boolean;
+ configurationRequired?: boolean;
+ adult?: boolean;
+ p2p?: boolean;
+ };
+ config?: ConfigObject[];
+ addonCatalogs?: Catalog[];
+ background?: string;
+ logo?: string;
+ contactEmail?: string;
+}
+
+interface ConfigObject {
+ key: string;
+ type: 'text' | 'number' | 'password' | 'checkbox' | 'select';
+ default?: string;
+ title?: string;
+ options?: string[];
+ required?: boolean;
+}
+
+export interface MetaLink {
+ name: string;
+ category: string;
+ url: string;
+}
+
+export interface MetaDetails extends Meta {
+ videos?: {
+ id: string;
+ title: string;
+ released: string;
+ season?: number;
+ episode?: number;
+ thumbnail?: string;
+ streams?: Stream[];
+ available?: boolean;
+ overview?: string;
+ trailers?: Stream[];
+ }[];
+ links?: MetaLink[];
+}
+
+export interface AddonCapabilities {
+ name: string;
+ id: string;
+ version: string;
+ catalogs: {
+ type: string;
+ id: string;
+ name: string;
+ }[];
+ resources: {
+ name: string;
+ types: string[];
+ idPrefixes?: string[];
+ }[];
+ types: string[];
+}
+
+export interface AddonCatalogItem {
+ transportName: string;
+ transportUrl: string;
+ manifest: Manifest;
+}
+
+export type { Catalog, ConfigObject, ResourceObject };
diff --git a/src/services/stremioService.ts b/src/services/stremioService.ts
index f30b0af2..d0c7d89e 100644
--- a/src/services/stremioService.ts
+++ b/src/services/stremioService.ts
@@ -1,2161 +1,20 @@
-import axios from 'axios';
-import { mmkvStorage } from './mmkvStorage';
-import { logger } from '../utils/logger';
-import EventEmitter from 'eventemitter3';
-import { localScraperService } from './pluginService';
-import { DEFAULT_SETTINGS, AppSettings } from '../hooks/useSettings';
-import { TMDBService } from './tmdbService';
-import { MalSync } from './mal/MalSync';
-import { safeAxiosConfig, createSafeAxiosConfig } from '../utils/axiosConfig';
-
-// Create an event emitter for addon changes
-export const addonEmitter = new EventEmitter();
-export const ADDON_EVENTS = {
- ORDER_CHANGED: 'order_changed',
- ADDON_ADDED: 'addon_added',
- ADDON_REMOVED: 'addon_removed'
-};
-
-// Basic types for Stremio
-export interface Meta {
- id: string;
- type: string;
- name: string;
- poster?: string;
- posterShape?: 'poster' | 'square' | 'landscape'; // For variable aspect ratios
- background?: string;
- logo?: string;
- description?: string;
- releaseInfo?: string;
- imdbRating?: string;
- year?: number;
- genres?: string[];
- runtime?: string;
- cast?: string[];
- director?: string | string[];
- writer?: string | string[];
- certification?: string;
- // Extended fields available from some addons
- country?: string;
- imdb_id?: string;
- slug?: string;
- released?: string;
- trailerStreams?: Array<{
- title: string;
- ytId: string;
- }>;
- links?: Array<{
- name: string;
- category: string;
- url: string;
- }>;
- behaviorHints?: {
- defaultVideoId?: string;
- hasScheduledVideos?: boolean;
- [key: string]: any;
- };
- app_extras?: {
- cast?: Array<{
- name: string;
- character?: string;
- photo?: string;
- }>;
- };
-}
-
-export interface Subtitle {
- id: string; // Required per protocol
- url: string;
- lang: string;
- fps?: number;
- addon?: string;
- addonName?: string;
- format?: 'srt' | 'vtt' | 'ass' | 'ssa';
-}
-
-// Source object for archive streams per protocol
-export interface SourceObject {
- url: string;
- bytes?: number;
-}
-
-export interface Stream {
- // Primary stream source - one of these must be provided
- url?: string; // Direct HTTP(S)/FTP(S)/RTMP URL
- ytId?: string; // YouTube video ID
- infoHash?: string; // BitTorrent info hash
- externalUrl?: string; // External URL to open in browser
- nzbUrl?: string; // Usenet NZB file URL
- rarUrls?: SourceObject[]; // RAR archive files
- zipUrls?: SourceObject[]; // ZIP archive files
- '7zipUrls'?: SourceObject[]; // 7z archive files
- tgzUrls?: SourceObject[]; // TGZ archive files
- tarUrls?: SourceObject[]; // TAR archive files
-
- // Stream selection within archives/torrents
- fileIdx?: number; // File index in archive/torrent
- fileMustInclude?: string; // Regex for file matching in archives
- servers?: string[]; // NNTP servers for nzbUrl
-
- // Display information
- name?: string; // Stream name (usually quality)
- title?: string; // Stream title/description (deprecated for description)
- description?: string; // Stream description
-
- // Addon identification
- addon?: string;
- addonId?: string;
- addonName?: string;
-
- // Stream properties
- size?: number;
- isFree?: boolean;
- isDebrid?: boolean;
- quality?: string;
- headers?: Record;
-
- // Embedded subtitles per protocol
- subtitles?: Subtitle[];
-
- // Additional tracker/DHT sources
- sources?: string[];
-
- // Complete behavior hints per protocol
- behaviorHints?: {
- bingeGroup?: string; // Group for binge watching
- notWebReady?: boolean; // True if not HTTPS MP4
- countryWhitelist?: string[]; // ISO 3166-1 alpha-3 codes (lowercase)
- cached?: boolean; // Debrid cached status
- proxyHeaders?: { // Custom headers for stream
- request?: Record;
- response?: Record;
- };
- videoHash?: string; // OpenSubtitles hash
- videoSize?: number; // Video file size in bytes
- filename?: string; // Video filename
- [key: string]: any;
- };
-}
-
-export interface StreamResponse {
- streams: Stream[];
- addon: string;
- addonName: string;
-}
-
-export interface SubtitleResponse {
- subtitles: Subtitle[];
- addon: string;
- addonName: string;
-}
-
-// Modify the callback signature to include addon ID and installation ID
-interface StreamCallback {
- (streams: Stream[] | null, addonId: string | null, addonName: string | null, error: Error | null, installationId?: string | null): void;
-}
-
-interface CatalogFilter {
- title: string;
- value: any;
-}
-
-interface Catalog {
- type: string;
- id: string;
- name: string;
- extraSupported?: string[];
- extraRequired?: string[];
- itemCount?: number;
- // Per Stremio protocol - extra properties for filtering
- extra?: CatalogExtra[];
-}
-
-// Extra property definition per protocol
-export interface CatalogExtra {
- name: string; // Property name (e.g., 'genre', 'search', 'skip')
- isRequired?: boolean; // If true, must always be provided
- options?: string[]; // Available options (e.g., genre list)
- optionsLimit?: number; // Max selections allowed (default 1)
-}
-
-interface ResourceObject {
- name: string;
- types: string[];
- idPrefixes?: string[];
- idPrefix?: string[];
-}
-
-export interface Manifest {
- id: string;
- installationId?: string; // Unique ID for this installation (allows multiple installs of same addon)
- name: string;
- version: string;
- description: string;
- url?: string;
- originalUrl?: string;
- catalogs?: Catalog[];
- resources?: ResourceObject[];
- types?: string[];
- idPrefixes?: string[];
- manifestVersion?: string;
- queryParams?: string;
- behaviorHints?: {
- configurable?: boolean;
- configurationRequired?: boolean; // Per protocol
- adult?: boolean; // Adult content flag
- p2p?: boolean; // P2P content flag
- };
- config?: ConfigObject[]; // User configuration
- addonCatalogs?: Catalog[]; // Addon catalogs
- background?: string; // Background image URL
- logo?: string; // Logo URL
- contactEmail?: string; // Contact email
-}
-
-// Config object for addon configuration per protocol
-interface ConfigObject {
- key: string;
- type: 'text' | 'number' | 'password' | 'checkbox' | 'select';
- default?: string;
- title?: string;
- options?: string[];
- required?: boolean;
-}
-
-// Meta Link object per protocol
-export interface MetaLink {
- name: string;
- category: string; // 'actor', 'director', 'writer', etc.
- url: string; // External URL or stremio:/// deep link
-}
-
-export interface MetaDetails extends Meta {
- videos?: {
- id: string;
- title: string;
- released: string;
- season?: number;
- episode?: number;
- thumbnail?: string;
- streams?: Stream[]; // Embedded streams (used by PPV-style addons)
- available?: boolean; // Availability flag per protocol
- overview?: string; // Episode summary per protocol
- trailers?: Stream[]; // Trailer streams per protocol
- }[];
- links?: MetaLink[]; // Actor/Director/Genre links per protocol
-}
-
-export interface AddonCapabilities {
- name: string;
- id: string;
- version: string;
- catalogs: {
- type: string;
- id: string;
- name: string;
- }[];
- resources: {
- name: string;
- types: string[];
- idPrefixes?: string[];
- }[];
- types: string[];
-}
-
-class StremioService {
- private static instance: StremioService;
- private installedAddons: Map = new Map(); // Key is installationId
- private addonOrder: string[] = []; // Array of installationIds
- private readonly STORAGE_KEY = 'stremio-addons';
- private readonly ADDON_ORDER_KEY = 'stremio-addon-order';
- private readonly MAX_CONCURRENT_REQUESTS = 3;
- private readonly DEFAULT_PAGE_SIZE = 100; // Protocol standard page size
- private initialized: boolean = false;
- private initializationPromise: Promise | null = null;
- private catalogHasMore: Map = new Map();
-
- private constructor() {
- // Start initialization but don't wait for it
- this.initializationPromise = this.initialize();
- }
-
- // Generate a unique installation ID for an addon
- private generateInstallationId(addonId: string): string {
- const timestamp = Date.now();
- const random = Math.random().toString(36).substring(2, 9);
- return `${addonId}-${timestamp}-${random}`;
- }
-
-
- private addonProvidesStreams(manifest: Manifest): boolean {
- if (!manifest.resources || !Array.isArray(manifest.resources)) {
- return false;
- }
-
- return manifest.resources.some(resource => {
- if (typeof resource === 'string') {
- return resource === 'stream';
- } else if (typeof resource === 'object' && resource !== null && 'name' in resource) {
- return (resource as ResourceObject).name === 'stream';
- }
- return false;
- });
- }
-
- // Dynamic validator for content IDs based on installed addon capabilities
- public async isValidContentId(type: string, id: string | null | undefined): Promise {
- // Ensure addons are initialized before checking types
- await this.ensureInitialized();
-
- // Get all supported types from installed addons
- const supportedTypes = this.getAllSupportedTypes();
- const isValidType = supportedTypes.includes(type);
-
- const lowerId = (id || '').toLowerCase();
- const isNullishId = !id || lowerId === 'null' || lowerId === 'undefined';
- const providerLikeIds = new Set(['moviebox', 'torbox']);
- const isProviderSlug = providerLikeIds.has(lowerId);
-
- if (!isValidType || isNullishId || isProviderSlug) return false;
-
- // Get all supported ID prefixes from installed addons
- const supportedPrefixes = this.getAllSupportedIdPrefixes(type);
-
- // If no addons declare specific prefixes, allow any non-empty string
- if (supportedPrefixes.length === 0) {
- return true;
- }
-
- // Check if the ID matches any supported prefix.
- // For prefixes without a trailing separator (e.g. "mal", "kitsu"), the ID must be
- // longer than the prefix itself so that bare prefix strings like "mal" are rejected.
- const result = supportedPrefixes.some(prefix => {
- const lowerPrefix = prefix.toLowerCase();
- if (!lowerId.startsWith(lowerPrefix)) return false;
- if (lowerPrefix.endsWith(':') || lowerPrefix.endsWith('_')) return true;
- return lowerId.length > lowerPrefix.length;
- });
- return result;
- }
-
- // Get all content types supported by installed addons
- public getAllSupportedTypes(): string[] {
- const addons = this.getInstalledAddons();
- const types = new Set();
-
- for (const addon of addons) {
- // Check addon-level types
- if (addon.types && Array.isArray(addon.types)) {
- addon.types.forEach(type => types.add(type));
- }
-
- // Check resource-level types
- if (addon.resources && Array.isArray(addon.resources)) {
- for (const resource of addon.resources) {
- if (typeof resource === 'object' && resource !== null && 'name' in resource) {
- const typedResource = resource as ResourceObject;
- if (Array.isArray(typedResource.types)) {
- typedResource.types.forEach(type => types.add(type));
- }
- }
- }
- }
-
- // Check catalog-level types
- if (addon.catalogs && Array.isArray(addon.catalogs)) {
- for (const catalog of addon.catalogs) {
- if (catalog.type) {
- types.add(catalog.type);
- }
- }
- }
- }
-
- return Array.from(types);
- }
-
- // Get all ID prefixes supported by installed addons for a given content type
- public getAllSupportedIdPrefixes(type: string): string[] {
- const addons = this.getInstalledAddons();
- const prefixes = new Set();
-
- for (const addon of addons) {
- // Check addon-level idPrefixes
- if (addon.idPrefixes && Array.isArray(addon.idPrefixes)) {
- addon.idPrefixes.forEach(prefix => prefixes.add(prefix));
- }
-
- // Check resource-level idPrefixes
- if (addon.resources && Array.isArray(addon.resources)) {
- for (const resource of addon.resources) {
- if (typeof resource === 'object' && resource !== null && 'name' in resource) {
- const typedResource = resource as ResourceObject;
- // Only include prefixes for resources that support the content type
- if (Array.isArray(typedResource.types) && typedResource.types.includes(type)) {
- if (Array.isArray(typedResource.idPrefixes)) {
- typedResource.idPrefixes.forEach(prefix => prefixes.add(prefix));
- }
- }
- }
- }
- }
- }
-
- return Array.from(prefixes);
- }
-
- // Check if a content ID belongs to a collection addon
- public isCollectionContent(id: string): { isCollection: boolean; addon?: Manifest } {
- const addons = this.getInstalledAddons();
-
- for (const addon of addons) {
- // Check if this addon supports collections
- const supportsCollections = addon.types?.includes('collections') ||
- addon.catalogs?.some(catalog => catalog.type === 'collections');
-
- if (!supportsCollections) continue;
-
- // Check if our ID matches this addon's prefixes
- const addonPrefixes = addon.idPrefixes || [];
- const resourcePrefixes = addon.resources
- ?.filter(resource => typeof resource === 'object' && resource !== null && 'name' in resource)
- ?.filter(resource => (resource as any).name === 'meta' || (resource as any).name === 'catalog')
- ?.flatMap(resource => (resource as any).idPrefixes || []) || [];
-
- const allPrefixes = [...addonPrefixes, ...resourcePrefixes];
- if (allPrefixes.some(prefix => id.startsWith(prefix))) {
- return { isCollection: true, addon };
- }
- }
-
- return { isCollection: false };
- }
-
- static getInstance(): StremioService {
- if (!StremioService.instance) {
- StremioService.instance = new StremioService();
- }
- return StremioService.instance;
- }
-
- private async initialize(): Promise {
- if (this.initialized) return;
-
- try {
- const scope = (await mmkvStorage.getItem('@user:current')) || 'local';
- // Prefer scoped storage, but fall back to legacy keys to preserve older installs
- let storedAddons = await mmkvStorage.getItem(`@user:${scope}:${this.STORAGE_KEY}`);
- if (!storedAddons) storedAddons = await mmkvStorage.getItem(this.STORAGE_KEY);
- if (!storedAddons) storedAddons = await mmkvStorage.getItem(`@user:local:${this.STORAGE_KEY}`);
-
- if (storedAddons) {
- const parsed = JSON.parse(storedAddons);
-
- // Convert to Map using installationId as key
- this.installedAddons = new Map();
- for (const addon of parsed) {
- if (addon && addon.id) {
- // Generate installationId for existing addons that don't have one (migration)
- if (!addon.installationId) {
- addon.installationId = this.generateInstallationId(addon.id);
- }
- this.installedAddons.set(addon.installationId, addon);
- }
- }
- }
-
- // Install Cinemeta for new users, but allow existing users to uninstall it
- const cinemetaId = 'com.linvo.cinemeta';
- const hasUserRemovedCinemeta = await this.hasUserRemovedAddon(cinemetaId);
-
- // Check if Cinemeta is already installed (by checking addon.id, not installationId)
- const hasCinemeta = Array.from(this.installedAddons.values()).some(addon => addon.id === cinemetaId);
-
- if (!hasCinemeta && !hasUserRemovedCinemeta) {
- try {
- const cinemetaManifest = await this.getManifest('https://v3-cinemeta.strem.io/manifest.json');
- cinemetaManifest.installationId = this.generateInstallationId(cinemetaId);
- this.installedAddons.set(cinemetaManifest.installationId, cinemetaManifest);
- } catch (error) {
- // Fallback to minimal manifest if fetch fails
- const fallbackManifest: Manifest = {
- id: cinemetaId,
- installationId: this.generateInstallationId(cinemetaId),
- name: 'Cinemeta',
- version: '3.0.13',
- description: 'Provides metadata for movies and series from TheTVDB, TheMovieDB, etc.',
- url: 'https://v3-cinemeta.strem.io',
- originalUrl: 'https://v3-cinemeta.strem.io/manifest.json',
- types: ['movie', 'series'],
- catalogs: [
- {
- type: 'movie',
- id: 'top',
- name: 'Popular',
- extraSupported: ['search', 'genre', 'skip']
- },
- {
- type: 'series',
- id: 'top',
- name: 'Popular',
- extraSupported: ['search', 'genre', 'skip']
- }
- ],
- resources: [
- {
- name: 'catalog',
- types: ['movie', 'series'],
- idPrefixes: ['tt']
- },
- {
- name: 'meta',
- types: ['movie', 'series'],
- idPrefixes: ['tt']
- }
- ],
- behaviorHints: {
- configurable: false
- }
- };
- this.installedAddons.set(fallbackManifest.installationId!, fallbackManifest);
- }
- }
-
- // Install OpenSubtitles v3 by default unless user has explicitly removed it
- const opensubsId = 'org.stremio.opensubtitlesv3';
- const hasUserRemovedOpenSubtitles = await this.hasUserRemovedAddon(opensubsId);
-
- // Check if OpenSubtitles is already installed (by checking addon.id, not installationId)
- const hasOpenSubs = Array.from(this.installedAddons.values()).some(addon => addon.id === opensubsId);
-
- if (!hasOpenSubs && !hasUserRemovedOpenSubtitles) {
- try {
- const opensubsManifest = await this.getManifest('https://opensubtitles-v3.strem.io/manifest.json');
- opensubsManifest.installationId = this.generateInstallationId(opensubsId);
- this.installedAddons.set(opensubsManifest.installationId, opensubsManifest);
- } catch (error) {
- const fallbackManifest: Manifest = {
- id: opensubsId,
- installationId: this.generateInstallationId(opensubsId),
- name: 'OpenSubtitles v3',
- version: '1.0.0',
- description: 'OpenSubtitles v3 Addon for Stremio',
- url: 'https://opensubtitles-v3.strem.io',
- originalUrl: 'https://opensubtitles-v3.strem.io/manifest.json',
- types: ['movie', 'series'],
- catalogs: [],
- resources: [
- {
- name: 'subtitles',
- types: ['movie', 'series'],
- idPrefixes: ['tt']
- }
- ],
- behaviorHints: {
- configurable: false
- }
- };
- this.installedAddons.set(fallbackManifest.installationId!, fallbackManifest);
- }
- }
-
- // Load addon order if exists (scoped first, then legacy, then @user:local for migration safety)
- let storedOrder = await mmkvStorage.getItem(`@user:${scope}:${this.ADDON_ORDER_KEY}`);
- if (!storedOrder) storedOrder = await mmkvStorage.getItem(this.ADDON_ORDER_KEY);
- if (!storedOrder) storedOrder = await mmkvStorage.getItem(`@user:local:${this.ADDON_ORDER_KEY}`);
- if (storedOrder) {
- this.addonOrder = JSON.parse(storedOrder);
- // Filter out any installationIds that aren't in installedAddons
- this.addonOrder = this.addonOrder.filter(installationId => this.installedAddons.has(installationId));
- }
-
- // Add Cinemeta to order only if user hasn't removed it
- const hasUserRemovedCinemetaOrder = await this.hasUserRemovedAddon(cinemetaId);
- const cinemetaInstallation = Array.from(this.installedAddons.values()).find(addon => addon.id === cinemetaId);
- if (cinemetaInstallation && cinemetaInstallation.installationId &&
- !this.addonOrder.includes(cinemetaInstallation.installationId) && !hasUserRemovedCinemetaOrder) {
- this.addonOrder.push(cinemetaInstallation.installationId);
- }
-
- // Only add OpenSubtitles to order if user hasn't removed it
- const hasUserRemovedOpenSubtitlesOrder = await this.hasUserRemovedAddon(opensubsId);
- const opensubsInstallation = Array.from(this.installedAddons.values()).find(addon => addon.id === opensubsId);
- if (opensubsInstallation && opensubsInstallation.installationId &&
- !this.addonOrder.includes(opensubsInstallation.installationId) && !hasUserRemovedOpenSubtitlesOrder) {
- this.addonOrder.push(opensubsInstallation.installationId);
- }
-
- // Add any missing addons to the order (use installationIds)
- const installedInstallationIds = Array.from(this.installedAddons.keys());
- const missingInstallationIds = installedInstallationIds.filter(installationId => !this.addonOrder.includes(installationId));
- this.addonOrder = [...this.addonOrder, ...missingInstallationIds];
-
- // Ensure order and addons are saved
- await this.saveAddonOrder();
- await this.saveInstalledAddons();
-
- this.initialized = true;
- } catch (error) {
- // Initialize with empty state on error
- this.installedAddons = new Map();
- this.addonOrder = [];
- this.initialized = true;
- }
- }
-
- // Ensure service is initialized before any operation
- private async ensureInitialized(): Promise {
- if (!this.initialized && this.initializationPromise) {
- await this.initializationPromise;
- }
- }
-
- private async retryRequest(request: () => Promise, retries = 1, delay = 1000): Promise {
- let lastError: any;
- for (let attempt = 0; attempt < retries + 1; attempt++) {
- try {
- return await request();
- } catch (error: any) {
- lastError = error;
-
- // Don't retry on 404 errors (content not found) - these are expected for some content
- if (error.response?.status === 404) {
- throw error;
- }
-
- // Only log warnings for non-404 errors to reduce noise
- if (error.response?.status !== 404) {
- logger.warn(`Request failed (attempt ${attempt + 1}/${retries + 1}):`, {
- message: error.message,
- code: error.code,
- isAxiosError: error.isAxiosError,
- status: error.response?.status,
- });
- }
-
- if (attempt < retries) {
- const backoffDelay = delay * Math.pow(2, attempt);
- logger.log(`Retrying in ${backoffDelay}ms...`);
- await new Promise(resolve => setTimeout(resolve, backoffDelay));
- }
- }
- }
- throw lastError;
- }
-
- private async saveInstalledAddons(): Promise {
- try {
- const addonsArray = Array.from(this.installedAddons.values());
- const scope = (await mmkvStorage.getItem('@user:current')) || 'local';
- // Write to both scoped and legacy keys for compatibility
- await Promise.all([
- mmkvStorage.setItem(`@user:${scope}:${this.STORAGE_KEY}`, JSON.stringify(addonsArray)),
- mmkvStorage.setItem(this.STORAGE_KEY, JSON.stringify(addonsArray)),
- ]);
- } catch (error) {
- // Continue even if save fails
- }
- }
-
- private async saveAddonOrder(): Promise {
- try {
- const scope = (await mmkvStorage.getItem('@user:current')) || 'local';
- // Write to both scoped and legacy keys for compatibility
- await Promise.all([
- mmkvStorage.setItem(`@user:${scope}:${this.ADDON_ORDER_KEY}`, JSON.stringify(this.addonOrder)),
- mmkvStorage.setItem(this.ADDON_ORDER_KEY, JSON.stringify(this.addonOrder)),
- ]);
- } catch (error) {
- // Continue even if save fails
- }
- }
-
- async getManifest(url: string): Promise {
- try {
- // Clean up URL - ensure it ends with manifest.json
- const manifestUrl = url.endsWith('manifest.json')
- ? url
- : `${url.replace(/\/$/, '')}/manifest.json`;
-
- const response = await this.retryRequest(async () => {
- return await axios.get(manifestUrl, safeAxiosConfig);
- });
-
- const manifest = response.data;
-
- // Add some extra fields for internal use
- manifest.originalUrl = url;
- manifest.url = url.replace(/manifest\.json$/, '');
-
- // Ensure ID exists
- if (!manifest.id) {
- manifest.id = this.formatId(url);
- }
-
- return manifest;
- } catch (error) {
- logger.error(`Failed to fetch manifest from ${url}:`, error);
- throw new Error(`Failed to fetch addon manifest from ${url}`);
- }
- }
-
- async installAddon(url: string): Promise {
- const manifest = await this.getManifest(url);
- if (manifest && manifest.id) {
- // Check if this addon is already installed
- const existingInstallations = Array.from(this.installedAddons.values()).filter(a => a.id === manifest.id);
- const isAlreadyInstalled = existingInstallations.length > 0;
-
- // Only allow multiple installations for stream-providing addons
- if (isAlreadyInstalled && !this.addonProvidesStreams(manifest)) {
- throw new Error('This addon is already installed. Multiple installations are only allowed for stream providers.');
- }
-
- // Generate a unique installation ID for this installation
- manifest.installationId = this.generateInstallationId(manifest.id);
-
- // Store using installationId as key (allows multiple installations of same addon)
- this.installedAddons.set(manifest.installationId, manifest);
-
- // If addon was previously removed by user, unmark it on reinstall and clean up
- await this.unmarkAddonAsRemovedByUser(manifest.id);
- await this.cleanupRemovedAddonFromStorage(manifest.id);
-
- // Add installationId to order (new addons go to the end)
- if (!this.addonOrder.includes(manifest.installationId)) {
- this.addonOrder.push(manifest.installationId);
- }
-
- await this.saveInstalledAddons();
- await this.saveAddonOrder();
- // Emit an event that an addon was added (include both ids for compatibility)
- addonEmitter.emit(ADDON_EVENTS.ADDON_ADDED, { installationId: manifest.installationId, addonId: manifest.id });
- } else {
- throw new Error('Invalid addon manifest');
- }
- }
-
- async removeAddon(installationId: string): Promise {
- // Allow removal of any addon installation, including pre-installed ones like Cinemeta
- if (this.installedAddons.has(installationId)) {
- const addon = this.installedAddons.get(installationId);
- this.installedAddons.delete(installationId);
- // Remove from order using installationId
- this.addonOrder = this.addonOrder.filter(id => id !== installationId);
-
- // Track user explicit removal only if this is the last installation of this addon
- if (addon) {
- const remainingInstallations = Array.from(this.installedAddons.values()).filter(a => a.id === addon.id);
- if (remainingInstallations.length === 0) {
- // This was the last installation, mark addon as removed by user
- await this.markAddonAsRemovedByUser(addon.id);
- // Proactively clean up any persisted orders/legacy keys for this addon
- await this.cleanupRemovedAddonFromStorage(addon.id);
- }
- }
-
- // Persist removals before app possibly exits
- await this.saveInstalledAddons();
- await this.saveAddonOrder();
- // Emit an event that an addon was removed
- addonEmitter.emit(ADDON_EVENTS.ADDON_REMOVED, installationId);
- }
- }
-
- getInstalledAddons(): Manifest[] {
- // Return addons in the specified order (using installationIds)
- const result = this.addonOrder
- .filter(installationId => this.installedAddons.has(installationId))
- .map(installationId => this.installedAddons.get(installationId)!);
- return result;
- }
-
- async getInstalledAddonsAsync(): Promise {
- await this.ensureInitialized();
- return this.getInstalledAddons();
- }
-
- // Check if an addon is pre-installed and cannot be removed
- isPreInstalledAddon(id: string): boolean {
- // Allow removing all addons, including Cinemeta
- return false;
- }
-
- // Check if user has explicitly removed an addon
- async hasUserRemovedAddon(addonId: string): Promise {
- try {
- const removedAddons = await mmkvStorage.getItem('user_removed_addons');
- if (!removedAddons) return false;
- const removedList = JSON.parse(removedAddons);
- return Array.isArray(removedList) && removedList.includes(addonId);
- } catch (error) {
- return false;
- }
- }
-
- // Mark an addon as removed by user
- private async markAddonAsRemovedByUser(addonId: string): Promise {
- try {
- const removedAddons = await mmkvStorage.getItem('user_removed_addons');
- let removedList = removedAddons ? JSON.parse(removedAddons) : [];
- if (!Array.isArray(removedList)) removedList = [];
-
- if (!removedList.includes(addonId)) {
- removedList.push(addonId);
- await mmkvStorage.setItem('user_removed_addons', JSON.stringify(removedList));
- }
- } catch (error) {
- // Silently fail - this is not critical functionality
- }
- }
-
- // Remove an addon from the user removed list (allows reinstallation)
- async unmarkAddonAsRemovedByUser(addonId: string): Promise {
- try {
- const removedAddons = await mmkvStorage.getItem('user_removed_addons');
- if (!removedAddons) return;
-
- let removedList = JSON.parse(removedAddons);
- if (!Array.isArray(removedList)) return;
-
- const updatedList = removedList.filter(id => id !== addonId);
- await mmkvStorage.setItem('user_removed_addons', JSON.stringify(updatedList));
- } catch (error) {
- // Silently fail - this is not critical functionality
- }
- }
-
- // Clean up removed addon from all storage locations
- private async cleanupRemovedAddonFromStorage(addonId: string): Promise {
- try {
- const scope = (await mmkvStorage.getItem('@user:current')) || 'local';
-
- // Remove from all possible addon order storage keys
- const keys = [
- `@user:${scope}:${this.ADDON_ORDER_KEY}`,
- this.ADDON_ORDER_KEY,
- `@user:local:${this.ADDON_ORDER_KEY}`
- ];
-
- for (const key of keys) {
- const storedOrder = await mmkvStorage.getItem(key);
- if (storedOrder) {
- const order = JSON.parse(storedOrder);
- if (Array.isArray(order)) {
- const updatedOrder = order.filter(id => id !== addonId);
- await mmkvStorage.setItem(key, JSON.stringify(updatedOrder));
- }
- }
- }
- } catch (error) {
- // Silently fail - this is not critical functionality
- }
- }
-
- private formatId(id: string): string {
- return id.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase();
- }
-
- async getAllCatalogs(): Promise<{ [addonId: string]: Meta[] }> {
- const result: { [addonId: string]: Meta[] } = {};
- const addons = this.getInstalledAddons();
-
- const promises = addons.map(async (addon) => {
- if (!addon.catalogs || addon.catalogs.length === 0) return;
-
- const catalog = addon.catalogs[0]; // Just take the first catalog for now
-
- try {
- const items = await this.getCatalog(addon, catalog.type, catalog.id);
- if (items.length > 0) {
- result[addon.id] = items;
- }
- } catch (error) {
- logger.error(`Failed to fetch catalog from ${addon.name}:`, error);
- }
- });
-
- await Promise.all(promises);
- return result;
- }
-
- private getAddonBaseURL(url: string): { baseUrl: string; queryParams?: string } {
- // Extract query parameters if they exist
- const [baseUrl, queryString] = url.split('?');
-
- // Remove trailing manifest.json and slashes
- let cleanBaseUrl = baseUrl.replace(/manifest\.json$/, '').replace(/\/$/, '');
-
- // Ensure URL has protocol
- if (!cleanBaseUrl.startsWith('http')) {
- cleanBaseUrl = `https://${cleanBaseUrl}`;
- }
-
- return { baseUrl: cleanBaseUrl, queryParams: queryString };
- }
-
- async getCatalog(manifest: Manifest, type: string, id: string, page = 1, filters: CatalogFilter[] = []): Promise {
- // Build URLs per Stremio protocol: /{resource}/{type}/{id}/{extraArgs}.json
- // Extra args (search, genre, skip) go in path segment, NOT query params
- const encodedId = encodeURIComponent(id);
- const pageSkip = (page - 1) * this.DEFAULT_PAGE_SIZE;
-
- // For all addons
- if (!manifest.url) {
- throw new Error('Addon URL is missing');
- }
-
- try {
- if (__DEV__) console.log(`đ [getCatalog] Manifest URL for ${manifest.name}: ${manifest.url}`);
- const { baseUrl, queryParams } = this.getAddonBaseURL(manifest.url);
-
- // Build extraArgs as combined path segment per protocol
- // Format: /catalog/{type}/{id}/{extraArgs}.json where extraArgs is like "genre=Action&skip=100"
- const extraParts: string[] = [];
-
- // Add filters to extra args (genre, search, etc.)
- if (filters && filters.length > 0) {
- filters.filter(f => f && f.value).forEach(f => {
- extraParts.push(`${encodeURIComponent(f.title)}=${encodeURIComponent(f.value)}`);
- });
- }
-
- // Add skip for pagination (only if not page 1)
- if (pageSkip > 0) {
- extraParts.push(`skip=${pageSkip}`);
- }
-
- // Build the extraArgs path segment
- const extraArgsPath = extraParts.length > 0 ? `/${extraParts.join('&')}` : '';
-
- // Construct URLs per protocol
- // Primary: Path-style with extra args in path segment
- const urlPathStyle = `${baseUrl}/catalog/${type}/${encodedId}${extraArgsPath}.json${queryParams ? `?${queryParams}` : ''}`;
-
- // Fallback for page 1 without filters: simple URL
- const urlSimple = `${baseUrl}/catalog/${type}/${encodedId}.json${queryParams ? `?${queryParams}` : ''}`;
-
- // Legacy fallback: Query-style URL (for older addons)
- const legacyFilterQuery = (filters || [])
- .filter(f => f && f.value)
- .map(f => `&${encodeURIComponent(f.title)}=${encodeURIComponent(f.value!)}`)
- .join('');
- let urlQueryStyle = `${baseUrl}/catalog/${type}/${encodedId}.json?skip=${pageSkip}&limit=${this.DEFAULT_PAGE_SIZE}`;
- if (queryParams) urlQueryStyle += `&${queryParams}`;
- urlQueryStyle += legacyFilterQuery;
-
- // Try URLs in order of compatibility
- let response;
- try {
- // For page 1 without filters, try simple URL first (best compatibility)
- if (pageSkip === 0 && extraParts.length === 0) {
- if (__DEV__) console.log(`đ [getCatalog] Trying simple URL for ${manifest.name}: ${urlSimple}`);
- response = await this.retryRequest(async () => axios.get(urlSimple, safeAxiosConfig));
- // Check if we got valid metas - if empty, try other styles
- if (!response?.data?.metas || response.data.metas.length === 0) {
- throw new Error('Empty response from simple URL');
- }
- } else {
- throw new Error('Has extra args, use path-style');
- }
- } catch (e) {
- try {
- // Try path-style URL (correct per protocol)
- if (__DEV__) console.log(`đ [getCatalog] Trying path-style URL for ${manifest.name}: ${urlPathStyle}`);
- response = await this.retryRequest(async () => axios.get(urlPathStyle, safeAxiosConfig));
- // Check if we got valid metas - if empty, try query-style
- if (!response?.data?.metas || response.data.metas.length === 0) {
- throw new Error('Empty response from path-style URL');
- }
- } catch (e2) {
- try {
- // Try legacy query-style URL as last resort
- if (__DEV__) console.log(`đ [getCatalog] Trying query-style URL for ${manifest.name}: ${urlQueryStyle}`);
- response = await this.retryRequest(async () => axios.get(urlQueryStyle, safeAxiosConfig));
- } catch (e3) {
- if (__DEV__) console.log(`â [getCatalog] All URL styles failed for ${manifest.name}`);
- throw e3;
- }
- }
- }
-
- if (response && response.data) {
- const hasMore = typeof response.data.hasMore === 'boolean' ? response.data.hasMore : undefined;
- try {
- const key = `${manifest.id}|${type}|${id}`;
- if (typeof hasMore === 'boolean') this.catalogHasMore.set(key, hasMore);
- } catch { }
- if (response.data.metas && Array.isArray(response.data.metas)) {
- return response.data.metas;
- }
- }
- return [];
- } catch (error) {
- logger.error(`Failed to fetch catalog from ${manifest.name}:`, error);
- throw error;
- }
- }
-
- public getCatalogHasMore(manifestId: string, type: string, id: string): boolean | undefined {
- const key = `${manifestId}|${type}|${id}`;
- return this.catalogHasMore.get(key);
- }
-
- async getMetaDetails(type: string, id: string, preferredAddonId?: string): Promise {
- try {
- // Validate content ID first
- const isValidId = await this.isValidContentId(type, id);
-
- if (!isValidId) {
- return null;
- }
-
- const addons = this.getInstalledAddons();
-
- // If a preferred addon is specified, try it first
- if (preferredAddonId) {
- const preferredAddon = addons.find(addon => addon.id === preferredAddonId);
-
- if (preferredAddon && preferredAddon.resources) {
- // Build URL for metadata request
- const { baseUrl, queryParams } = this.getAddonBaseURL(preferredAddon.url || '');
- const encodedId = encodeURIComponent(id);
- const url = queryParams ? `${baseUrl}/meta/${type}/${encodedId}.json?${queryParams}` : `${baseUrl}/meta/${type}/${encodedId}.json`;
-
- // Check if addon supports meta resource for this type
- let hasMetaSupport = false;
- let supportsIdPrefix = false;
-
- for (const resource of preferredAddon.resources) {
- // Check if the current element is a ResourceObject
- if (typeof resource === 'object' && resource !== null && 'name' in resource) {
- const typedResource = resource as ResourceObject;
- if (typedResource.name === 'meta' &&
- Array.isArray(typedResource.types) &&
- typedResource.types.includes(type)) {
- hasMetaSupport = true;
- // Check idPrefix support
- if (Array.isArray(typedResource.idPrefixes) && typedResource.idPrefixes.length > 0) {
- supportsIdPrefix = typedResource.idPrefixes.some(p => id.startsWith(p));
- } else {
- supportsIdPrefix = true;
- }
- break;
- }
- }
- // Check if the element is the simple string "meta" AND the addon has a top-level types array
- else if (typeof resource === 'string' && resource === 'meta' && preferredAddon.types) {
- if (Array.isArray(preferredAddon.types) && preferredAddon.types.includes(type)) {
- hasMetaSupport = true;
- // Check addon-level idPrefixes
- if (preferredAddon.idPrefixes && Array.isArray(preferredAddon.idPrefixes) && preferredAddon.idPrefixes.length > 0) {
- supportsIdPrefix = preferredAddon.idPrefixes.some(p => id.startsWith(p));
- } else {
- supportsIdPrefix = true;
- }
- break;
- }
- }
- }
-
-
- // Only require ID prefix compatibility if the addon has declared specific prefixes
- const requiresIdPrefix = preferredAddon.idPrefixes && preferredAddon.idPrefixes.length > 0;
- const isSupported = hasMetaSupport && (!requiresIdPrefix || supportsIdPrefix);
-
- if (isSupported) {
- try {
- const response = await this.retryRequest(async () => {
- return await axios.get(url, createSafeAxiosConfig(10000));
- });
-
-
- if (response.data && response.data.meta && response.data.meta.id) {
- return response.data.meta;
- } else {
- if (__DEV__) console.warn(`â ī¸ [getMetaDetails] Preferred addon ${preferredAddon.name} returned empty/invalid meta`);
- }
- } catch (error: any) {
- // Continue trying other addons
- }
- } else {
- }
- }
- }
-
- // Try Cinemeta with different base URLs
- const cinemetaUrls = [
- 'https://v3-cinemeta.strem.io',
- 'http://v3-cinemeta.strem.io'
- ];
-
-
- for (const baseUrl of cinemetaUrls) {
- try {
- const encodedId = encodeURIComponent(id);
- const url = `${baseUrl}/meta/${type}/${encodedId}.json`;
-
-
- const response = await this.retryRequest(async () => {
- return await axios.get(url, createSafeAxiosConfig(10000));
- });
-
-
- if (response.data && response.data.meta && response.data.meta.id) {
- return response.data.meta;
- } else {
- if (__DEV__) console.log(`[getMetaDetails] Cinemeta URL ${baseUrl} returned empty/invalid meta`);
- }
- } catch (error: any) {
- continue; // Try next URL
- }
- }
-
- // If Cinemeta fails, try other addons (excluding the preferred one already tried)
- for (const addon of addons) {
- if (!addon.resources || addon.id === 'com.linvo.cinemeta' || addon.id === preferredAddonId) continue;
-
- // Check if addon supports meta resource for this type AND idPrefix (handles both string and object formats)
- let hasMetaSupport = false;
- let supportsIdPrefix = false;
-
- for (const resource of addon.resources) {
- // Check if the current element is a ResourceObject
- if (typeof resource === 'object' && resource !== null && 'name' in resource) {
- const typedResource = resource as ResourceObject;
- if (typedResource.name === 'meta' &&
- Array.isArray(typedResource.types) &&
- typedResource.types.includes(type)) {
- hasMetaSupport = true;
- // Match idPrefixes if present; otherwise assume support
- if (Array.isArray(typedResource.idPrefixes) && typedResource.idPrefixes.length > 0) {
- supportsIdPrefix = typedResource.idPrefixes.some(p => id.startsWith(p));
- } else {
- supportsIdPrefix = true;
- }
- break;
- }
- }
- // Check if the element is the simple string "meta" AND the addon has a top-level types array
- else if (typeof resource === 'string' && resource === 'meta' && addon.types) {
- if (Array.isArray(addon.types) && addon.types.includes(type)) {
- hasMetaSupport = true;
- // For simple resources, check addon-level idPrefixes if present
- if (addon.idPrefixes && Array.isArray(addon.idPrefixes) && addon.idPrefixes.length > 0) {
- supportsIdPrefix = addon.idPrefixes.some(p => id.startsWith(p));
- } else {
- supportsIdPrefix = true;
- }
- break;
- }
- }
- }
-
- // Require meta support, but allow any ID if addon doesn't declare specific prefixes
-
- // Only require ID prefix compatibility if the addon has declared specific prefixes
- const requiresIdPrefix = addon.idPrefixes && addon.idPrefixes.length > 0;
- const isSupported = hasMetaSupport && (!requiresIdPrefix || supportsIdPrefix);
-
- if (!isSupported) {
- continue;
- }
-
- try {
- const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url || '');
- const encodedId = encodeURIComponent(id);
- const url = queryParams ? `${baseUrl}/meta/${type}/${encodedId}.json?${queryParams}` : `${baseUrl}/meta/${type}/${encodedId}.json`;
-
-
- const response = await this.retryRequest(async () => {
- return await axios.get(url, createSafeAxiosConfig(10000));
- });
-
-
- if (response.data && response.data.meta && response.data.meta.id) {
- return response.data.meta;
- } else {
- if (__DEV__) console.log(`[getMetaDetails] Addon ${addon.name} returned empty/invalid meta`);
- }
- } catch (error: any) {
- continue; // Try next addon
- }
- }
-
- return null;
- } catch (error) {
- logger.error('Error in getMetaDetails:', error);
- return null;
- }
- }
-
- /**
- * Memory-efficient method to fetch only upcoming episodes within a specific date range
- * This prevents over-fetching all episode data and reduces memory consumption
- */
- async getUpcomingEpisodes(
- type: string,
- id: string,
- options: {
- daysBack?: number;
- daysAhead?: number;
- maxEpisodes?: number;
- preferredAddonId?: string;
- } = {}
- ): Promise<{ seriesName: string; poster: string; episodes: any[] } | null> {
- const { daysBack = 14, daysAhead = 28, maxEpisodes = 50, preferredAddonId } = options;
-
- try {
- // Get metadata first (this is lightweight compared to episodes)
- const metadata = await this.getMetaDetails(type, id, preferredAddonId);
- if (!metadata) {
- return null;
- }
-
- // If no videos array exists, return basic info
- if (!metadata.videos || metadata.videos.length === 0) {
- return {
- seriesName: metadata.name,
- poster: metadata.poster || '',
- episodes: []
- };
- }
-
- const now = new Date();
- const startDate = new Date(now.getTime() - (daysBack * 24 * 60 * 60 * 1000));
- const endDate = new Date(now.getTime() + (daysAhead * 24 * 60 * 60 * 1000));
-
- // Filter episodes to only include those within our date range
- // This is done immediately after fetching to reduce memory footprint
-
- const filteredEpisodes = metadata.videos
- .filter(video => {
- if (!video.released) {
- logger.log(`[StremioService] Episode ${video.id} has no release date`);
- return false;
- }
- const releaseDate = new Date(video.released);
- const inRange = releaseDate >= startDate && releaseDate <= endDate;
- return inRange;
- })
- .sort((a, b) => new Date(a.released).getTime() - new Date(b.released).getTime())
- .slice(0, maxEpisodes); // Limit number of episodes to prevent memory overflow
-
-
- return {
- seriesName: metadata.name,
- poster: metadata.poster || '',
- episodes: filteredEpisodes
- };
- } catch (error) {
- logger.error(`[StremioService] Error fetching upcoming episodes for ${id}:`, error);
- return null;
- }
- }
-
- // Modify getStreams to use this.getInstalledAddons() instead of getEnabledAddons
- async getStreams(type: string, id: string, callback?: StreamCallback): Promise {
- await this.ensureInitialized();
-
- let activeId = id;
- let resolvedTmdbId: string | null = null;
-
- const addons = this.getInstalledAddons();
-
- // Some addons use non-standard meta types (e.g. "anime") but expect streams under the "series" endpoint.
- // We'll try the requested type first, then (if no addons match) fall back to "series".
- const pickStreamAddons = (requestType: string) =>
- addons.filter(addon => {
- if (!addon.resources || !Array.isArray(addon.resources)) {
- logger.log(`â ī¸ [getStreams] Addon ${addon.id} has no valid resources array`);
- return false;
- }
-
- let hasStreamResource = false;
- let supportsIdPrefix = false;
-
- for (const resource of addon.resources) {
- if (typeof resource === 'object' && resource !== null && 'name' in resource) {
- const typedResource = resource as ResourceObject;
- if (typedResource.name === 'stream' &&
- Array.isArray(typedResource.types) &&
- typedResource.types.includes(requestType)) {
- hasStreamResource = true;
-
- if (Array.isArray(typedResource.idPrefixes) && typedResource.idPrefixes.length > 0) {
- supportsIdPrefix = typedResource.idPrefixes.some(p => id.startsWith(p));
- } else {
- supportsIdPrefix = true;
- }
- break;
- }
- } else if (typeof resource === 'string' && resource === 'stream' && addon.types) {
- if (Array.isArray(addon.types) && addon.types.includes(requestType)) {
- hasStreamResource = true;
- if (addon.idPrefixes && Array.isArray(addon.idPrefixes) && addon.idPrefixes.length > 0) {
- supportsIdPrefix = addon.idPrefixes.some(p => id.startsWith(p));
- } else {
- supportsIdPrefix = true;
- }
- break;
- }
- }
- }
-
- return hasStreamResource && supportsIdPrefix;
- });
-
- // Check if local scrapers are enabled and execute them first
- try {
- // Load settings from AsyncStorage directly (scoped with fallback)
- const scope = (await mmkvStorage.getItem('@user:current')) || 'local';
- const settingsJson = (await mmkvStorage.getItem(`@user:${scope}:app_settings`))
- || (await mmkvStorage.getItem('app_settings'));
- const rawSettings = settingsJson ? JSON.parse(settingsJson) : {};
- const settings: AppSettings = { ...DEFAULT_SETTINGS, ...rawSettings };
-
- if (settings.enableLocalScrapers) {
- const hasScrapers = await localScraperService.hasScrapers();
- if (hasScrapers) {
- logger.log('đ§ [getStreams] Executing local scrapers for', type, id);
-
- // Map Stremio types to local scraper types
- const scraperType = type === 'series' ? 'tv' : type;
-
- // Parse the Stremio ID to extract ID and season/episode info
- let tmdbId: string | null = resolvedTmdbId;
- let season: number | undefined = undefined;
- let episode: number | undefined = undefined;
- let idType: 'imdb' | 'kitsu' | 'tmdb' = 'imdb';
-
- try {
- const idParts = id.split(':');
- let baseId: string;
-
- // Handle different episode ID formats
- if (idParts[0] === 'series') {
- // Format: series:imdbId:season:episode or series:kitsu:7442:season:episode
- baseId = idParts[1];
- if (scraperType === 'tv' && idParts.length >= 4) {
- season = parseInt(idParts[2], 10);
- episode = parseInt(idParts[3], 10);
- }
- // Check if it's a kitsu ID
- if (idParts[1] === 'kitsu') {
- idType = 'kitsu';
- baseId = idParts[2]; // kitsu:7442:season:episode -> baseId = 7442
- if (scraperType === 'tv' && idParts.length >= 5) {
- season = parseInt(idParts[3], 10);
- episode = parseInt(idParts[4], 10);
- }
- }
- } else if (idParts[0].startsWith('tt')) {
- // Format: imdbId:season:episode (direct IMDb ID)
- baseId = idParts[0];
- idType = 'imdb';
- if (scraperType === 'tv' && idParts.length >= 3) {
- season = parseInt(idParts[1], 10);
- episode = parseInt(idParts[2], 10);
- }
- } else if (idParts[0] === 'kitsu') {
- // Format: kitsu:7442:season:episode (direct Kitsu ID)
- baseId = idParts[1];
- idType = 'kitsu';
- if (scraperType === 'tv' && idParts.length >= 4) {
- season = parseInt(idParts[2], 10);
- episode = parseInt(idParts[3], 10);
- }
- } else if (idParts[0] === 'tmdb') {
- // Format: tmdb:286801:season:episode (direct TMDB ID)
- baseId = idParts[1];
- idType = 'tmdb';
- if (scraperType === 'tv' && idParts.length >= 4) {
- season = parseInt(idParts[2], 10);
- episode = parseInt(idParts[3], 10);
- }
- } else {
- // Fallback: assume first part is the ID
- baseId = idParts[0];
- if (scraperType === 'tv' && idParts.length >= 3) {
- season = parseInt(idParts[1], 10);
- episode = parseInt(idParts[2], 10);
- }
- }
-
- // Handle ID conversion for local scrapers (they need TMDB ID)
- if (idType === 'imdb') {
- // Convert IMDb ID to TMDB ID
- const tmdbService = TMDBService.getInstance();
- const tmdbIdNumber = await tmdbService.findTMDBIdByIMDB(baseId);
- if (tmdbIdNumber) {
- tmdbId = tmdbIdNumber.toString();
- } else {
- logger.log('đ§ [getStreams] Skipping local scrapers: could not convert IMDb to TMDB for', baseId);
- }
- } else if (idType === 'tmdb') {
- // Already have TMDB ID, use it directly
- tmdbId = baseId;
- logger.log('đ§ [getStreams] Using TMDB ID directly for local scrapers:', tmdbId);
- } else if (idType === 'kitsu') {
- // For kitsu IDs, skip local scrapers as they don't support kitsu
- logger.log('đ§ [getStreams] Skipping local scrapers for kitsu ID:', baseId);
- } else {
- // For other ID types, try to use as TMDB ID
- tmdbId = baseId;
- logger.log('đ§ [getStreams] Using base ID as TMDB ID for local scrapers:', tmdbId);
- }
- } catch (error) {
- logger.warn('đ§ [getStreams] Skipping local scrapers due to ID parsing error:', error);
- }
-
- // Execute local scrapers asynchronously with TMDB ID (when available)
- if (tmdbId) {
- localScraperService.getStreams(scraperType, tmdbId, season, episode, (streams, scraperId, scraperName, error) => {
- // Always call callback to ensure UI updates, regardless of result
- if (callback) {
- if (error) {
- callback(null, scraperId, scraperName, error);
- } else if (streams && streams.length > 0) {
- callback(streams, scraperId, scraperName, null);
- } else {
- // Handle case where scraper completed successfully but returned no streams
- // This ensures the scraper is removed from "fetching" state in UI
- callback([], scraperId, scraperName, null);
- }
- }
- });
- } else {
- logger.log('đ§ [getStreams] Local scrapers not executed - no TMDB ID available');
- // Notify UI that local scrapers won't execute by calling their callbacks
- try {
- const installedScrapers = await localScraperService.getInstalledScrapers();
- const enabledScrapers = installedScrapers.filter(s => s.enabled);
- enabledScrapers.forEach(scraper => {
- if (callback) {
- callback([], scraper.id, scraper.name, null);
- }
- });
- } catch (error) {
- logger.warn('đ§ [getStreams] Failed to notify UI about skipped local scrapers:', error);
- }
- }
- }
- }
- } catch (error) {
- // Continue even if local scrapers fail
- }
-
- // Check specifically for TMDB Embed addon
- const tmdbEmbed = addons.find(addon => addon.id === 'org.tmdbembedapi');
- if (!tmdbEmbed) {
- // TMDB Embed addon not found
- }
-
- let effectiveType = type;
- let streamAddons = pickStreamAddons(type);
-
- logger.log(`đ§ [getStreams] Resolving stream addons for type='${type}' id='${id}' (matched=${streamAddons.length})`);
-
- if (streamAddons.length === 0) {
- const fallbackTypes = ['series', 'movie', 'tv', 'channel'].filter(t => t !== type);
- for (const fallbackType of fallbackTypes) {
- const fallbackAddons = pickStreamAddons(fallbackType);
- if (fallbackAddons.length > 0) {
- effectiveType = fallbackType;
- streamAddons = fallbackAddons;
- logger.log(`đ [getStreams] No stream addons for type '${type}', falling back to '${effectiveType}' for id '${id}'`);
- break;
- }
- }
- }
-
- if (effectiveType !== type) {
- logger.log(`đ§ [getStreams] Using effectiveType='${effectiveType}' (requested='${type}') for id='${id}'`);
- }
-
- if (streamAddons.length === 0) {
- logger.warn('â ī¸ [getStreams] No addons found that can provide streams');
-
- // Log what the URL would have been for debugging
- const encodedId = encodeURIComponent(id);
- const exampleUrl = `/stream/${effectiveType}/${encodedId}.json`;
- logger.log(`đĢ [getStreams] No stream addons matched. Would have requested: ${exampleUrl}`);
- logger.log(`đĢ [getStreams] Details: requestedType='${type}' effectiveType='${effectiveType}' id='${id}'`);
-
- // Show which addons have stream capability but didn't match
- const streamCapableAddons = addons.filter(addon => {
- if (!addon.resources || !Array.isArray(addon.resources)) return false;
- return addon.resources.some(resource => {
- if (typeof resource === 'object' && resource !== null && 'name' in resource) {
- return (resource as ResourceObject).name === 'stream';
- }
- return typeof resource === 'string' && resource === 'stream';
- });
- });
-
- if (streamCapableAddons.length > 0) {
- logger.log(`đĢ [getStreams] Found ${streamCapableAddons.length} stream-capable addon(s) that didn't match:`);
-
- for (const addon of streamCapableAddons) {
- const streamResources = addon.resources!.filter(resource => {
- if (typeof resource === 'object' && resource !== null && 'name' in resource) {
- return (resource as ResourceObject).name === 'stream';
- }
- return typeof resource === 'string' && resource === 'stream';
- });
-
- for (const resource of streamResources) {
- if (typeof resource === 'object' && resource !== null) {
- const typedResource = resource as ResourceObject;
- const types = typedResource.types || [];
- const prefixes = typedResource.idPrefixes || [];
- const typeMatch = types.includes(effectiveType);
- const prefixMatch = prefixes.length === 0 || prefixes.some(p => id.startsWith(p));
-
- if (addon.url) {
- const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url);
- const wouldBeUrl = queryParams
- ? `${baseUrl}/stream/${effectiveType}/${encodedId}.json?${queryParams}`
- : `${baseUrl}/stream/${effectiveType}/${encodedId}.json`;
-
- console.log(
- ` â ${addon.name} (${addon.id}):\n` +
- ` types=[${types.join(',')}] typeMatch=${typeMatch}\n` +
- ` prefixes=[${prefixes.join(',')}] prefixMatch=${prefixMatch}\n` +
- ` url=${wouldBeUrl}`
- );
- } else {
- console.log(` â ${addon.name} (${addon.id}): no URL configured`);
- }
- } else if (typeof resource === 'string' && resource === 'stream') {
- // String resource - check addon-level types and prefixes
- const addonTypes = addon.types || [];
- const addonPrefixes = addon.idPrefixes || [];
- const typeMatch = addonTypes.includes(effectiveType);
- const prefixMatch = addonPrefixes.length === 0 || addonPrefixes.some(p => id.startsWith(p));
-
- if (addon.url) {
- const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url);
- const wouldBeUrl = queryParams
- ? `${baseUrl}/stream/${effectiveType}/${encodedId}.json?${queryParams}`
- : `${baseUrl}/stream/${effectiveType}/${encodedId}.json`;
-
- console.log(
- ` â ${addon.name} (${addon.id}) [addon-level]:\n` +
- ` types=[${addonTypes.join(',')}] typeMatch=${typeMatch}\n` +
- ` prefixes=[${addonPrefixes.join(',')}] prefixMatch=${prefixMatch}\n` +
- ` url=${wouldBeUrl}`
- );
- }
- }
- }
- }
- } else {
- logger.log(`đĢ [getStreams] No stream-capable addons installed`);
- }
-
- return;
- }
-
- // Process each addon and call the callback individually
- streamAddons.forEach(addon => {
- // Use an IIFE to create scope for async operation inside forEach
- (async () => {
- try {
- if (!addon.url) {
- logger.warn(`â ī¸ [getStreams] Addon ${addon.id} has no URL`);
- if (callback) callback(null, addon.id, addon.name, new Error('Addon has no URL'), addon.installationId);
- return;
- }
-
- const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url);
- const encodedId = encodeURIComponent(activeId);
- const url = queryParams ? `${baseUrl}/stream/${effectiveType}/${encodedId}.json?${queryParams}` : `${baseUrl}/stream/${effectiveType}/${encodedId}.json`;
-
- logger.log(
- `đ [getStreams] GET ${url} (addon='${addon.name}' id='${addon.id}' install='${addon.installationId}' requestedType='${type}' effectiveType='${effectiveType}' rawId='${id}')`
- );
-
- const response = await this.retryRequest(async () => {
- return await axios.get(url, safeAxiosConfig);
- });
-
- let processedStreams: Stream[] = [];
- if (response.data && response.data.streams) {
- logger.log(`â
[getStreams] Got ${response.data.streams.length} streams from ${addon.name} (${addon.id}) [${addon.installationId}]`);
- processedStreams = this.processStreams(response.data.streams, addon);
- logger.log(`â
[getStreams] Processed ${processedStreams.length} valid streams from ${addon.name} (${addon.id}) [${addon.installationId}]`);
- } else {
- logger.log(`â ī¸ [getStreams] No streams found in response from ${addon.name} (${addon.id}) [${addon.installationId}]`);
- }
-
- if (callback) {
- // Call callback with processed streams (can be empty array), include installationId
- callback(processedStreams, addon.id, addon.name, null, addon.installationId);
- }
- } catch (error) {
- if (callback) {
- // Call callback with error, include installationId
- callback(null, addon.id, addon.name, error as Error, addon.installationId);
- }
- }
- })(); // Immediately invoke the async function
- });
-
- // No longer waiting here, callbacks handle results asynchronously
- // Removed: await Promise.all(addonPromises.values());
- // No longer returning aggregated results
- // Removed: return streamResponses;
- }
-
- private async fetchStreamsFromAddon(addon: Manifest, type: string, id: string): Promise {
- if (!addon.url) {
- logger.warn(`Addon ${addon.id} has no URL defined`);
- return null;
- }
-
- const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url);
- const encodedId = encodeURIComponent(id);
- const streamPath = `/stream/${type}/${encodedId}.json`;
- const url = queryParams ? `${baseUrl}${streamPath}?${queryParams}` : `${baseUrl}${streamPath}`;
-
- logger.log(
- `đ [fetchStreamsFromAddon] GET ${url} (addon='${addon.name}' id='${addon.id}' install='${addon.installationId}' type='${type}' rawId='${id}')`
- );
-
- try {
- // Increase timeout for debrid services
- const timeout = addon.id.toLowerCase().includes('torrentio') ? 60000 : 10000;
-
- const response = await this.retryRequest(async () => {
- logger.log(`đ [fetchStreamsFromAddon] Requesting ${url} (timeout=${timeout}ms)`);
- return await axios.get(url, createSafeAxiosConfig(timeout, {
- headers: {
- 'Accept': 'application/json',
- 'User-Agent': 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Mobile Safari/537.36'
- }
- }));
- }, 5); // Increase retries for stream fetching
-
- if (response.data && response.data.streams && Array.isArray(response.data.streams)) {
- const streams = this.processStreams(response.data.streams, addon);
- logger.log(`Successfully processed ${streams.length} streams from ${addon.id}`);
-
- return {
- streams,
- addon: addon.id,
- addonName: addon.name
- };
- } else {
- logger.warn(`Invalid response format from ${addon.id}:`, response.data);
- }
- } catch (error: any) {
- const errorDetails = {
- addonId: addon.id,
- addonName: addon.name,
- url,
- message: error.message,
- code: error.code,
- isAxiosError: error.isAxiosError,
- status: error.response?.status,
- responseData: error.response?.data
- };
- // Re-throw the error with more context
- throw new Error(`Failed to fetch streams from ${addon.name}: ${error.message}`);
- }
-
- return null;
- }
-
- private isDirectStreamingUrl(url?: string): boolean {
- if (typeof url !== 'string') return false;
- return url.startsWith('http://') || url.startsWith('https://');
- }
-
- private getStreamUrl(stream: any): string {
- // Prefer plain string URLs; guard against objects or unexpected types
- if (typeof stream?.url === 'string') {
- return stream.url;
- }
- // Some addons might nest the URL inside an object; try common shape
- if (stream?.url && typeof stream.url === 'object' && typeof stream.url.url === 'string') {
- return stream.url.url;
- }
-
- // Handle YouTube video ID per protocol
- if (stream.ytId) {
- return `https://www.youtube.com/watch?v=${stream.ytId}`;
- }
-
- if (stream.infoHash) {
- const trackers = [
- 'udp://tracker.opentrackr.org:1337/announce',
- 'udp://9.rarbg.com:2810/announce',
- 'udp://tracker.openbittorrent.com:6969/announce',
- 'udp://tracker.torrent.eu.org:451/announce',
- 'udp://open.stealth.si:80/announce',
- 'udp://tracker.leechers-paradise.org:6969/announce',
- 'udp://tracker.coppersurfer.tk:6969/announce',
- 'udp://tracker.internetwarriors.net:1337/announce'
- ];
- // Add sources from stream if available per protocol
- const additionalTrackers = (stream.sources || [])
- .filter((s: string) => s.startsWith('tracker:'))
- .map((s: string) => s.replace('tracker:', ''));
- const allTrackers = [...trackers, ...additionalTrackers];
- const trackersString = allTrackers.map(t => `&tr=${encodeURIComponent(t)}`).join('');
- const encodedTitle = encodeURIComponent(stream.title || stream.name || 'Unknown');
- return `magnet:?xt=urn:btih:${stream.infoHash}&dn=${encodedTitle}${trackersString}`;
- }
-
- return '';
- }
-
- private processStreams(streams: any[], addon: Manifest): Stream[] {
- return streams
- .filter(stream => {
- // Basic filtering - ensure there's a way to play per protocol
- // One of: url, ytId, infoHash, externalUrl, nzbUrl, or archive arrays
- const hasPlayableLink = !!(
- stream.url ||
- stream.infoHash ||
- stream.ytId ||
- stream.externalUrl ||
- stream.nzbUrl ||
- (stream.rarUrls && stream.rarUrls.length > 0) ||
- (stream.zipUrls && stream.zipUrls.length > 0) ||
- (stream['7zipUrls'] && stream['7zipUrls'].length > 0) ||
- (stream.tgzUrls && stream.tgzUrls.length > 0) ||
- (stream.tarUrls && stream.tarUrls.length > 0)
- );
- const hasIdentifier = !!(stream.title || stream.name);
- return stream && hasPlayableLink && hasIdentifier;
- })
- .map(stream => {
- const streamUrl = this.getStreamUrl(stream);
- const isDirectStreamingUrl = this.isDirectStreamingUrl(streamUrl);
- const isMagnetStream = streamUrl?.startsWith('magnet:');
- const isExternalUrl = !!stream.externalUrl;
- const isYouTube = !!stream.ytId;
-
- // Prefer full, untruncated text to preserve complete addon details
- let displayTitle = stream.title || stream.name || 'Unnamed Stream';
- if (stream.description && stream.description.includes('\n') && stream.description.length > (stream.title?.length || 0)) {
- // If description exists and is likely the formatted metadata, prefer it as-is
- displayTitle = stream.description;
- }
-
- // Use full name for primary identifier if available
- let name = stream.name || stream.title || 'Unnamed Stream';
-
- // Extract size: Prefer behaviorHints.videoSize, fallback to top-level size
- const sizeInBytes = stream.behaviorHints?.videoSize || stream.size || undefined;
-
- // Preserve complete behaviorHints per protocol
- const behaviorHints: Stream['behaviorHints'] = {
- notWebReady: !isDirectStreamingUrl || isExternalUrl,
- cached: stream.behaviorHints?.cached || undefined,
- bingeGroup: stream.behaviorHints?.bingeGroup || undefined,
- // Per protocol: Country whitelist for geo-restrictions
- countryWhitelist: stream.behaviorHints?.countryWhitelist || undefined,
- // Per protocol: Proxy headers for custom stream headers
- proxyHeaders: stream.behaviorHints?.proxyHeaders || undefined,
- // Per protocol: Video metadata for subtitle matching
- videoHash: stream.behaviorHints?.videoHash || undefined,
- videoSize: stream.behaviorHints?.videoSize || undefined,
- filename: stream.behaviorHints?.filename || undefined,
- // Include essential torrent data for magnet streams
- ...(isMagnetStream ? {
- infoHash: stream.infoHash || streamUrl?.match(/btih:([a-zA-Z0-9]+)/)?.[1],
- fileIdx: stream.fileIdx,
- type: 'torrent',
- } : {}),
- };
-
- // Explicitly construct the final Stream object with all protocol fields
- const processedStream: Stream = {
- // Primary URL (may be empty for ytId/externalUrl streams)
- url: streamUrl || undefined,
- name: name,
- title: displayTitle,
- addonName: addon.name,
- addonId: addon.id,
-
- // Include description as-is to preserve full details
- description: stream.description,
-
- // Alternative source types per protocol
- ytId: stream.ytId || undefined,
- externalUrl: stream.externalUrl || undefined,
- nzbUrl: stream.nzbUrl || undefined,
- rarUrls: stream.rarUrls || undefined,
- zipUrls: stream.zipUrls || undefined,
- '7zipUrls': stream['7zipUrls'] || undefined,
- tgzUrls: stream.tgzUrls || undefined,
- tarUrls: stream.tarUrls || undefined,
- servers: stream.servers || undefined,
-
- // Torrent/archive file selection
- infoHash: stream.infoHash || undefined,
- fileIdx: stream.fileIdx,
- fileMustInclude: stream.fileMustInclude || undefined,
-
- // Stream metadata
- size: sizeInBytes,
- isFree: stream.isFree,
- isDebrid: !!(stream.behaviorHints?.cached),
-
- // Embedded subtitles per protocol
- subtitles: stream.subtitles?.map((sub: any, index: number) => ({
- id: sub.id || `${addon.id}-${sub.lang || 'unknown'}-${index}`,
- ...sub,
- })) || undefined,
-
- // Additional tracker/DHT sources per protocol
- sources: stream.sources || undefined,
-
- // Complete behavior hints
- behaviorHints: behaviorHints,
- };
-
- return processedStream;
- });
- }
-
- getAddonCapabilities(): AddonCapabilities[] {
- return this.getInstalledAddons().map(addon => {
- return {
- name: addon.name,
- id: addon.id,
- version: addon.version,
- catalogs: addon.catalogs || [],
- resources: addon.resources || [],
- types: addon.types || [],
- };
- });
- }
-
- async getCatalogPreview(addonId: string, type: string, id: string, limit: number = 5): Promise<{
- addon: string;
- type: string;
- id: string;
- items: Meta[];
- }> {
- const addon = this.getInstalledAddons().find(a => a.id === addonId);
-
- if (!addon) {
- throw new Error(`Addon ${addonId} not found`);
- }
-
- const items = await this.getCatalog(addon, type, id);
- return {
- addon: addonId,
- type,
- id,
- items: items.slice(0, limit)
- };
- }
-
- async getSubtitles(type: string, id: string, videoId?: string): Promise {
- await this.ensureInitialized();
- // Collect from all installed addons that expose a subtitles resource
- const addons = this.getInstalledAddons();
-
- // The ID to check for prefix matching - use videoId for series (e.g., tt1234567:1:1), otherwise use id
- const idForChecking = type === 'series' && videoId
- ? videoId.replace('series:', '')
- : id;
-
- const subtitleAddons = addons.filter(addon => {
- if (!addon.resources) return false;
-
- // Check if addon has subtitles resource
- const subtitlesResource = addon.resources.find((resource: any) => {
- if (typeof resource === 'string') return resource === 'subtitles';
- return resource && resource.name === 'subtitles';
- });
-
- if (!subtitlesResource) return false;
-
- // Check type support - either from the resource object or addon-level types
- let supportsType = true;
- if (typeof subtitlesResource === 'object' && subtitlesResource.types) {
- supportsType = subtitlesResource.types.includes(type);
- } else if (addon.types) {
- supportsType = addon.types.includes(type);
- }
-
- if (!supportsType) {
- logger.log(`[getSubtitles] Addon ${addon.name} does not support type ${type}`);
- return false;
- }
-
- // Check idPrefixes - either from the resource object or addon-level
- let supportsIdPrefix = true;
- let idPrefixes: string[] | undefined;
-
- if (typeof subtitlesResource === 'object' && subtitlesResource.idPrefixes) {
- idPrefixes = subtitlesResource.idPrefixes;
- } else if (addon.idPrefixes) {
- idPrefixes = addon.idPrefixes;
- }
-
- if (idPrefixes && idPrefixes.length > 0) {
- supportsIdPrefix = idPrefixes.some(prefix => idForChecking.startsWith(prefix));
- }
-
- if (!supportsIdPrefix) {
- logger.log(`[getSubtitles] Addon ${addon.name} does not support ID prefix for ${idForChecking} (requires: ${idPrefixes?.join(', ')})`);
- return false;
- }
-
- return true;
- });
-
- if (subtitleAddons.length === 0) {
- logger.warn('No subtitle-capable addons installed that support the requested type/id');
- return [];
- }
-
- logger.log(`[getSubtitles] Found ${subtitleAddons.length} subtitle addons for ${type}/${id}: ${subtitleAddons.map(a => a.name).join(', ')}`);
-
- const requests = subtitleAddons.map(async (addon) => {
- if (!addon.url) return [] as Subtitle[];
- try {
- const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url || '');
- let url = '';
- if (type === 'series' && videoId) {
- const episodeInfo = encodeURIComponent(videoId.replace('series:', ''));
- url = queryParams
- ? `${baseUrl}/subtitles/series/${episodeInfo}.json?${queryParams}`
- : `${baseUrl}/subtitles/series/${episodeInfo}.json`;
- } else {
- const encodedId = encodeURIComponent(id);
- url = queryParams
- ? `${baseUrl}/subtitles/${type}/${encodedId}.json?${queryParams}`
- : `${baseUrl}/subtitles/${type}/${encodedId}.json`;
- }
- logger.log(`[getSubtitles] Fetching subtitles from ${addon.name}: ${url}`);
- const response = await this.retryRequest(async () => axios.get(url, createSafeAxiosConfig(10000)));
- if (response.data && Array.isArray(response.data.subtitles)) {
- logger.log(`[getSubtitles] Got ${response.data.subtitles.length} subtitles from ${addon.name}`);
- return response.data.subtitles.map((sub: any, index: number) => ({
- // Ensure ID is always present per protocol (required field)
- id: sub.id || `${addon.id}-${sub.lang || 'unknown'}-${index}`,
- ...sub,
- addon: addon.id,
- addonName: addon.name,
- })) as Subtitle[];
- } else {
- logger.log(`[getSubtitles] No subtitles array in response from ${addon.name}`);
- }
- } catch (error: any) {
- logger.error(`[getSubtitles] Failed to fetch subtitles from ${addon.name}:`, error?.message || error);
- }
- return [] as Subtitle[];
- });
-
- const all = await Promise.all(requests);
- // Flatten and de-duplicate by URL
- const merged = ([] as Subtitle[]).concat(...all);
- const seen = new Set();
- const deduped = merged.filter(s => {
- const key = s.url;
- if (!key) return false;
- if (seen.has(key)) return false;
- seen.add(key);
- return true;
- });
- logger.log(`[getSubtitles] Total: ${deduped.length} unique subtitles from all addons`);
- return deduped;
- }
-
- // Add methods to move addons in the order (using installationIds)
- moveAddonUp(installationId: string): boolean {
- const index = this.addonOrder.indexOf(installationId);
- if (index > 0) {
- // Swap with the previous item
- [this.addonOrder[index - 1], this.addonOrder[index]] =
- [this.addonOrder[index], this.addonOrder[index - 1]];
- this.saveAddonOrder();
- // Emit an event that the order has changed
- addonEmitter.emit(ADDON_EVENTS.ORDER_CHANGED);
- return true;
- }
- return false;
- }
-
- moveAddonDown(installationId: string): boolean {
- const index = this.addonOrder.indexOf(installationId);
- if (index >= 0 && index < this.addonOrder.length - 1) {
- // Swap with the next item
- [this.addonOrder[index], this.addonOrder[index + 1]] =
- [this.addonOrder[index + 1], this.addonOrder[index]];
- this.saveAddonOrder();
- // Emit an event that the order has changed
- addonEmitter.emit(ADDON_EVENTS.ORDER_CHANGED);
- return true;
- }
- return false;
- }
-
- // Reconcile local addon order to match a remote ordered list of addon manifest URLs.
- // Any local addons not present in the remote list are appended in their current order.
- async applyAddonOrderFromManifestUrls(manifestUrls: string[]): Promise {
- await this.ensureInitialized();
- if (!Array.isArray(manifestUrls) || manifestUrls.length === 0) return false;
-
- const normalizeManifestUrl = (raw: string): string => {
- const value = (raw || '').trim();
- if (!value) return '';
- const withManifest = value.includes('manifest.json')
- ? value
- : `${value.replace(/\/$/, '')}/manifest.json`;
- return withManifest.toLowerCase();
- };
-
- const localByNormalizedUrl = new Map();
- for (const installationId of this.addonOrder) {
- const addon = this.installedAddons.get(installationId);
- if (!addon) continue;
- const normalized = normalizeManifestUrl(addon.originalUrl || addon.url || '');
- if (!normalized) continue;
- const list = localByNormalizedUrl.get(normalized) || [];
- list.push(installationId);
- localByNormalizedUrl.set(normalized, list);
- }
-
- const nextOrder: string[] = [];
- const seenInstallations = new Set();
-
- for (const remoteUrl of manifestUrls) {
- const normalizedRemote = normalizeManifestUrl(remoteUrl);
- if (!normalizedRemote) continue;
- const candidates = localByNormalizedUrl.get(normalizedRemote);
- if (!candidates || candidates.length === 0) continue;
- const installationId = candidates.shift();
- if (!installationId || seenInstallations.has(installationId)) continue;
- nextOrder.push(installationId);
- seenInstallations.add(installationId);
- }
-
- for (const installationId of this.addonOrder) {
- if (!this.installedAddons.has(installationId)) continue;
- if (seenInstallations.has(installationId)) continue;
- nextOrder.push(installationId);
- seenInstallations.add(installationId);
- }
-
- const changed =
- nextOrder.length !== this.addonOrder.length ||
- nextOrder.some((id, index) => id !== this.addonOrder[index]);
-
- if (!changed) return false;
-
- this.addonOrder = nextOrder;
- await this.saveAddonOrder();
- addonEmitter.emit(ADDON_EVENTS.ORDER_CHANGED);
- return true;
- }
-
- // Check if any installed addons can provide streams (including embedded streams in metadata)
- async hasStreamProviders(type?: string): Promise {
- await this.ensureInitialized();
- const addons = Array.from(this.installedAddons.values());
-
- for (const addon of addons) {
- if (addon.resources && Array.isArray(addon.resources)) {
- // Check for explicit 'stream' resource
- const hasStreamResource = addon.resources.some(resource =>
- typeof resource === 'string'
- ? resource === 'stream'
- : (resource as any).name === 'stream'
- );
-
- if (hasStreamResource) {
- // If type specified, also check if addon supports this type
- if (type) {
- const supportsType = addon.types?.includes(type) ||
- addon.resources.some(resource =>
- typeof resource === 'object' &&
- (resource as any).name === 'stream' &&
- (resource as any).types?.includes(type)
- );
- if (supportsType) return true;
- } else {
- return true;
- }
- }
-
- // Also check for addons with meta resource that support the type
- // These addons might provide embedded streams within metadata
- if (type) {
- const hasMetaResource = addon.resources.some(resource =>
- typeof resource === 'string'
- ? resource === 'meta'
- : (resource as any).name === 'meta'
- );
-
- if (hasMetaResource && addon.types?.includes(type)) {
- // This addon provides meta for the type - might have embedded streams
- return true;
- }
- }
- }
- }
-
- return false;
- }
-
- /**
- * Fetch addon catalogs from addons that provide the addon_catalog resource per protocol.
- * Returns a list of other addon manifests that can be installed.
- */
- async getAddonCatalogs(type: string, id: string): Promise {
- await this.ensureInitialized();
-
- // Find addons that provide addon_catalog resource
- const addons = this.getInstalledAddons().filter(addon => {
- if (!addon.resources) return false;
- return addon.resources.some(r =>
- typeof r === 'string' ? r === 'addon_catalog' : (r as any).name === 'addon_catalog'
- );
- });
-
- if (addons.length === 0) {
- logger.log('[getAddonCatalogs] No addons provide addon_catalog resource');
- return [];
- }
-
- const results: AddonCatalogItem[] = [];
-
- for (const addon of addons) {
- try {
- const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url || '');
- const url = `${baseUrl}/addon_catalog/${type}/${encodeURIComponent(id)}.json${queryParams ? `?${queryParams}` : ''}`;
-
- logger.log(`[getAddonCatalogs] Fetching from ${addon.name}: ${url}`);
- const response = await this.retryRequest(() => axios.get(url, createSafeAxiosConfig(10000)));
-
- if (response.data?.addons && Array.isArray(response.data.addons)) {
- results.push(...response.data.addons);
- }
- } catch (error) {
- logger.warn(`[getAddonCatalogs] Failed to fetch from ${addon.name}:`, error);
- }
- }
-
- return results;
- }
-
-}
-
-// Addon catalog item per protocol
-export interface AddonCatalogItem {
- transportName: string; // 'http'
- transportUrl: string; // URL to manifest.json
- manifest: Manifest;
-}
-
-export const stremioService = StremioService.getInstance();
-export default stremioService;
+export { ADDON_EVENTS, addonEmitter } from './stremio/events';
+export {
+ StremioService,
+ default,
+ stremioService,
+} from './stremio/StremioService';
+export type {
+ AddonCapabilities,
+ AddonCatalogItem,
+ CatalogExtra,
+ Manifest,
+ Meta,
+ MetaDetails,
+ MetaLink,
+ SourceObject,
+ Stream,
+ StreamResponse,
+ Subtitle,
+ SubtitleResponse,
+} from './stremio/StremioService';
diff --git a/src/services/telemetryService.ts b/src/services/telemetryService.ts
index d29844bf..20ac59d7 100644
--- a/src/services/telemetryService.ts
+++ b/src/services/telemetryService.ts
@@ -148,7 +148,6 @@ class TelemetryService {
}
this.initialized = true;
- console.log('[TelemetryService] Initialized with settings:', this.settings);
} catch (error) {
console.error('[TelemetryService] Error initializing:', error);
// Use defaults on error
diff --git a/src/utils/logger.ts b/src/utils/logger.ts
index f5952ede..445592f9 100644
--- a/src/utils/logger.ts
+++ b/src/utils/logger.ts
@@ -3,7 +3,7 @@ class Logger {
constructor() {
// __DEV__ is a global variable in React Native
- this.isEnabled = __DEV__;
+ this.isEnabled = false;
}
log(...args: any[]) {
@@ -37,4 +37,4 @@ class Logger {
}
}
-export const logger = new Logger();
\ No newline at end of file
+export const logger = new Logger();