mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-17 15:32:01 +00:00
Merge branch 'NuvioMedia:main' into localization-patch
This commit is contained in:
commit
40141bca60
43 changed files with 4410 additions and 4244 deletions
2
App.tsx
2
App.tsx
|
|
@ -195,11 +195,9 @@ const ThemedApp = () => {
|
||||||
|
|
||||||
// Initialize memory monitoring service to prevent OutOfMemoryError
|
// Initialize memory monitoring service to prevent OutOfMemoryError
|
||||||
memoryMonitorService; // Just accessing it starts the monitoring
|
memoryMonitorService; // Just accessing it starts the monitoring
|
||||||
console.log('Memory monitoring service initialized');
|
|
||||||
|
|
||||||
// Initialize AI service
|
// Initialize AI service
|
||||||
await aiService.initialize();
|
await aiService.initialize();
|
||||||
console.log('AI service initialized');
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error initializing app:', error);
|
console.error('Error initializing app:', error);
|
||||||
|
|
|
||||||
4
assets/player-icons/ic_player_play_black.svg
Normal file
4
assets/player-icons/ic_player_play_black.svg
Normal 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 |
|
|
@ -76,9 +76,7 @@ const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
||||||
renderMode: 'SOFTWARE' as any, // Fallback to software rendering if hardware fails
|
renderMode: 'SOFTWARE' as any, // Fallback to software rendering if hardware fails
|
||||||
})}
|
})}
|
||||||
// Error handling
|
// Error handling
|
||||||
onAnimationFinish={() => {
|
onAnimationFinish={() => {}}
|
||||||
if (__DEV__) console.log('Lottie animation finished');
|
|
||||||
}}
|
|
||||||
onAnimationFailure={(error) => {
|
onAnimationFailure={(error) => {
|
||||||
if (__DEV__) console.warn('Lottie animation failed:', error);
|
if (__DEV__) console.warn('Lottie animation failed:', error);
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -76,9 +76,7 @@ const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
||||||
renderMode: 'SOFTWARE' as any, // Fallback to software rendering if hardware fails
|
renderMode: 'SOFTWARE' as any, // Fallback to software rendering if hardware fails
|
||||||
})}
|
})}
|
||||||
// Error handling
|
// Error handling
|
||||||
onAnimationFinish={() => {
|
onAnimationFinish={() => {}}
|
||||||
if (__DEV__) console.log('Lottie animation finished');
|
|
||||||
}}
|
|
||||||
onAnimationFailure={(error) => {
|
onAnimationFailure={(error) => {
|
||||||
if (__DEV__) console.warn('Lottie animation failed:', error);
|
if (__DEV__) console.warn('Lottie animation failed:', error);
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { RootStackParamList } from '../../navigation/AppNavigator';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import FastImage from '@d11/react-native-fast-image';
|
import FastImage from '@d11/react-native-fast-image';
|
||||||
import { MaterialIcons, Entypo } from '@expo/vector-icons';
|
import { MaterialIcons, Entypo } from '@expo/vector-icons';
|
||||||
|
import PlayerPlayIconBlack from '../../../assets/player-icons/ic_player_play_black.svg';
|
||||||
import Animated, {
|
import Animated, {
|
||||||
FadeIn,
|
FadeIn,
|
||||||
FadeOut,
|
FadeOut,
|
||||||
|
|
@ -1316,11 +1317,19 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
||||||
onPress={handlePlayAction}
|
onPress={handlePlayAction}
|
||||||
activeOpacity={0.85}
|
activeOpacity={0.85}
|
||||||
>
|
>
|
||||||
<MaterialIcons
|
{shouldResume ? (
|
||||||
name={shouldResume ? "replay" : "play-arrow"}
|
<MaterialIcons
|
||||||
size={24}
|
name="replay"
|
||||||
color="#000"
|
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>
|
<Text style={styles.playButtonText}>{shouldResume ? t('home.resume') : t('home.play')}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
|
@ -1330,11 +1339,21 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
||||||
onPress={handleSaveAction}
|
onPress={handleSaveAction}
|
||||||
activeOpacity={0.85}
|
activeOpacity={0.85}
|
||||||
>
|
>
|
||||||
<MaterialIcons
|
{Platform.OS === 'ios' ? (
|
||||||
name={inLibrary ? "bookmark" : "bookmark-outline"}
|
<ExpoBlurView intensity={35} tint="light" style={styles.saveButtonBlur}>
|
||||||
size={24}
|
<MaterialIcons
|
||||||
color="white"
|
name={inLibrary ? "bookmark" : "bookmark-outline"}
|
||||||
/>
|
size={24}
|
||||||
|
color="white"
|
||||||
|
/>
|
||||||
|
</ExpoBlurView>
|
||||||
|
) : (
|
||||||
|
<MaterialIcons
|
||||||
|
name={inLibrary ? "bookmark" : "bookmark-outline"}
|
||||||
|
size={24}
|
||||||
|
color="white"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|
@ -1487,11 +1506,18 @@ const styles = StyleSheet.create({
|
||||||
height: 52,
|
height: 52,
|
||||||
borderRadius: 30,
|
borderRadius: 30,
|
||||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||||
|
overflow: 'hidden',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
borderColor: 'rgba(255,255,255,0.3)',
|
borderColor: 'rgba(255,255,255,0.3)',
|
||||||
},
|
},
|
||||||
|
saveButtonBlur: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
paginationContainer: {
|
paginationContainer: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ import { TMDBService } from '../../services/tmdbService';
|
||||||
import TrailerService, { TrailerPlaybackSource } from '../../services/trailerService';
|
import TrailerService, { TrailerPlaybackSource } from '../../services/trailerService';
|
||||||
import TrailerPlayer from '../video/TrailerPlayer';
|
import TrailerPlayer from '../video/TrailerPlayer';
|
||||||
import { HERO_HEIGHT, SCREEN_WIDTH as width, IS_TABLET as isTablet } from '../../constants/dimensions';
|
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');
|
const { height } = Dimensions.get('window');
|
||||||
|
|
||||||
|
|
@ -355,16 +356,19 @@ const ActionButtons = memo(({
|
||||||
onPress={handleShowStreams}
|
onPress={handleShowStreams}
|
||||||
activeOpacity={0.85}
|
activeOpacity={0.85}
|
||||||
>
|
>
|
||||||
<MaterialIcons
|
{isWatched && type === 'movie' ? (
|
||||||
name={(() => {
|
<MaterialIcons
|
||||||
if (isWatched) {
|
name="replay"
|
||||||
return type === 'movie' ? 'replay' : 'play-arrow';
|
size={isTablet ? 28 : 24}
|
||||||
}
|
color="#fff"
|
||||||
return playButtonText === 'Resume' ? 'play-circle-outline' : 'play-arrow';
|
/>
|
||||||
})()}
|
) : (
|
||||||
size={isTablet ? 28 : 24}
|
<PlayerPlayIconBlack
|
||||||
color={isWatched && type === 'movie' ? "#fff" : "#000"}
|
width={isTablet ? 28 : 24}
|
||||||
/>
|
height={isTablet ? 28 : 24}
|
||||||
|
style={{ transform: [{ scale: isTablet ? 0.85 : 0.79 }] }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Text style={[playButtonTextStyle, isTablet && styles.tabletPlayButtonText]}>{finalPlayButtonText}</Text>
|
<Text style={[playButtonTextStyle, isTablet && styles.tabletPlayButtonText]}>{finalPlayButtonText}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import Animated, {
|
||||||
Extrapolate,
|
Extrapolate,
|
||||||
} from 'react-native-reanimated';
|
} from 'react-native-reanimated';
|
||||||
import { useTheme } from '../../contexts/ThemeContext';
|
import { useTheme } from '../../contexts/ThemeContext';
|
||||||
import { isMDBListEnabled } from '../../screens/MDBListSettingsScreen';
|
import { isMDBListEnabled } from '../../services/mdblistConstants';
|
||||||
import { getAgeRatingColor } from '../../utils/ageRatingColors';
|
import { getAgeRatingColor } from '../../utils/ageRatingColors';
|
||||||
import AgeRatingBadge from '../common/AgeRatingBadge';
|
import AgeRatingBadge from '../common/AgeRatingBadge';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { useTheme } from '../../contexts/ThemeContext';
|
||||||
import FastImage from '@d11/react-native-fast-image';
|
import FastImage from '@d11/react-native-fast-image';
|
||||||
import { useMDBListRatings } from '../../hooks/useMDBListRatings';
|
import { useMDBListRatings } from '../../hooks/useMDBListRatings';
|
||||||
import { mmkvStorage } from '../../services/mmkvStorage';
|
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 SVG icons
|
||||||
import LetterboxdIcon from '../../../assets/rating-icons/letterboxd.svg';
|
import LetterboxdIcon from '../../../assets/rating-icons/letterboxd.svg';
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,18 @@ import { useFocusEffect } from '@react-navigation/native';
|
||||||
import Animated, { FadeIn, FadeOut, SlideInRight, SlideOutLeft } from 'react-native-reanimated';
|
import Animated, { FadeIn, FadeOut, SlideInRight, SlideOutLeft } from 'react-native-reanimated';
|
||||||
import { TraktService } from '../../services/traktService';
|
import { TraktService } from '../../services/traktService';
|
||||||
import { watchedService } from '../../services/watchedService';
|
import { watchedService } from '../../services/watchedService';
|
||||||
import { logger } from '../../utils/logger';
|
|
||||||
import { mmkvStorage } from '../../services/mmkvStorage';
|
import { mmkvStorage } from '../../services/mmkvStorage';
|
||||||
import { MalSync } from '../../services/mal/MalSync';
|
import { MalSync } from '../../services/mal/MalSync';
|
||||||
|
|
||||||
|
const noop = (..._args: unknown[]) => {};
|
||||||
|
const logger = {
|
||||||
|
log: noop,
|
||||||
|
error: noop,
|
||||||
|
warn: noop,
|
||||||
|
info: noop,
|
||||||
|
debug: noop,
|
||||||
|
};
|
||||||
|
|
||||||
// ... other imports
|
// ... other imports
|
||||||
const BREAKPOINTS = {
|
const BREAKPOINTS = {
|
||||||
phone: 0,
|
phone: 0,
|
||||||
|
|
@ -212,10 +220,10 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
||||||
const savedMode = await mmkvStorage.getItem('global_season_view_mode');
|
const savedMode = await mmkvStorage.getItem('global_season_view_mode');
|
||||||
if (savedMode === 'text' || savedMode === 'posters') {
|
if (savedMode === 'text' || savedMode === 'posters') {
|
||||||
setSeasonViewMode(savedMode);
|
setSeasonViewMode(savedMode);
|
||||||
if (__DEV__) console.log('[SeriesContent] Loaded global view mode:', savedMode);
|
if (__DEV__) logger.log('[SeriesContent] Loaded global view mode:', savedMode);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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') => {
|
const updateViewMode = (newMode: 'posters' | 'text') => {
|
||||||
setSeasonViewMode(newMode);
|
setSeasonViewMode(newMode);
|
||||||
mmkvStorage.setItem('global_season_view_mode', newMode).catch((error: any) => {
|
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(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
// Clear any pending timeouts
|
// 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)
|
// Force garbage collection if available (development only)
|
||||||
if (__DEV__ && global.gc) {
|
if (__DEV__ && global.gc) {
|
||||||
|
|
@ -854,7 +862,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
const newMode = seasonViewMode === 'posters' ? 'text' : 'posters';
|
const newMode = seasonViewMode === 'posters' ? 'text' : 'posters';
|
||||||
updateViewMode(newMode);
|
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}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,20 @@ import FastImage from '@d11/react-native-fast-image';
|
||||||
import { useTheme } from '../../contexts/ThemeContext';
|
import { useTheme } from '../../contexts/ThemeContext';
|
||||||
import { useSettings } from '../../hooks/useSettings';
|
import { useSettings } from '../../hooks/useSettings';
|
||||||
import { useTrailer } from '../../contexts/TrailerContext';
|
import { useTrailer } from '../../contexts/TrailerContext';
|
||||||
import { logger } from '../../utils/logger';
|
|
||||||
import TrailerService from '../../services/trailerService';
|
import TrailerService from '../../services/trailerService';
|
||||||
import { TMDBService } from '../../services/tmdbService';
|
import { TMDBService } from '../../services/tmdbService';
|
||||||
import TrailerModal from './TrailerModal';
|
import TrailerModal from './TrailerModal';
|
||||||
import Animated, { useSharedValue, withTiming, withDelay, useAnimatedStyle } from 'react-native-reanimated';
|
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
|
// Enhanced responsive breakpoints for Trailers Section
|
||||||
const BREAKPOINTS = {
|
const BREAKPOINTS = {
|
||||||
phone: 0,
|
phone: 0,
|
||||||
|
|
|
||||||
|
|
@ -216,11 +216,9 @@ export const CampaignManager: React.FC = () => {
|
||||||
|
|
||||||
const checkForCampaigns = useCallback(async () => {
|
const checkForCampaigns = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
console.log('[CampaignManager] Checking for campaigns...');
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||||
|
|
||||||
const campaign = await campaignService.getActiveCampaign();
|
const campaign = await campaignService.getActiveCampaign();
|
||||||
console.log('[CampaignManager] Got campaign:', campaign?.id, campaign?.type);
|
|
||||||
|
|
||||||
if (campaign) {
|
if (campaign) {
|
||||||
setActiveCampaign(campaign);
|
setActiveCampaign(campaign);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { mdblistService, MDBListRatings } from '../services/mdblistService';
|
import { mdblistService, MDBListRatings } from '../services/mdblistService';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
import { isMDBListEnabled } from '../screens/MDBListSettingsScreen';
|
import { isMDBListEnabled } from '../services/mdblistConstants';
|
||||||
|
|
||||||
export const useMDBListRatings = (imdbId: string, mediaType: 'movie' | 'show') => {
|
export const useMDBListRatings = (imdbId: string, mediaType: 'movie' | 'show') => {
|
||||||
const [ratings, setRatings] = useState<MDBListRatings | null>(null);
|
const [ratings, setRatings] = useState<MDBListRatings | null>(null);
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import { cacheService } from '../services/cacheService';
|
||||||
import { localScraperService, ScraperInfo } from '../services/pluginService';
|
import { localScraperService, ScraperInfo } from '../services/pluginService';
|
||||||
import { Cast, Episode, GroupedEpisodes, GroupedStreams } from '../types/metadata';
|
import { Cast, Episode, GroupedEpisodes, GroupedStreams } from '../types/metadata';
|
||||||
import { TMDBService } from '../services/tmdbService';
|
import { TMDBService } from '../services/tmdbService';
|
||||||
import { logger } from '../utils/logger';
|
|
||||||
import { usePersistentSeasons } from './usePersistentSeasons';
|
import { usePersistentSeasons } from './usePersistentSeasons';
|
||||||
import { mmkvStorage } from '../services/mmkvStorage';
|
import { mmkvStorage } from '../services/mmkvStorage';
|
||||||
import { Stream } from '../types/metadata';
|
import { Stream } from '../types/metadata';
|
||||||
|
|
@ -15,6 +14,15 @@ import { storageService } from '../services/storageService';
|
||||||
import { useSettings } from './useSettings';
|
import { useSettings } from './useSettings';
|
||||||
import { MalSync } from '../services/mal/MalSync';
|
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
|
// Constants for timeouts and retries
|
||||||
const API_TIMEOUT = 10000; // 10 seconds
|
const API_TIMEOUT = 10000; // 10 seconds
|
||||||
const MAX_RETRIES = 1; // Reduced since stremioService already retries
|
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.
|
// Normalize anime subtypes to their base types for all internal logic.
|
||||||
// anime.series behaves like series; anime.movie behaves like movie.
|
// anime.series behaves like series; anime.movie behaves like movie.
|
||||||
const normalizedType = type === 'anime.series' ? 'series'
|
// Lowercase first — some addons use capitalized types (e.g. "Movie", "Series", "Other")
|
||||||
: type === 'anime.movie' ? 'movie'
|
// which would break all type comparisons downstream.
|
||||||
: type;
|
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 [metadata, setMetadata] = useState<StreamingContent | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
@ -163,8 +177,6 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
|
|
||||||
// Memory optimization: Stream cleanup and garbage collection
|
// Memory optimization: Stream cleanup and garbage collection
|
||||||
const cleanupStreams = useCallback(() => {
|
const cleanupStreams = useCallback(() => {
|
||||||
if (__DEV__) console.log('[useMetadata] Running stream cleanup to free memory');
|
|
||||||
|
|
||||||
// Clear preloaded streams cache
|
// Clear preloaded streams cache
|
||||||
setPreloadedStreams({});
|
setPreloadedStreams({});
|
||||||
setPreloadedEpisodeStreams({});
|
setPreloadedEpisodeStreams({});
|
||||||
|
|
@ -222,25 +234,11 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
(streams, addonId, addonName, error, installationId) => {
|
(streams, addonId, addonName, error, installationId) => {
|
||||||
const processTime = Date.now() - sourceStartTime;
|
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
|
// ALWAYS remove from active fetching list when callback is received
|
||||||
// This ensures that even failed scrapers are removed from the "Fetching from:" chip
|
// This ensures that even failed scrapers are removed from the "Fetching from:" chip
|
||||||
if (addonName) {
|
if (addonName) {
|
||||||
setActiveFetchingScrapers(prev => {
|
setActiveFetchingScrapers(prev => {
|
||||||
const updated = prev.filter(name => name !== addonName);
|
const updated = prev.filter(name => name !== addonName);
|
||||||
console.log('🔍 [processStremioSource] Removing from activeFetchingScrapers:', {
|
|
||||||
addonName,
|
|
||||||
before: prev,
|
|
||||||
after: updated
|
|
||||||
});
|
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -496,18 +494,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
|
|
||||||
const loadMetadata = async () => {
|
const loadMetadata = async () => {
|
||||||
try {
|
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) {
|
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.`);
|
setError(`Failed to load content after ${MAX_RETRIES + 1} attempts. Please check your connection and try again.`);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
|
|
@ -520,14 +507,6 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
// Check metadata screen cache
|
// Check metadata screen cache
|
||||||
const cachedScreen = cacheService.getMetadataScreen(id, normalizedType);
|
const cachedScreen = cacheService.getMetadataScreen(id, normalizedType);
|
||||||
if (cachedScreen) {
|
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);
|
setMetadata(cachedScreen.metadata);
|
||||||
setCast(cachedScreen.cast);
|
setCast(cachedScreen.cast);
|
||||||
if (normalizedType === 'series' && cachedScreen.episodes) {
|
if (normalizedType === 'series' && cachedScreen.episodes) {
|
||||||
|
|
@ -545,7 +524,6 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
console.log('🔍 [useMetadata] No cached metadata found, proceeding with fresh fetch');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle TMDB-specific IDs
|
// Handle TMDB-specific IDs
|
||||||
|
|
@ -556,7 +534,6 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
// STRICT MODE: Do NOT convert to IMDb/Cinemeta.
|
// STRICT MODE: Do NOT convert to IMDb/Cinemeta.
|
||||||
// We want to force the app to use AnimeKitsu (or other MAL-compatible addons) for metadata.
|
// 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.
|
// 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
|
// Note: Stream fetching (stremioService) WILL still convert this to IMDb secretly
|
||||||
// to ensure Torrentio works, but the Metadata UI will stay purely MAL-based.
|
// 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:')) {
|
if (id.startsWith('tmdb:')) {
|
||||||
// Always try the original TMDB ID first - let addons decide if they support it
|
// 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 enrichment disabled, try original ID first, then fallback to conversion if needed
|
||||||
if (!settings.enrichMetadataWithTMDB) {
|
if (!settings.enrichMetadataWithTMDB) {
|
||||||
// Keep the original TMDB ID - let the addon system handle it dynamically
|
// Keep the original TMDB ID - let the addon system handle it dynamically
|
||||||
actualId = id;
|
actualId = id;
|
||||||
console.log('🔍 [useMetadata] TMDB enrichment disabled, using original TMDB ID:', { actualId });
|
|
||||||
} else {
|
} else {
|
||||||
const tmdbId = id.split(':')[1];
|
const tmdbId = id.split(':')[1];
|
||||||
// For TMDB IDs, we need to handle metadata differently
|
// 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)
|
// Load series data (episodes)
|
||||||
setTmdbId(parseInt(tmdbId));
|
setTmdbId(parseInt(tmdbId));
|
||||||
loadSeriesData().catch((error) => { if (__DEV__) console.error(error); });
|
loadSeriesData().catch((error) => { if (__DEV__) logger.error(error); });
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const items = await catalogService.getLibraryItems();
|
const items = await catalogService.getLibraryItems();
|
||||||
|
|
@ -749,7 +723,6 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load all data in parallel
|
// 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 });
|
if (__DEV__) logger.log('[loadMetadata] fetching addon metadata', { type, actualId, addonId });
|
||||||
|
|
||||||
let contentResult: any = null;
|
let contentResult: any = null;
|
||||||
|
|
@ -761,7 +734,6 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
if (preferExternal) {
|
if (preferExternal) {
|
||||||
// Try external meta addons first
|
// Try external meta addons first
|
||||||
try {
|
try {
|
||||||
console.log('🔍 [useMetadata] Trying external meta addons first');
|
|
||||||
const [content, castData] = await Promise.allSettled([
|
const [content, castData] = await Promise.allSettled([
|
||||||
withRetry(async () => {
|
withRetry(async () => {
|
||||||
// Get all installed addons
|
// Get all installed addons
|
||||||
|
|
@ -791,20 +763,17 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
console.log('🔍 [useMetadata] Got metadata from external addon:', addon.name);
|
|
||||||
if (actualId.startsWith('tt')) {
|
if (actualId.startsWith('tt')) {
|
||||||
setImdbId(actualId);
|
setImdbId(actualId);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('🔍 [useMetadata] External addon failed:', addon.name, error);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no external addon worked, fall back to catalog addon
|
// 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(
|
const result = await withTimeout(
|
||||||
catalogService.getEnhancedContentDetails(normalizedType, actualId, addonId),
|
catalogService.getEnhancedContentDetails(normalizedType, actualId, addonId),
|
||||||
API_TIMEOUT
|
API_TIMEOUT
|
||||||
|
|
@ -819,39 +788,29 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
|
|
||||||
contentResult = content;
|
contentResult = content;
|
||||||
if (content.status === 'fulfilled' && content.value) {
|
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 {
|
} else {
|
||||||
console.log('🔍 [useMetadata] External meta addon priority failed, will try fallback');
|
|
||||||
lastError = (content as any)?.reason;
|
lastError = (content as any)?.reason;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('🔍 [useMetadata] External meta addon attempt failed:', { error: error instanceof Error ? error.message : String(error) });
|
|
||||||
lastError = error;
|
lastError = error;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Original behavior: try with original ID first
|
// Original behavior: try with original ID first
|
||||||
try {
|
try {
|
||||||
console.log('🔍 [useMetadata] Attempting metadata fetch with original ID:', { type, actualId, addonId });
|
|
||||||
const [content, castData] = await Promise.allSettled([
|
const [content, castData] = await Promise.allSettled([
|
||||||
// Load content with timeout and retry
|
// Load content with timeout and retry
|
||||||
withRetry(async () => {
|
withRetry(async () => {
|
||||||
console.log('⚡ [useMetadata] Calling catalogService.getEnhancedContentDetails...');
|
|
||||||
console.log('🔍 [useMetadata] Calling catalogService.getEnhancedContentDetails:', { type, actualId, addonId });
|
|
||||||
const result = await withTimeout(
|
const result = await withTimeout(
|
||||||
catalogService.getEnhancedContentDetails(normalizedType, actualId, addonId),
|
catalogService.getEnhancedContentDetails(normalizedType, actualId, addonId),
|
||||||
API_TIMEOUT
|
API_TIMEOUT
|
||||||
);
|
);
|
||||||
console.log('✅ [useMetadata] catalogService returned:', result ? 'DATA' : 'NULL');
|
|
||||||
// Store the actual ID used (could be IMDB)
|
// Store the actual ID used (could be IMDB)
|
||||||
if (actualId.startsWith('tt')) {
|
if (actualId.startsWith('tt')) {
|
||||||
setImdbId(actualId);
|
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) });
|
if (__DEV__) logger.log('[loadMetadata] addon metadata fetched', { hasResult: Boolean(result) });
|
||||||
return result;
|
return result;
|
||||||
}),
|
}),
|
||||||
|
|
@ -861,13 +820,13 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
|
|
||||||
contentResult = content;
|
contentResult = content;
|
||||||
if (content.status === 'fulfilled' && content.value) {
|
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 {
|
} else {
|
||||||
console.log('🔍 [useMetadata] Original ID failed, will try fallback conversion');
|
|
||||||
lastError = (content as any)?.reason;
|
lastError = (content as any)?.reason;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('🔍 [useMetadata] Original ID attempt failed:', { error: error instanceof Error ? error.message : String(error) });
|
|
||||||
lastError = 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 original TMDB ID failed and enrichment is disabled, try ID conversion as fallback
|
||||||
if (!contentResult || (contentResult.status === 'fulfilled' && !contentResult.value) || contentResult.status === 'rejected') {
|
if (!contentResult || (contentResult.status === 'fulfilled' && !contentResult.value) || contentResult.status === 'rejected') {
|
||||||
if (id.startsWith('tmdb:') && !settings.enrichMetadataWithTMDB) {
|
if (id.startsWith('tmdb:') && !settings.enrichMetadataWithTMDB) {
|
||||||
console.log('🔍 [useMetadata] Original TMDB ID failed, trying ID conversion fallback');
|
|
||||||
const tmdbRaw = id.split(':')[1];
|
const tmdbRaw = id.split(':')[1];
|
||||||
try {
|
try {
|
||||||
const stremioId = await catalogService.getStremioId(normalizedType === 'series' ? 'tv' : 'movie', tmdbRaw);
|
const stremioId = await catalogService.getStremioId(normalizedType === 'series' ? 'tv' : 'movie', tmdbRaw);
|
||||||
if (stremioId && stremioId !== id) {
|
if (stremioId && stremioId !== id) {
|
||||||
console.log('🔍 [useMetadata] Trying converted ID:', { originalId: id, convertedId: stremioId });
|
|
||||||
const [content, castData] = await Promise.allSettled([
|
const [content, castData] = await Promise.allSettled([
|
||||||
withRetry(async () => {
|
withRetry(async () => {
|
||||||
const result = await withTimeout(
|
const result = await withTimeout(
|
||||||
|
|
@ -897,7 +854,9 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
contentResult = content;
|
contentResult = content;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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 content = contentResult || { status: 'rejected' as const, reason: lastError || new Error('No content result') };
|
||||||
const castData = { status: 'fulfilled' as const, value: undefined };
|
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) {
|
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 });
|
if (__DEV__) logger.log('[loadMetadata] addon metadata:success', { id: content.value?.id, type: content.value?.type, name: content.value?.name });
|
||||||
|
|
||||||
// Start with addon metadata
|
// Start with addon metadata
|
||||||
|
|
@ -1025,7 +969,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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
|
// 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
|
// Use TMDB logo if found, otherwise fall back to addon logo
|
||||||
finalMetadata.logo = logoUrl || addonLogo || undefined;
|
finalMetadata.logo = logoUrl || addonLogo || undefined;
|
||||||
if (__DEV__) {
|
if (__DEV__) {
|
||||||
console.log('[useMetadata] Logo fetch result:', {
|
logger.log('[useMetadata] Logo fetch result:', {
|
||||||
contentType,
|
contentType,
|
||||||
tmdbIdForLogo,
|
tmdbIdForLogo,
|
||||||
preferredLanguage,
|
preferredLanguage,
|
||||||
|
|
@ -1062,13 +1006,13 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
} else {
|
} else {
|
||||||
// No TMDB ID, fall back to addon logo
|
// No TMDB ID, fall back to addon logo
|
||||||
finalMetadata.logo = addonLogo || undefined;
|
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 {
|
} else {
|
||||||
// When enrichment or logos is OFF, use addon logo
|
// When enrichment or logos is OFF, use addon logo
|
||||||
finalMetadata.logo = addonLogo || finalMetadata.logo || undefined;
|
finalMetadata.logo = addonLogo || finalMetadata.logo || undefined;
|
||||||
if (__DEV__) {
|
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,
|
hasAddonLogo: !!finalMetadata.logo,
|
||||||
enrichmentEnabled: settings.enrichMetadataWithTMDB,
|
enrichmentEnabled: settings.enrichMetadataWithTMDB,
|
||||||
logosEnabled: settings.tmdbEnrichLogos
|
logosEnabled: settings.tmdbEnrichLogos
|
||||||
|
|
@ -1077,7 +1021,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Handle error silently, keep existing logo behavior
|
// 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;
|
finalMetadata.logo = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1114,17 +1058,8 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
const reason = (content as any)?.reason;
|
const reason = (content as any)?.reason;
|
||||||
const reasonMessage = reason?.message || String(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__) {
|
if (__DEV__) {
|
||||||
console.log('[loadMetadata] addon metadata:not found or failed', {
|
logger.log('[loadMetadata] addon metadata:not found or failed', {
|
||||||
status: content.status,
|
status: content.status,
|
||||||
reason: reasonMessage,
|
reason: reasonMessage,
|
||||||
fullReason: reason
|
fullReason: reason
|
||||||
|
|
@ -1139,28 +1074,23 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
reasonMessage.includes('Network Error') ||
|
reasonMessage.includes('Network Error') ||
|
||||||
reasonMessage.includes('Request failed')
|
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
|
// This was a server/network error, preserve the original error message
|
||||||
throw reason instanceof Error ? reason : new Error(reasonMessage);
|
throw reason instanceof Error ? reason : new Error(reasonMessage);
|
||||||
} else {
|
} else {
|
||||||
console.log('🔍 [useMetadata] Detected content not found error, throwing generic error');
|
|
||||||
// This was likely a content not found error
|
// This was likely a content not found error
|
||||||
throw new Error('Content not found');
|
throw new Error('Content not found');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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__) {
|
if (__DEV__) {
|
||||||
console.error('Failed to load metadata:', error);
|
logger.log('[loadMetadata] failed with error', {
|
||||||
console.log('Error message being set:', error instanceof Error ? error.message : String(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
|
// Preserve the original error details for better error parsing
|
||||||
|
|
@ -1173,7 +1103,6 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
setGroupedEpisodes({});
|
setGroupedEpisodes({});
|
||||||
setEpisodes([]);
|
setEpisodes([]);
|
||||||
} finally {
|
} finally {
|
||||||
console.log('🔍 [useMetadata] loadMetadata completed, setting loading to false');
|
|
||||||
setLoading(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)');
|
if (__DEV__) logger.log('[useMetadata] merged episode names/overviews from TMDB (batch)');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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) {
|
} catch (error) {
|
||||||
if (__DEV__) console.error('Failed to load episodes:', error);
|
if (__DEV__) logger.error('Failed to load episodes:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingSeasons(false);
|
setLoadingSeasons(false);
|
||||||
}
|
}
|
||||||
|
|
@ -1531,7 +1460,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allEmbeddedStreams.length > 0) {
|
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
|
// Add to grouped streams
|
||||||
setGroupedStreams(prevStreams => ({
|
setGroupedStreams(prevStreams => ({
|
||||||
|
|
@ -1566,7 +1495,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
);
|
);
|
||||||
|
|
||||||
if (episodeVideo && episodeVideo.streams && episodeVideo.streams.length > 0) {
|
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) => ({
|
const episodeStreamsList: Stream[] = episodeVideo.streams.map((stream: any) => ({
|
||||||
...stream,
|
...stream,
|
||||||
|
|
@ -1592,7 +1521,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
const loadStreams = async () => {
|
const loadStreams = async () => {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
try {
|
try {
|
||||||
if (__DEV__) console.log('🚀 [loadStreams] START - Loading streams for:', id);
|
if (__DEV__) logger.log('🚀 [loadStreams] START - Loading streams for:', id);
|
||||||
updateLoadingState();
|
updateLoadingState();
|
||||||
|
|
||||||
// Reset scraper tracking
|
// Reset scraper tracking
|
||||||
|
|
@ -1600,22 +1529,22 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
setActiveFetchingScrapers([]);
|
setActiveFetchingScrapers([]);
|
||||||
setAddonResponseOrder([]); // Reset response order
|
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 tmdbId;
|
||||||
let stremioId = id;
|
let stremioId = id;
|
||||||
let effectiveStreamType: string = type;
|
let effectiveStreamType: string = type;
|
||||||
|
|
||||||
if (id.startsWith('tmdb:')) {
|
if (id.startsWith('tmdb:')) {
|
||||||
tmdbId = id.split(':')[1];
|
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
|
// Try to get IMDb ID from metadata first, then convert if needed
|
||||||
if (metadata?.imdb_id) {
|
if (metadata?.imdb_id) {
|
||||||
stremioId = 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) {
|
} else if (imdbId) {
|
||||||
stremioId = 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 {
|
} else {
|
||||||
// Convert TMDB ID to IMDb ID for Stremio addons (they expect IMDb format)
|
// Convert TMDB ID to IMDb ID for Stremio addons (they expect IMDb format)
|
||||||
try {
|
try {
|
||||||
|
|
@ -1629,28 +1558,28 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
|
|
||||||
if (externalIds?.imdb_id) {
|
if (externalIds?.imdb_id) {
|
||||||
stremioId = 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 {
|
} 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) {
|
} 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')) {
|
} else if (id.startsWith('tt')) {
|
||||||
// This is already an IMDB ID, perfect for Stremio
|
// This is already an IMDB ID, perfect for Stremio
|
||||||
stremioId = id;
|
stremioId = id;
|
||||||
if (settings.enrichMetadataWithTMDB) {
|
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);
|
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 {
|
} 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 {
|
} else {
|
||||||
tmdbId = id;
|
tmdbId = id;
|
||||||
stremioId = 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
|
// Initialize scraper tracking
|
||||||
|
|
@ -1710,14 +1639,14 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
if (fallback.length > 0) {
|
if (fallback.length > 0) {
|
||||||
effectiveStreamType = fallbackType;
|
effectiveStreamType = fallbackType;
|
||||||
eligibleStreamAddons = fallback;
|
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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const streamAddons = eligibleStreamAddons;
|
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
|
// Initialize scraper statuses for tracking
|
||||||
const initialStatuses: ScraperStatus[] = [];
|
const initialStatuses: ScraperStatus[] = [];
|
||||||
|
|
@ -1764,11 +1693,11 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
setLoadingStreams(false);
|
setLoadingStreams(false);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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
|
// 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.
|
// 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.
|
// This stays aligned with Stremio manifest filtering rules and avoids hard-mapping non-standard types.
|
||||||
processStremioSource(effectiveStreamType, stremioId, false);
|
processStremioSource(effectiveStreamType, stremioId, false);
|
||||||
|
|
@ -1807,7 +1736,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
}, 60000);
|
}, 60000);
|
||||||
|
|
||||||
} catch (error) {
|
} 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
|
// Preserve the original error details for better error parsing
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Failed to load streams';
|
const errorMessage = error instanceof Error ? error.message : 'Failed to load streams';
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
|
|
@ -1818,7 +1747,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
const loadEpisodeStreams = async (episodeId: string) => {
|
const loadEpisodeStreams = async (episodeId: string) => {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
try {
|
try {
|
||||||
if (__DEV__) console.log('🚀 [loadEpisodeStreams] START - Loading episode streams for:', episodeId);
|
if (__DEV__) logger.log('🚀 [loadEpisodeStreams] START - Loading episode streams for:', episodeId);
|
||||||
updateEpisodeLoadingState();
|
updateEpisodeLoadingState();
|
||||||
|
|
||||||
// Reset scraper tracking for episodes
|
// Reset scraper tracking for episodes
|
||||||
|
|
@ -1861,7 +1790,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
const fallback = pickStreamCapableAddons(fallbackType);
|
const fallback = pickStreamCapableAddons(fallbackType);
|
||||||
if (fallback.length > 0) {
|
if (fallback.length > 0) {
|
||||||
streamAddons = fallback;
|
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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1912,12 +1841,12 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
setLoadingEpisodeStreams(false);
|
setLoadingEpisodeStreams(false);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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
|
// Get TMDB ID for external sources and determine the correct ID for Stremio addons
|
||||||
const isImdb = id.startsWith('tt');
|
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 tmdbId;
|
||||||
let stremioEpisodeId = episodeId; // Default to original episode ID
|
let stremioEpisodeId = episodeId; // Default to original episode ID
|
||||||
let isCollection = false;
|
let isCollection = false;
|
||||||
|
|
@ -1965,13 +1894,13 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
showIdStr = parts.join(':');
|
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) {
|
} 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 (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
|
// For collections, extract the individual movie ID from the episodeId
|
||||||
// episodeId format for collections: "tt7888964" (IMDb ID of individual movie)
|
// 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);
|
tmdbId = await withTimeout(tmdbService.findTMDBIdByIMDB(episodeId), API_TIMEOUT);
|
||||||
}
|
}
|
||||||
stremioEpisodeId = episodeId; // Use the IMDb ID directly for Stremio addons
|
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 {
|
} else {
|
||||||
// Fallback: try to verify if it's a tmdb id
|
// Fallback: try to verify if it's a tmdb id
|
||||||
const isTmdb = episodeId.startsWith('tmdb:') || !isNaN(Number(episodeId));
|
const isTmdb = episodeId.startsWith('tmdb:') || !isNaN(Number(episodeId));
|
||||||
|
|
@ -1992,20 +1921,20 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
} else {
|
} else {
|
||||||
stremioEpisodeId = episodeId;
|
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:')) {
|
} else if (id.startsWith('tmdb:')) {
|
||||||
tmdbId = id.split(':')[1];
|
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
|
// Try to get IMDb ID from metadata first, then convert if needed
|
||||||
if (metadata?.imdb_id) {
|
if (metadata?.imdb_id) {
|
||||||
// Use format: imdb_id:season:episode
|
// Use format: imdb_id:season:episode
|
||||||
stremioEpisodeId = `${metadata.imdb_id}:${seasonNum}:${episodeNum}`;
|
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) {
|
} else if (imdbId) {
|
||||||
stremioEpisodeId = `${imdbId}:${seasonNum}:${episodeNum}`;
|
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 {
|
} else {
|
||||||
// Convert TMDB ID to IMDb ID for Stremio addons
|
// Convert TMDB ID to IMDb ID for Stremio addons
|
||||||
try {
|
try {
|
||||||
|
|
@ -2013,27 +1942,27 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
|
|
||||||
if (externalIds?.imdb_id) {
|
if (externalIds?.imdb_id) {
|
||||||
stremioEpisodeId = `${externalIds.imdb_id}:${seasonNum}:${episodeNum}`;
|
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 {
|
} else {
|
||||||
// Fallback to TMDB format if conversions fail
|
// Fallback to TMDB format if conversions fail
|
||||||
// e.g. tmdb:123:1:1
|
// e.g. tmdb:123:1:1
|
||||||
stremioEpisodeId = `${id}:${seasonNum}:${episodeNum}`;
|
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) {
|
} catch (error) {
|
||||||
stremioEpisodeId = `${id}:${seasonNum}:${episodeNum}`;
|
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) {
|
} else if (isImdb) {
|
||||||
// This is already an IMDB ID, perfect for Stremio
|
// This is already an IMDB ID, perfect for Stremio
|
||||||
if (settings.enrichMetadataWithTMDB) {
|
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);
|
tmdbId = await withTimeout(tmdbService.findTMDBIdByIMDB(id), API_TIMEOUT);
|
||||||
} else {
|
} 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.
|
// 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
|
// 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;
|
const baseId = showIdStr && showIdStr !== id ? showIdStr : id;
|
||||||
stremioEpisodeId = `${baseId}:${seasonNum}:${episodeNum}`;
|
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 {
|
} else {
|
||||||
tmdbId = id;
|
tmdbId = id;
|
||||||
// If season/episode parsing failed (empty strings), use the raw episode 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;
|
const baseId = showIdStr && showIdStr !== id ? showIdStr : id;
|
||||||
stremioEpisodeId = `${baseId}:${seasonNum}:${episodeNum}`;
|
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
|
// Extract episode info from the episodeId for logging
|
||||||
const episodeQuery = `?s=${seasonNum}&e=${episodeNum}`;
|
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
|
// 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 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
|
// For other types (e.g. StreamsPPV), preserve the original type unless it's explicitly 'series' logic we want
|
||||||
const contentType = isCollection ? 'movie' : type;
|
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);
|
processStremioSource(contentType, stremioEpisodeId, true);
|
||||||
|
|
||||||
|
|
@ -2121,7 +2050,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
}, 60000);
|
}, 60000);
|
||||||
|
|
||||||
} catch (error) {
|
} 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
|
// Preserve the original error details for better error parsing
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Failed to load episode streams';
|
const errorMessage = error instanceof Error ? error.message : 'Failed to load episode streams';
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
|
|
@ -2187,7 +2116,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
// This will be handled by the StreamsScreen component
|
// This will be handled by the StreamsScreen component
|
||||||
// The useMetadata hook focuses on metadata and episodes
|
// The useMetadata hook focuses on metadata and episodes
|
||||||
} catch (error) {
|
} 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(() => {
|
useEffect(() => {
|
||||||
if (metadata && metadata.videos && metadata.videos.length > 0) {
|
if (metadata && metadata.videos && metadata.videos.length > 0) {
|
||||||
logger.log(`🎬 Metadata updated with ${metadata.videos.length} episodes, reloading series data`);
|
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)
|
// Also extract embedded streams from metadata videos (PPV-style addons)
|
||||||
extractEmbeddedStreams();
|
extractEmbeddedStreams();
|
||||||
}
|
}
|
||||||
|
|
@ -2214,7 +2143,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
|
|
||||||
const loadRecommendations = useCallback(async () => {
|
const loadRecommendations = useCallback(async () => {
|
||||||
if (!settings.enrichMetadataWithTMDB) {
|
if (!settings.enrichMetadataWithTMDB) {
|
||||||
if (__DEV__) console.log('[useMetadata] enrichment disabled; skip recommendations');
|
if (__DEV__) logger.log('[useMetadata] enrichment disabled; skip recommendations');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!tmdbId) return;
|
if (!tmdbId) return;
|
||||||
|
|
@ -2236,7 +2165,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
|
|
||||||
setRecommendations(formattedRecommendations);
|
setRecommendations(formattedRecommendations);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (__DEV__) console.error('Failed to load recommendations:', error);
|
if (__DEV__) logger.error('Failed to load recommendations:', error);
|
||||||
setRecommendations([]);
|
setRecommendations([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingRecommendations(false);
|
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,
|
// 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.
|
// because they're needed for Trakt scrobbling even when TMDB enrichment is disabled.
|
||||||
if (!settings.enrichMetadataWithTMDB && !isAnimeId) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2262,7 +2191,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
const tmdbSvc = TMDBService.getInstance();
|
const tmdbSvc = TMDBService.getInstance();
|
||||||
const fetchedTmdbId = await tmdbSvc.extractTMDBIdFromStremioId(id);
|
const fetchedTmdbId = await tmdbSvc.extractTMDBIdFromStremioId(id);
|
||||||
if (fetchedTmdbId) {
|
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);
|
setTmdbId(fetchedTmdbId);
|
||||||
|
|
||||||
// For anime IDs, also resolve the IMDb ID from TMDB external IDs so Trakt can scrobble
|
// 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 {
|
try {
|
||||||
const externalIds = await tmdbSvc.getShowExternalIds(fetchedTmdbId);
|
const externalIds = await tmdbSvc.getShowExternalIds(fetchedTmdbId);
|
||||||
if (externalIds?.imdb_id) {
|
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);
|
setImdbId(externalIds.imdb_id);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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) {
|
if (settings.tmdbEnrichCertification) {
|
||||||
const certification = await tmdbSvc.getCertification(normalizedType, fetchedTmdbId);
|
const certification = await tmdbSvc.getCertification(normalizedType, fetchedTmdbId);
|
||||||
if (certification) {
|
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 ? {
|
setMetadata(prev => prev ? {
|
||||||
...prev,
|
...prev,
|
||||||
tmdbId: fetchedTmdbId,
|
tmdbId: fetchedTmdbId,
|
||||||
certification
|
certification
|
||||||
} : null);
|
} : null);
|
||||||
} else {
|
} 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 {
|
} else {
|
||||||
// Just set the TMDB ID without certification
|
// Just set the TMDB ID without certification
|
||||||
setMetadata(prev => prev ? { ...prev, tmdbId: fetchedTmdbId } : null);
|
setMetadata(prev => prev ? { ...prev, tmdbId: fetchedTmdbId } : null);
|
||||||
}
|
}
|
||||||
} else {
|
} 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) {
|
} 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) {
|
if (tmdbId) {
|
||||||
// Check both master switch AND granular recommendations setting
|
// Check both master switch AND granular recommendations setting
|
||||||
if (settings.enrichMetadataWithTMDB && settings.tmdbEnrichRecommendations) {
|
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();
|
loadRecommendations();
|
||||||
}
|
}
|
||||||
// Reset recommendations when tmdbId changes
|
// Reset recommendations when tmdbId changes
|
||||||
|
|
@ -2343,32 +2272,32 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
const maybeAttachCertification = async () => {
|
const maybeAttachCertification = async () => {
|
||||||
// Check both master switch AND granular certification setting
|
// Check both master switch AND granular certification setting
|
||||||
if (!settings.enrichMetadataWithTMDB || !settings.tmdbEnrichCertification) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (!metadata) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (!tmdbId) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if ((metadata as any).certification) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const tmdbSvc = TMDBService.getInstance();
|
const tmdbSvc = TMDBService.getInstance();
|
||||||
const cert = await tmdbSvc.getCertification(normalizedType, tmdbId);
|
const cert = await tmdbSvc.getCertification(normalizedType, tmdbId);
|
||||||
if (cert) {
|
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);
|
setMetadata(prev => prev ? { ...prev, tmdbId, certification: cert } : prev);
|
||||||
} else {
|
} 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) {
|
} catch (err) {
|
||||||
if (__DEV__) console.error('[useMetadata] error attaching certification', err);
|
if (__DEV__) logger.error('[useMetadata] error attaching certification', err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
maybeAttachCertification();
|
maybeAttachCertification();
|
||||||
|
|
@ -2405,7 +2334,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
const tmdbService = TMDBService.getInstance();
|
const tmdbService = TMDBService.getInstance();
|
||||||
let productionInfo: any[] = [];
|
let productionInfo: any[] = [];
|
||||||
|
|
||||||
if (__DEV__) console.log('[useMetadata] fetchProductionInfo starting', {
|
if (__DEV__) logger.log('[useMetadata] fetchProductionInfo starting', {
|
||||||
contentKey,
|
contentKey,
|
||||||
type,
|
type,
|
||||||
tmdbId,
|
tmdbId,
|
||||||
|
|
@ -2423,7 +2352,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
const lang = settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en';
|
const lang = settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en';
|
||||||
const showDetails = await tmdbService.getTVShowDetails(tmdbId, lang);
|
const showDetails = await tmdbService.getTVShowDetails(tmdbId, lang);
|
||||||
if (showDetails) {
|
if (showDetails) {
|
||||||
if (__DEV__) console.log('[useMetadata] fetchProductionInfo got showDetails', {
|
if (__DEV__) logger.log('[useMetadata] fetchProductionInfo got showDetails', {
|
||||||
hasNetworks: !!showDetails.networks,
|
hasNetworks: !!showDetails.networks,
|
||||||
networksCount: showDetails.networks?.length || 0
|
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 lang = settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en';
|
||||||
const movieDetails = await tmdbService.getMovieDetails(String(tmdbId), lang);
|
const movieDetails = await tmdbService.getMovieDetails(String(tmdbId), lang);
|
||||||
if (movieDetails) {
|
if (movieDetails) {
|
||||||
if (__DEV__) console.log('[useMetadata] fetchProductionInfo got movieDetails', {
|
if (__DEV__) logger.log('[useMetadata] fetchProductionInfo got movieDetails', {
|
||||||
hasProductionCompanies: !!movieDetails.production_companies,
|
hasProductionCompanies: !!movieDetails.production_companies,
|
||||||
productionCompaniesCount: movieDetails.production_companies?.length || 0
|
productionCompaniesCount: movieDetails.production_companies?.length || 0
|
||||||
});
|
});
|
||||||
|
|
@ -2557,7 +2486,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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 {
|
return {
|
||||||
|
|
@ -2592,7 +2521,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (__DEV__) console.error('[useMetadata] Error fetching collection:', error);
|
if (__DEV__) logger.error('[useMetadata] Error fetching collection:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingCollection(false);
|
setLoadingCollection(false);
|
||||||
}
|
}
|
||||||
|
|
@ -2604,7 +2533,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
setMetadata((prev: any) => ({ ...prev, networks: productionInfo }));
|
setMetadata((prev: any) => ({ ...prev, networks: productionInfo }));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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
|
// Reset production info fetch tracking
|
||||||
productionInfoFetchedRef.current = null;
|
productionInfoFetchedRef.current = null;
|
||||||
|
|
||||||
if (__DEV__) console.log('[useMetadata] Component unmounted, memory cleaned up');
|
if (__DEV__) logger.log('[useMetadata] Component unmounted, memory cleaned up');
|
||||||
};
|
};
|
||||||
}, [cleanupStreams]);
|
}, [cleanupStreams]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -172,7 +172,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
||||||
// AI
|
// AI
|
||||||
aiChatEnabled: false,
|
aiChatEnabled: false,
|
||||||
// Metadata enrichment
|
// Metadata enrichment
|
||||||
enrichMetadataWithTMDB: true,
|
enrichMetadataWithTMDB: false,
|
||||||
useTmdbLocalizedMetadata: false,
|
useTmdbLocalizedMetadata: false,
|
||||||
// Granular TMDB enrichment controls (all enabled by default for backward compatibility)
|
// Granular TMDB enrichment controls (all enabled by default for backward compatibility)
|
||||||
tmdbEnrichCast: true,
|
tmdbEnrichCast: true,
|
||||||
|
|
|
||||||
|
|
@ -1941,10 +1941,8 @@ const ConditionalPostHogProvider: React.FC<{ children: React.ReactNode }> = ({ c
|
||||||
if (posthogRef.current) {
|
if (posthogRef.current) {
|
||||||
if (settings.analyticsEnabled) {
|
if (settings.analyticsEnabled) {
|
||||||
posthogRef.current.optIn();
|
posthogRef.current.optIn();
|
||||||
console.log('[Telemetry] PostHog opted in');
|
|
||||||
} else {
|
} else {
|
||||||
posthogRef.current.optOut();
|
posthogRef.current.optOut();
|
||||||
console.log('[Telemetry] PostHog opted out');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1996,10 +1994,8 @@ const PostHogOptController: React.FC<{
|
||||||
onPostHogReady(posthog);
|
onPostHogReady(posthog);
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
posthog.optIn();
|
posthog.optIn();
|
||||||
console.log('[Telemetry] PostHog opted in');
|
|
||||||
} else {
|
} else {
|
||||||
posthog.optOut();
|
posthog.optOut();
|
||||||
console.log('[Telemetry] PostHog opted out');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [enabled, posthog, onPostHogReady]);
|
}, [enabled, posthog, onPostHogReady]);
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,26 @@ import { useScrollToTop } from '../contexts/ScrollToTopContext';
|
||||||
const CATALOG_SETTINGS_KEY = 'catalog_settings';
|
const CATALOG_SETTINGS_KEY = 'catalog_settings';
|
||||||
const MAX_CONCURRENT_CATALOG_REQUESTS = 4;
|
const MAX_CONCURRENT_CATALOG_REQUESTS = 4;
|
||||||
const HOME_LOADING_SCREEN_TIMEOUT_MS = 5000;
|
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
|
// In-memory cache for catalog settings to avoid repeated MMKV reads
|
||||||
let cachedCatalogSettings: Record<string, boolean> | null = null;
|
let cachedCatalogSettings: Record<string, boolean> | null = null;
|
||||||
|
|
@ -134,6 +154,7 @@ const HomeScreen = () => {
|
||||||
const [catalogs, setCatalogs] = useState<(CatalogContent | null)[]>([]);
|
const [catalogs, setCatalogs] = useState<(CatalogContent | null)[]>([]);
|
||||||
const [catalogsLoading, setCatalogsLoading] = useState(true);
|
const [catalogsLoading, setCatalogsLoading] = useState(true);
|
||||||
const [loadedCatalogCount, setLoadedCatalogCount] = useState(0);
|
const [loadedCatalogCount, setLoadedCatalogCount] = useState(0);
|
||||||
|
const [pendingCatalogIndexes, setPendingCatalogIndexes] = useState<Record<number, boolean>>({});
|
||||||
const [hasAddons, setHasAddons] = useState<boolean | null>(null);
|
const [hasAddons, setHasAddons] = useState<boolean | null>(null);
|
||||||
const [hintVisible, setHintVisible] = useState(false);
|
const [hintVisible, setHintVisible] = useState(false);
|
||||||
const [loadingScreenTimedOut, setLoadingScreenTimedOut] = useState(false);
|
const [loadingScreenTimedOut, setLoadingScreenTimedOut] = useState(false);
|
||||||
|
|
@ -192,6 +213,7 @@ const HomeScreen = () => {
|
||||||
setCatalogsLoading(true);
|
setCatalogsLoading(true);
|
||||||
setCatalogs([]);
|
setCatalogs([]);
|
||||||
setLoadedCatalogCount(0);
|
setLoadedCatalogCount(0);
|
||||||
|
setPendingCatalogIndexes({});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check cache first
|
// Check cache first
|
||||||
|
|
@ -279,13 +301,17 @@ const HomeScreen = () => {
|
||||||
if (isEnabled) {
|
if (isEnabled) {
|
||||||
const currentIndex = catalogIndex;
|
const currentIndex = catalogIndex;
|
||||||
|
|
||||||
const catalogLoader = async () => {
|
const catalogLoader = async () => {
|
||||||
try {
|
try {
|
||||||
const manifest = manifestByAddonId.get(addon.id);
|
const manifest = manifestByAddonId.get(addon.id);
|
||||||
if (!manifest) return;
|
if (!manifest) return;
|
||||||
|
|
||||||
const metas = await stremioService.getCatalog(manifest, catalog.type, catalog.id, 1);
|
const metas = await withTimeout(
|
||||||
if (metas && metas.length > 0) {
|
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
|
// Aggressively limit items per catalog on Android to reduce memory usage
|
||||||
const limit = Platform.OS === 'android' ? 18 : 30;
|
const limit = Platform.OS === 'android' ? 18 : 30;
|
||||||
const limitedMetas = metas.slice(0, limit);
|
const limitedMetas = metas.slice(0, limit);
|
||||||
|
|
@ -344,6 +370,15 @@ const HomeScreen = () => {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (__DEV__) console.error(`[HomeScreen] Failed to load ${catalog.name} from ${addon.name}:`, error);
|
if (__DEV__) console.error(`[HomeScreen] Failed to load ${catalog.name} from ${addon.name}:`, error);
|
||||||
} finally {
|
} finally {
|
||||||
|
setPendingCatalogIndexes((prev) => {
|
||||||
|
if (!prev[currentIndex]) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[currentIndex];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
// Update loading count - ensure on main thread
|
// Update loading count - ensure on main thread
|
||||||
InteractionManager.runAfterInteractions(() => {
|
InteractionManager.runAfterInteractions(() => {
|
||||||
setLoadedCatalogCount(prev => {
|
setLoadedCatalogCount(prev => {
|
||||||
|
|
@ -362,8 +397,12 @@ const HomeScreen = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
catalogQueue.push(catalogLoader);
|
catalogQueue.push(catalogLoader);
|
||||||
catalogIndex++;
|
setPendingCatalogIndexes((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[currentIndex]: true,
|
||||||
|
}));
|
||||||
|
catalogIndex++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -722,7 +761,7 @@ const HomeScreen = () => {
|
||||||
catalogsToShow.forEach((catalog, index) => {
|
catalogsToShow.forEach((catalog, index) => {
|
||||||
if (catalog) {
|
if (catalog) {
|
||||||
data.push({ type: 'catalog', catalog, key: `${catalog.addon}-${catalog.id}-${index}` });
|
data.push({ type: 'catalog', catalog, key: `${catalog.addon}-${catalog.id}-${index}` });
|
||||||
} else {
|
} else if (catalogsLoading && pendingCatalogIndexes[index]) {
|
||||||
// Add a key for placeholders
|
// Add a key for placeholders
|
||||||
data.push({ type: 'placeholder', key: `placeholder-${index}` });
|
data.push({ type: 'placeholder', key: `placeholder-${index}` });
|
||||||
}
|
}
|
||||||
|
|
@ -734,7 +773,7 @@ const HomeScreen = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}, [hasAddons, catalogs, visibleCatalogCount, settings.showThisWeekSection]);
|
}, [hasAddons, catalogs, catalogsLoading, pendingCatalogIndexes, visibleCatalogCount, settings.showThisWeekSection]);
|
||||||
|
|
||||||
const handleLoadMoreCatalogs = useCallback(() => {
|
const handleLoadMoreCatalogs = useCallback(() => {
|
||||||
setVisibleCatalogCount(prev => Math.min(prev + 3, catalogs.length));
|
setVisibleCatalogCount(prev => Math.min(prev + 3, catalogs.length));
|
||||||
|
|
|
||||||
|
|
@ -1782,11 +1782,8 @@ const LibraryScreen = () => {
|
||||||
setFilter(filterType);
|
setFilter(filterType);
|
||||||
}}
|
}}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
{iconName && (
|
<Text
|
||||||
<MaterialIcons name={iconName} size={20} color={isActive ? currentTheme.colors.white : currentTheme.colors.mediumGray} style={styles.filterIcon} />
|
|
||||||
)}
|
|
||||||
<Text
|
|
||||||
style={[
|
style={[
|
||||||
styles.filterText,
|
styles.filterText,
|
||||||
{ color: currentTheme.colors.mediumGray },
|
{ color: currentTheme.colors.mediumGray },
|
||||||
|
|
@ -1901,11 +1898,11 @@ const LibraryScreen = () => {
|
||||||
style={styles.filtersContainer}
|
style={styles.filtersContainer}
|
||||||
contentContainerStyle={styles.filtersContent}
|
contentContainerStyle={styles.filtersContent}
|
||||||
>
|
>
|
||||||
{renderFilter('trakt', 'Trakt', 'pan-tool')}
|
{renderFilter('trakt', 'Trakt')}
|
||||||
{renderFilter('simkl', 'SIMKL', 'video-library')}
|
{renderFilter('simkl', 'SIMKL')}
|
||||||
{renderFilter('mal', 'MAL', 'book')}
|
{renderFilter('mal', 'MAL')}
|
||||||
{renderFilter('movies', t('search.movies'), 'movie')}
|
{renderFilter('movies', t('search.movies'))}
|
||||||
{renderFilter('series', t('search.tv_shows'), 'live-tv')}
|
{renderFilter('series', t('search.tv_shows'))}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,39 +26,19 @@ import { useTheme } from '../contexts/ThemeContext';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
import { RATING_PROVIDERS } from '../components/metadata/RatingsSection';
|
import { RATING_PROVIDERS } from '../components/metadata/RatingsSection';
|
||||||
import CustomAlert from '../components/CustomAlert'; // Moved CustomAlert import here
|
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;
|
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
|
// Create a styles creator function that accepts the theme colors
|
||||||
const createStyles = (colors: any) => StyleSheet.create({
|
const createStyles = (colors: any) => StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
|
|
|
||||||
|
|
@ -84,11 +84,19 @@ const BREAKPOINTS = {
|
||||||
const MemoizedRatingsSection = memo(RatingsSection);
|
const MemoizedRatingsSection = memo(RatingsSection);
|
||||||
const MemoizedCommentsSection = memo(CommentsSection);
|
const MemoizedCommentsSection = memo(CommentsSection);
|
||||||
const MemoizedCastDetailsModal = memo(CastDetailsModal);
|
const MemoizedCastDetailsModal = memo(CastDetailsModal);
|
||||||
|
const noop = (..._args: unknown[]) => {};
|
||||||
|
const logger = {
|
||||||
|
log: noop,
|
||||||
|
error: noop,
|
||||||
|
warn: noop,
|
||||||
|
info: noop,
|
||||||
|
debug: noop,
|
||||||
|
};
|
||||||
|
|
||||||
// ... other imports
|
// ... other imports
|
||||||
|
|
||||||
const MetadataScreen: React.FC = () => {
|
const MetadataScreen: React.FC = () => {
|
||||||
useEffect(() => { console.log('✅ MetadataScreen MOUNTED'); }, []);
|
useEffect(() => { logger.log('✅ MetadataScreen MOUNTED'); }, []);
|
||||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||||
const route = useRoute<RouteProp<RootStackParamList, 'Metadata'>>();
|
const route = useRoute<RouteProp<RootStackParamList, 'Metadata'>>();
|
||||||
const { id, type, episodeId, addonId } = route.params;
|
const { id, type, episodeId, addonId } = route.params;
|
||||||
|
|
@ -184,16 +192,16 @@ const MetadataScreen: React.FC = () => {
|
||||||
|
|
||||||
// Debug state changes
|
// Debug state changes
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
console.log('MetadataScreen: commentBottomSheetVisible changed to:', commentBottomSheetVisible);
|
logger.log('MetadataScreen: commentBottomSheetVisible changed to:', commentBottomSheetVisible);
|
||||||
}, [commentBottomSheetVisible]);
|
}, [commentBottomSheetVisible]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
console.log('MetadataScreen: selectedComment changed to:', selectedComment?.id);
|
logger.log('MetadataScreen: selectedComment changed to:', selectedComment?.id);
|
||||||
}, [selectedComment]);
|
}, [selectedComment]);
|
||||||
|
|
||||||
// Log useMetadata hook state changes for debugging
|
// Log useMetadata hook state changes for debugging
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
console.log('🔍 [MetadataScreen] useMetadata state:', {
|
logger.log('🔍 [MetadataScreen] useMetadata state:', {
|
||||||
loading,
|
loading,
|
||||||
hasMetadata: !!metadata,
|
hasMetadata: !!metadata,
|
||||||
metadataId: metadata?.id,
|
metadataId: metadata?.id,
|
||||||
|
|
@ -357,7 +365,7 @@ const MetadataScreen: React.FC = () => {
|
||||||
// Debug logging for color extraction timing
|
// Debug logging for color extraction timing
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (__DEV__ && heroImageUri && dominantColor) {
|
if (__DEV__ && heroImageUri && dominantColor) {
|
||||||
if (__DEV__) console.log('[MetadataScreen] Dynamic background color:', {
|
if (__DEV__) logger.log('[MetadataScreen] Dynamic background color:', {
|
||||||
dominantColor,
|
dominantColor,
|
||||||
fallback: currentTheme.colors.darkBackground,
|
fallback: currentTheme.colors.darkBackground,
|
||||||
finalColor: dynamicBackgroundColor,
|
finalColor: dynamicBackgroundColor,
|
||||||
|
|
@ -422,7 +430,7 @@ const MetadataScreen: React.FC = () => {
|
||||||
const isAuthenticated = await traktService.isAuthenticated();
|
const isAuthenticated = await traktService.isAuthenticated();
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
if (__DEV__) console.log(`[MetadataScreen] Not authenticated with Trakt`);
|
if (__DEV__) logger.log(`[MetadataScreen] Not authenticated with Trakt`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -449,7 +457,7 @@ const MetadataScreen: React.FC = () => {
|
||||||
if (relevantProgress.length === 0) return;
|
if (relevantProgress.length === 0) return;
|
||||||
|
|
||||||
// Log only essential progress information for performance
|
// 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
|
// Find most recent progress if multiple episodes
|
||||||
if (Object.keys(groupedEpisodes).length > 0 && relevantProgress.length > 1) {
|
if (Object.keys(groupedEpisodes).length > 0 && relevantProgress.length > 1) {
|
||||||
|
|
@ -458,12 +466,12 @@ const MetadataScreen: React.FC = () => {
|
||||||
)[0];
|
)[0];
|
||||||
|
|
||||||
if (mostRecent.episode && mostRecent.show) {
|
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) {
|
} 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]);
|
}, [shouldLoadSecondaryData, metadata, id, type]);
|
||||||
|
|
||||||
|
|
@ -495,7 +503,7 @@ const MetadataScreen: React.FC = () => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
const renderTime = Date.now() - startTime;
|
const renderTime = Date.now() - startTime;
|
||||||
if (renderTime > 100) {
|
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);
|
}, 0);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
|
|
@ -513,11 +521,11 @@ const MetadataScreen: React.FC = () => {
|
||||||
const totalMB = Math.round(memory.totalJSHeapSize / 1048576);
|
const totalMB = Math.round(memory.totalJSHeapSize / 1048576);
|
||||||
const limitMB = Math.round(memory.jsHeapSizeLimit / 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
|
// Trigger cleanup if memory usage is high
|
||||||
if (usedMB > limitMB * 0.8) {
|
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
|
// Force garbage collection if available
|
||||||
if (global.gc) {
|
if (global.gc) {
|
||||||
global.gc();
|
global.gc();
|
||||||
|
|
@ -536,16 +544,7 @@ const MetadataScreen: React.FC = () => {
|
||||||
// Memoized derived values for performance
|
// Memoized derived values for performance
|
||||||
const isReady = useMemo(() => !loading && metadata && !metadataError, [loading, metadata, metadataError]);
|
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
|
// Optimized content ready state management
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -627,13 +626,13 @@ const MetadataScreen: React.FC = () => {
|
||||||
const nextEpisodeId = isImdb
|
const nextEpisodeId = isImdb
|
||||||
? `${id}:${currentSeason || episodes[0]?.season_number || 1}:${currentEpisode + 1}`
|
? `${id}:${currentSeason || episodes[0]?.season_number || 1}:${currentEpisode + 1}`
|
||||||
: `${id}:${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));
|
const nextEpisodeExists = episodes.some(ep => ep.episode_number === (currentEpisode + 1));
|
||||||
if (nextEpisodeExists) {
|
if (nextEpisodeExists) {
|
||||||
if (__DEV__) console.log(`[MetadataScreen] Verified next episode exists`);
|
if (__DEV__) logger.log(`[MetadataScreen] Verified next episode exists`);
|
||||||
} else {
|
} else {
|
||||||
if (__DEV__) console.log(`[MetadataScreen] Warning: Next episode not found`);
|
if (__DEV__) logger.log(`[MetadataScreen] Warning: Next episode not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
targetEpisodeId = nextEpisodeId;
|
targetEpisodeId = nextEpisodeId;
|
||||||
|
|
@ -643,7 +642,7 @@ const MetadataScreen: React.FC = () => {
|
||||||
// Fallback logic: if not finished or nextEp not found
|
// Fallback logic: if not finished or nextEp not found
|
||||||
if (!targetEpisodeId) {
|
if (!targetEpisodeId) {
|
||||||
targetEpisodeId = watchProgress?.episodeId || episodeId || (episodes.length > 0 ? buildEpisodeId(episodes[0]) : undefined);
|
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) {
|
if (targetEpisodeId) {
|
||||||
|
|
@ -657,7 +656,7 @@ const MetadataScreen: React.FC = () => {
|
||||||
else if (epParts.length === 2 && isImdb) {
|
else if (epParts.length === 2 && isImdb) {
|
||||||
normalizedEpisodeId = `${id}:${epParts[0]}:${epParts[1]}`;
|
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 });
|
navigation.navigate('Streams', { id, type, episodeId: normalizedEpisodeId });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -671,14 +670,14 @@ const MetadataScreen: React.FC = () => {
|
||||||
fallbackEpisodeId = isImdb ? `${id}:${p[0]}:${p[1]}` : `${id}:${p[1]}`;
|
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.navigate('Streams', { id, type, episodeId: fallbackEpisodeId });
|
||||||
}, [navigation, id, type, episodes, episodeId, watchProgressData.watchProgress]);
|
}, [navigation, id, type, episodes, episodeId, watchProgressData.watchProgress]);
|
||||||
|
|
||||||
const handleEpisodeSelect = useCallback((episode: Episode) => {
|
const handleEpisodeSelect = useCallback((episode: Episode) => {
|
||||||
if (!isScreenFocused) return;
|
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;
|
let episodeId: string;
|
||||||
if (episode.stremioId) {
|
if (episode.stremioId) {
|
||||||
|
|
@ -716,15 +715,11 @@ const MetadataScreen: React.FC = () => {
|
||||||
}, [isScreenFocused]);
|
}, [isScreenFocused]);
|
||||||
|
|
||||||
const handleCommentPress = useCallback((comment: any) => {
|
const handleCommentPress = useCallback((comment: any) => {
|
||||||
console.log('MetadataScreen: handleCommentPress called with comment:', comment?.id);
|
|
||||||
if (!isScreenFocused) {
|
if (!isScreenFocused) {
|
||||||
console.log('MetadataScreen: Screen not focused, ignoring');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log('MetadataScreen: Setting selected comment and opening bottomsheet');
|
|
||||||
setSelectedComment(comment);
|
setSelectedComment(comment);
|
||||||
setCommentBottomSheetVisible(true);
|
setCommentBottomSheetVisible(true);
|
||||||
console.log('MetadataScreen: State should be updated now');
|
|
||||||
}, [isScreenFocused]);
|
}, [isScreenFocused]);
|
||||||
|
|
||||||
const handleCommentBottomSheetClose = useCallback(() => {
|
const handleCommentBottomSheetClose = useCallback(() => {
|
||||||
|
|
@ -756,8 +751,8 @@ const MetadataScreen: React.FC = () => {
|
||||||
|
|
||||||
// Ultra-optimized animated styles - minimal calculations with conditional updates
|
// Ultra-optimized animated styles - minimal calculations with conditional updates
|
||||||
const containerStyle = useAnimatedStyle(() => ({
|
const containerStyle = useAnimatedStyle(() => ({
|
||||||
opacity: isScreenFocused ? animations.screenOpacity.value : 0.8,
|
opacity: animations.screenOpacity.value,
|
||||||
}), [isScreenFocused]);
|
}), []);
|
||||||
|
|
||||||
const contentStyle = useAnimatedStyle(() => ({
|
const contentStyle = useAnimatedStyle(() => ({
|
||||||
opacity: animations.contentOpacity.value,
|
opacity: animations.contentOpacity.value,
|
||||||
|
|
@ -774,7 +769,6 @@ const MetadataScreen: React.FC = () => {
|
||||||
|
|
||||||
// Parse error to extract code and user-friendly message
|
// Parse error to extract code and user-friendly message
|
||||||
const parseError = (error: string) => {
|
const parseError = (error: string) => {
|
||||||
console.log('🔍 Parsing error in MetadataScreen:', error);
|
|
||||||
|
|
||||||
// Check for HTTP status codes - handle multiple formats
|
// Check for HTTP status codes - handle multiple formats
|
||||||
// Match patterns like: "status code 500", "status": 500, "Request failed with status code 500"
|
// Match patterns like: "status code 500", "status": 500, "Request failed with status code 500"
|
||||||
|
|
@ -785,7 +779,6 @@ const MetadataScreen: React.FC = () => {
|
||||||
|
|
||||||
if (statusCodeMatch) {
|
if (statusCodeMatch) {
|
||||||
const code = parseInt(statusCodeMatch[1]);
|
const code = parseInt(statusCodeMatch[1]);
|
||||||
console.log('✅ Found status code:', code);
|
|
||||||
switch (code) {
|
switch (code) {
|
||||||
case 404:
|
case 404:
|
||||||
return { code: '404', message: t('metadata.content_not_found'), userMessage: t('metadata.content_not_found_desc') };
|
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
|
// Show error if exists
|
||||||
if (metadataError || (!loading && !metadata)) {
|
if (metadataError || (!loading && !metadata)) {
|
||||||
console.log('❌ MetadataScreen ERROR state:', { metadataError, loading, hasMetadata: !!metadata });
|
|
||||||
return ErrorComponent;
|
return ErrorComponent;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show loading screen if metadata is not yet available or exit animation hasn't completed
|
// Show loading screen if metadata is not yet available or exit animation hasn't completed
|
||||||
if (loading || !isContentReady || !loadingScreenExited) {
|
if (loading || !isContentReady || !loadingScreenExited) {
|
||||||
console.log('⏳ MetadataScreen LOADING state:', { loading, isContentReady, loadingScreenExited, hasMetadata: !!metadata });
|
|
||||||
return (
|
return (
|
||||||
<MetadataLoadingScreen
|
<MetadataLoadingScreen
|
||||||
ref={loadingScreenRef}
|
ref={loadingScreenRef}
|
||||||
|
|
@ -1637,4 +1628,4 @@ const styles = StyleSheet.create({
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default MetadataScreen;
|
export default MetadataScreen;
|
||||||
|
|
|
||||||
|
|
@ -46,10 +46,10 @@ import {
|
||||||
MAX_RECENT_SEARCHES,
|
MAX_RECENT_SEARCHES,
|
||||||
} from '../components/search/searchUtils';
|
} from '../components/search/searchUtils';
|
||||||
import { searchStyles as styles } from '../components/search/searchStyles';
|
import { searchStyles as styles } from '../components/search/searchStyles';
|
||||||
import { SearchAnimation } from '../components/search/SearchAnimation';
|
|
||||||
import { AddonSection } from '../components/search/AddonSection';
|
import { AddonSection } from '../components/search/AddonSection';
|
||||||
import { DiscoverSection } from '../components/search/DiscoverSection';
|
import { DiscoverSection } from '../components/search/DiscoverSection';
|
||||||
import { DiscoverBottomSheets } from '../components/search/DiscoverBottomSheets';
|
import { DiscoverBottomSheets } from '../components/search/DiscoverBottomSheets';
|
||||||
|
import LoadingSpinner from '../components/common/LoadingSpinner';
|
||||||
|
|
||||||
const { width } = Dimensions.get('window');
|
const { width } = Dimensions.get('window');
|
||||||
const AnimatedTouchable = Animated.createAnimatedComponent(TouchableOpacity);
|
const AnimatedTouchable = Animated.createAnimatedComponent(TouchableOpacity);
|
||||||
|
|
@ -761,7 +761,9 @@ const SearchScreen = () => {
|
||||||
|
|
||||||
<View style={styles.contentContainer}>
|
<View style={styles.contentContainer}>
|
||||||
{searching && results.byAddon.length === 0 ? (
|
{searching && results.byAddon.length === 0 ? (
|
||||||
<SearchAnimation />
|
<View style={styles.emptyContainer}>
|
||||||
|
<LoadingSpinner size="large" />
|
||||||
|
</View>
|
||||||
) : searched && !hasResultsToShow && !searching ? (
|
) : searched && !hasResultsToShow && !searching ? (
|
||||||
<View style={styles.emptyContainer}>
|
<View style={styles.emptyContainer}>
|
||||||
<MaterialIcons name="search-off" size={64} color={currentTheme.colors.lightGray} />
|
<MaterialIcons name="search-off" size={64} color={currentTheme.colors.lightGray} />
|
||||||
|
|
|
||||||
|
|
@ -59,16 +59,12 @@ class CampaignService {
|
||||||
try {
|
try {
|
||||||
const now = Date.now();
|
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) {
|
if (this.campaignQueue.length > 0 && (now - this.lastFetch) < this.CACHE_TTL) {
|
||||||
console.log('[CampaignService] Using cached campaigns');
|
|
||||||
return this.getNextValidCampaign();
|
return this.getNextValidCampaign();
|
||||||
}
|
}
|
||||||
|
|
||||||
const platform = Platform.OS;
|
const platform = Platform.OS;
|
||||||
const url = `${CAMPAIGN_API_URL}/api/campaigns/queue?platform=${platform}`;
|
const url = `${CAMPAIGN_API_URL}/api/campaigns/queue?platform=${platform}`;
|
||||||
console.log('[CampaignService] Fetching from:', url);
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${CAMPAIGN_API_URL}/api/campaigns/queue?platform=${platform}`,
|
`${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.campaignQueue = campaigns;
|
||||||
this.currentIndex = 0;
|
this.currentIndex = 0;
|
||||||
this.lastFetch = now;
|
this.lastFetch = now;
|
||||||
|
|
||||||
const result = this.getNextValidCampaign();
|
const result = this.getNextValidCampaign();
|
||||||
console.log('[CampaignService] Next valid campaign:', result?.id, result?.type);
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[CampaignService] Error fetching campaigns:', error);
|
console.warn('[CampaignService] Error fetching campaigns:', error);
|
||||||
|
|
|
||||||
84
src/services/catalog/catalog-utils.ts
Normal file
84
src/services/catalog/catalog-utils.ts
Normal 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');
|
||||||
|
}
|
||||||
166
src/services/catalog/content-details.ts
Normal file
166
src/services/catalog/content-details.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
157
src/services/catalog/content-mappers.ts
Normal file
157
src/services/catalog/content-mappers.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
398
src/services/catalog/discovery.ts
Normal file
398
src/services/catalog/discovery.ts
Normal 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
330
src/services/catalog/library.ts
Normal file
330
src/services/catalog/library.ts
Normal 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;
|
||||||
|
}
|
||||||
411
src/services/catalog/search.ts
Normal file
411
src/services/catalog/search.ts
Normal 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}`;
|
||||||
|
}
|
||||||
154
src/services/catalog/types.ts
Normal file
154
src/services/catalog/types.ts
Normal 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
31
src/services/mdblistConstants.ts
Normal file
31
src/services/mdblistConstants.ts
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -3,8 +3,7 @@ import { logger } from '../utils/logger';
|
||||||
import {
|
import {
|
||||||
MDBLIST_API_KEY_STORAGE_KEY,
|
MDBLIST_API_KEY_STORAGE_KEY,
|
||||||
MDBLIST_ENABLED_STORAGE_KEY,
|
MDBLIST_ENABLED_STORAGE_KEY,
|
||||||
isMDBListEnabled
|
} from './mdblistConstants';
|
||||||
} from '../screens/MDBListSettingsScreen';
|
|
||||||
|
|
||||||
export interface MDBListRatings {
|
export interface MDBListRatings {
|
||||||
trakt?: number;
|
trakt?: number;
|
||||||
|
|
|
||||||
456
src/services/stremio/StremioService.ts
Normal file
456
src/services/stremio/StremioService.ts
Normal 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;
|
||||||
449
src/services/stremio/addon-management.ts
Normal file
449
src/services/stremio/addon-management.ts
Normal 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.
|
||||||
|
}
|
||||||
|
}
|
||||||
112
src/services/stremio/addon-order.ts
Normal file
112
src/services/stremio/addon-order.ts
Normal 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;
|
||||||
|
}
|
||||||
423
src/services/stremio/catalog-operations.ts
Normal file
423
src/services/stremio/catalog-operations.ts
Normal 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;
|
||||||
|
}
|
||||||
39
src/services/stremio/context.ts
Normal file
39
src/services/stremio/context.ts
Normal 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>;
|
||||||
|
}
|
||||||
9
src/services/stremio/events.ts
Normal file
9
src/services/stremio/events.ts
Normal 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;
|
||||||
391
src/services/stremio/stream-operations.ts
Normal file
391
src/services/stremio/stream-operations.ts
Normal 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;
|
||||||
|
}
|
||||||
126
src/services/stremio/subtitle-operations.ts
Normal file
126
src/services/stremio/subtitle-operations.ts
Normal 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;
|
||||||
|
}
|
||||||
236
src/services/stremio/types.ts
Normal file
236
src/services/stremio/types.ts
Normal 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
|
|
@ -148,7 +148,6 @@ class TelemetryService {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.initialized = true;
|
this.initialized = true;
|
||||||
console.log('[TelemetryService] Initialized with settings:', this.settings);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[TelemetryService] Error initializing:', error);
|
console.error('[TelemetryService] Error initializing:', error);
|
||||||
// Use defaults on error
|
// Use defaults on error
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ class Logger {
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// __DEV__ is a global variable in React Native
|
// __DEV__ is a global variable in React Native
|
||||||
this.isEnabled = __DEV__;
|
this.isEnabled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
log(...args: any[]) {
|
log(...args: any[]) {
|
||||||
|
|
@ -37,4 +37,4 @@ class Logger {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const logger = new Logger();
|
export const logger = new Logger();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue