Merge branch 'NuvioMedia:main' into localization-patch

This commit is contained in:
albyalex96 2026-03-17 22:09:19 +01:00 committed by GitHub
commit 40141bca60
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 4410 additions and 4244 deletions

View file

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

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21.4086 9.35258C23.5305 10.5065 23.5305 13.4935 21.4086 14.6474L8.59662 21.6145C6.53435 22.736 4 21.2763 4 18.9671L4 5.0329C4 2.72368 6.53435 1.26402 8.59661 2.38548L21.4086 9.35258Z" fill="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 359 B

View file

@ -76,9 +76,7 @@ const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
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);
}}

View file

@ -76,9 +76,7 @@ const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
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);
}}

View file

@ -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<AppleTVHeroProps> = ({
onPress={handlePlayAction}
activeOpacity={0.85}
>
<MaterialIcons
name={shouldResume ? "replay" : "play-arrow"}
size={24}
color="#000"
/>
{shouldResume ? (
<MaterialIcons
name="replay"
size={24}
color="#000"
/>
) : (
<PlayerPlayIconBlack
width={24}
height={24}
style={{ transform: [{ scale: 0.85 }] }}
/>
)}
<Text style={styles.playButtonText}>{shouldResume ? t('home.resume') : t('home.play')}</Text>
</TouchableOpacity>
@ -1330,11 +1339,21 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
onPress={handleSaveAction}
activeOpacity={0.85}
>
<MaterialIcons
name={inLibrary ? "bookmark" : "bookmark-outline"}
size={24}
color="white"
/>
{Platform.OS === 'ios' ? (
<ExpoBlurView intensity={35} tint="light" style={styles.saveButtonBlur}>
<MaterialIcons
name={inLibrary ? "bookmark" : "bookmark-outline"}
size={24}
color="white"
/>
</ExpoBlurView>
) : (
<MaterialIcons
name={inLibrary ? "bookmark" : "bookmark-outline"}
size={24}
color="white"
/>
)}
</TouchableOpacity>
</View>
@ -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',

View file

@ -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}
>
<MaterialIcons
name={(() => {
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' ? (
<MaterialIcons
name="replay"
size={isTablet ? 28 : 24}
color="#fff"
/>
) : (
<PlayerPlayIconBlack
width={isTablet ? 28 : 24}
height={isTablet ? 28 : 24}
style={{ transform: [{ scale: isTablet ? 0.85 : 0.79 }] }}
/>
)}
<Text style={[playButtonTextStyle, isTablet && styles.tabletPlayButtonText]}>{finalPlayButtonText}</Text>
</TouchableOpacity>

View file

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

View file

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

View file

@ -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<SeriesContentProps> = ({
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<SeriesContentProps> = ({
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<SeriesContentProps> = ({
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<SeriesContentProps> = ({
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}
>

View file

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

View file

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

View file

@ -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<MDBListRatings | null>(null);

View file

@ -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<StreamingContent | null>(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]);

View file

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

View file

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

View file

@ -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 <T,>(promise: Promise<T>, timeoutMs: number): Promise<T> => {
return new Promise<T>((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<string, boolean> | 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<Record<number, boolean>>({});
const [hasAddons, setHasAddons] = useState<boolean | null>(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));

View file

@ -1782,11 +1782,8 @@ const LibraryScreen = () => {
setFilter(filterType);
}}
activeOpacity={0.7}
>
{iconName && (
<MaterialIcons name={iconName} size={20} color={isActive ? currentTheme.colors.white : currentTheme.colors.mediumGray} style={styles.filterIcon} />
)}
<Text
>
<Text
style={[
styles.filterText,
{ color: currentTheme.colors.mediumGray },
@ -1901,11 +1898,11 @@ const LibraryScreen = () => {
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'))}
</ScrollView>
)}

View file

@ -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<boolean> => {
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<string | null> => {
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: {

View file

@ -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<NavigationProp<RootStackParamList>>();
const route = useRoute<RouteProp<RootStackParamList, 'Metadata'>>();
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 (
<MetadataLoadingScreen
ref={loadingScreenRef}
@ -1637,4 +1628,4 @@ const styles = StyleSheet.create({
export default MetadataScreen;
export default MetadataScreen;

View file

@ -46,10 +46,10 @@ import {
MAX_RECENT_SEARCHES,
} from '../components/search/searchUtils';
import { searchStyles as styles } from '../components/search/searchStyles';
import { SearchAnimation } from '../components/search/SearchAnimation';
import { AddonSection } from '../components/search/AddonSection';
import { DiscoverSection } from '../components/search/DiscoverSection';
import { DiscoverBottomSheets } from '../components/search/DiscoverBottomSheets';
import LoadingSpinner from '../components/common/LoadingSpinner';
const { width } = Dimensions.get('window');
const AnimatedTouchable = Animated.createAnimatedComponent(TouchableOpacity);
@ -761,7 +761,9 @@ const SearchScreen = () => {
<View style={styles.contentContainer}>
{searching && results.byAddon.length === 0 ? (
<SearchAnimation />
<View style={styles.emptyContainer}>
<LoadingSpinner size="large" />
</View>
) : searched && !hasResultsToShow && !searching ? (
<View style={styles.emptyContainer}>
<MaterialIcons name="search-off" size={64} color={currentTheme.colors.lightGray} />

View file

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

View file

@ -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<Manifest[]>): Promise<StreamingAddon[]> {
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');
}

View file

@ -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<DataSource> {
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<void> {
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<StreamingContent | null> {
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<StreamingContent | null> {
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<StreamingContent | null> {
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<string | null> {
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;
}
}

View file

@ -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<string, StreamingContent>
): 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<string, StreamingContent>
): 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<string, StreamingContent>
): Promise<StreamingContent> {
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,
};
}

View file

@ -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<StreamingAddon[]> {
return getAllAddons(() => stremioService.getInstalledAddonsAsync());
}
export async function resolveHomeCatalogsToFetch(
limitIds?: string[]
): Promise<Array<{ addon: StreamingAddon; catalog: StreamingCatalog }>> {
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<string, StreamingContent>,
addon: StreamingAddon,
catalog: StreamingCatalog
): Promise<CatalogContent | null> {
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<string>();
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<string, StreamingContent>,
limitIds?: string[]
): Promise<CatalogContent[]> {
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<string, StreamingContent>,
dataSourcePreference: DataSource,
type: string,
genreFilter?: string
): Promise<CatalogContent[]> {
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<Promise<CatalogContent | null>> = [];
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<string, StreamingContent>,
type: string,
genreFilter?: string
): Promise<CatalogContent[]> {
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<string>();
const allTypes = new Set<string>();
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<string, StreamingContent>,
type: string,
genre?: string,
limit = 20
): Promise<Array<{ addonName: string; items: StreamingContent[] }>> {
const addons = await getAllStreamingAddons();
const manifests = await stremioService.getInstalledAddonsAsync();
const manifestMap = new Map(manifests.map(manifest => [manifest.id, manifest]));
const catalogPromises: Array<Promise<{ addonName: string; items: StreamingContent[] } | null>> = [];
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<string, StreamingContent[]>();
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<string, StreamingContent>,
addonId: string,
catalogId: string,
type: string,
genre?: string,
page = 1
): Promise<StreamingContent[]> {
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 [];
}
}

View file

@ -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<string, StreamingContent>;
recentContent: StreamingContent[];
librarySubscribers: Array<(items: StreamingContent[]) => void>;
libraryAddListeners: Array<(item: StreamingContent) => void>;
libraryRemoveListeners: Array<(type: string, id: string) => void>;
initPromise: Promise<void>;
isInitialized: boolean;
}
export function createLibraryKey(type: string, id: string): string {
return `${type}:${id}`;
}
export async function initializeCatalogState(state: CatalogLibraryState): Promise<void> {
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<void> {
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<void> {
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<void> {
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<string, StreamingContent> = {};
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<void> {
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<void> {
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<void> {
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<StreamingContent[]> {
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<void> {
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<void> {
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;
}

View file

@ -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<string, StreamingContent>,
query: string
): Promise<StreamingContent[]> {
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<string, StreamingContent>,
query: string
): Promise<GroupedSearchResults> {
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<string>();
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<string>();
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<string, StreamingContent>,
query: string,
onAddonResults: (section: AddonSearchResults) => void
): { cancel: () => void; done: Promise<void> } {
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<string, number> = {};
searchableAddons.forEach((addon, index) => {
addonOrderRef[addon.id] = index;
});
const catalogTypeLabels: Record<string, string> = {
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<string, StreamingContent>,
manifest: Manifest,
type: string,
catalogId: string,
query: string
): Promise<StreamingContent[]> {
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<string, StreamingContent>();
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<string>,
catalogTypeLabels: Record<string, string>
): 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}`;
}

View file

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

File diff suppressed because it is too large Load diff

View file

@ -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<boolean> => {
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<string | null> => {
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;
}
};

View file

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

View file

@ -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<string, Manifest> = 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<void> | null = null;
catalogHasMore: Map<string, boolean> = 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<void> {
await initializeAddons(this);
}
async ensureInitialized(): Promise<void> {
if (!this.initialized && this.initializationPromise) {
await this.initializationPromise;
}
}
async retryRequest<T>(request: () => Promise<T>, retries = 1, delay = 1000): Promise<T> {
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<void> {
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<void> {
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<boolean> {
return isValidContentIdImpl(
this,
type,
id,
() => this.getAllSupportedTypes(),
value => this.getAllSupportedIdPrefixes(value)
);
}
async getManifest(url: string): Promise<Manifest> {
return getManifestImpl(this, url);
}
async installAddon(url: string): Promise<void> {
await installAddonImpl(this, url);
}
async removeAddon(installationId: string): Promise<void> {
await removeAddonImpl(this, installationId);
}
getInstalledAddons(): Manifest[] {
return getInstalledAddonsImpl(this);
}
async getInstalledAddonsAsync(): Promise<Manifest[]> {
return getInstalledAddonsAsyncImpl(this);
}
isPreInstalledAddon(id: string): boolean {
void id;
return isPreInstalledAddonImpl();
}
async hasUserRemovedAddon(addonId: string): Promise<boolean> {
return hasUserRemovedAddonImpl(addonId);
}
async unmarkAddonAsRemovedByUser(addonId: string): Promise<void> {
await unmarkAddonAsRemovedByUserImpl(addonId);
}
async getAllCatalogs(): Promise<Record<string, Meta[]>> {
return getAllCatalogsImpl(this);
}
async getCatalog(
manifest: Manifest,
type: string,
id: string,
page = 1,
filters: CatalogFilter[] = []
): Promise<Meta[]> {
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<MetaDetails | null> {
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<void> {
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<Subtitle[]> {
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<boolean> {
return applyAddonOrderFromManifestUrlsImpl(this, manifestUrls);
}
async hasStreamProviders(type?: string): Promise<boolean> {
return hasStreamProvidersImpl(this, type);
}
async getAddonCatalogs(type: string, id: string): Promise<AddonCatalogItem[]> {
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;

View file

@ -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<string> {
return (await mmkvStorage.getItem('@user:current')) || 'local';
}
export async function initializeAddons(ctx: StremioServiceContext): Promise<void> {
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<string>();
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<string>();
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<Manifest> {
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<void> {
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<void> {
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<Manifest[]> {
await ctx.ensureInitialized();
return getInstalledAddons(ctx);
}
export function isPreInstalledAddon(): boolean {
return false;
}
export async function hasUserRemovedAddon(addonId: string): Promise<boolean> {
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<void> {
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<void> {
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<void> {
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.
}
}

View file

@ -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<boolean> {
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<string, string[]>();
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<string>();
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;
}

View file

@ -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<boolean> {
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<string>(['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<Record<string, Meta[]>> {
const result: Record<string, Meta[]> = {};
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<Meta[]> {
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<MetaDetails | null> {
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<MetaDetails | null> {
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<AddonCatalogItem[]> {
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;
}

View file

@ -0,0 +1,39 @@
import type {
CatalogFilter,
Manifest,
Meta,
MetaDetails,
Stream,
} from './types';
export interface StremioServiceContext {
installedAddons: Map<string, Manifest>;
addonOrder: string[];
STORAGE_KEY: string;
ADDON_ORDER_KEY: string;
DEFAULT_PAGE_SIZE: number;
initialized: boolean;
initializationPromise: Promise<void> | null;
catalogHasMore: Map<string, boolean>;
ensureInitialized(): Promise<void>;
retryRequest<T>(request: () => Promise<T>, retries?: number, delay?: number): Promise<T>;
saveInstalledAddons(): Promise<void>;
saveAddonOrder(): Promise<void>;
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<boolean>;
getCatalog(
manifest: Manifest,
type: string,
id: string,
page?: number,
filters?: CatalogFilter[]
): Promise<Meta[]>;
getMetaDetails(type: string, id: string, preferredAddonId?: string): Promise<MetaDetails | null>;
hasUserRemovedAddon(addonId: string): Promise<boolean>;
unmarkAddonAsRemovedByUser(addonId: string): Promise<void>;
}

View file

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

View file

@ -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<void> {
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<void> {
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<boolean> {
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;
}

View file

@ -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<Subtitle[]> {
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<string>();
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;
}

View file

@ -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<string, string>;
subtitles?: Subtitle[];
sources?: string[];
behaviorHints?: {
bingeGroup?: string;
notWebReady?: boolean;
countryWhitelist?: string[];
cached?: boolean;
proxyHeaders?: {
request?: Record<string, string>;
response?: Record<string, string>;
};
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 };

File diff suppressed because it is too large Load diff

View file

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

View file

@ -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();
export const logger = new Logger();