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