From 60cdf9fe865e792dd4bee341aa95ee10b9ebb5fb Mon Sep 17 00:00:00 2001 From: tapframe Date: Mon, 15 Dec 2025 15:37:04 +0530 Subject: [PATCH] added filter for catalogs --- .gitignore | 1 + ios/Nuvio.xcodeproj/project.pbxproj | 6 +- ios/Nuvio/Info.plist | 200 ++++++------ ios/Nuvio/Supporting/Expo.plist | 2 +- ios/Podfile.properties.json | 2 +- .../loading/MetadataLoadingScreen.tsx | 3 +- src/hooks/useTraktAutosync.ts | 125 +++++-- src/navigation/AppNavigator.tsx | 25 +- src/screens/CatalogScreen.tsx | 250 ++++++++++---- src/screens/DebridIntegrationScreen.tsx | 2 +- src/services/stremioService.ts | 309 +++++++++++++++--- src/services/traktService.ts | 116 +++++-- src/types/metadata.ts | 66 +++- src/types/streams.ts | 89 +++-- 14 files changed, 882 insertions(+), 314 deletions(-) diff --git a/.gitignore b/.gitignore index f857a69..3736957 100644 --- a/.gitignore +++ b/.gitignore @@ -85,3 +85,4 @@ node_modules expofs.md ios/sentry.properties android/sentry.properties +Stremio addons refer \ No newline at end of file diff --git a/ios/Nuvio.xcodeproj/project.pbxproj b/ios/Nuvio.xcodeproj/project.pbxproj index 28d1c83..c83f212 100644 --- a/ios/Nuvio.xcodeproj/project.pbxproj +++ b/ios/Nuvio.xcodeproj/project.pbxproj @@ -475,7 +475,7 @@ ); OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app; - PRODUCT_NAME = Nuvio; + PRODUCT_NAME = "Nuvio"; SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -506,8 +506,8 @@ "-lc++", ); OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; - PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.hub; - PRODUCT_NAME = Nuvio; + PRODUCT_BUNDLE_IDENTIFIER = "com.nuvio.app"; + PRODUCT_NAME = "Nuvio"; SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/ios/Nuvio/Info.plist b/ios/Nuvio/Info.plist index 269c631..619dacd 100644 --- a/ios/Nuvio/Info.plist +++ b/ios/Nuvio/Info.plist @@ -1,103 +1,103 @@ - - CADisableMinimumFrameDurationOnPhone - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Nuvio - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - 1.2.11 - CFBundleSignature - ???? - CFBundleURLTypes - - - CFBundleURLSchemes - - nuvio - com.nuvio.app - - - - CFBundleURLSchemes - - exp+nuvio - - - - CFBundleVersion - 26 - LSMinimumSystemVersion - 12.0 - LSRequiresIPhoneOS - - LSSupportsOpeningDocumentsInPlace - - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - - NSBonjourServices - - _http._tcp - _googlecast._tcp - _CC1AD845._googlecast._tcp - - NSLocalNetworkUsageDescription - Allow $(PRODUCT_NAME) to access your local network - NSMicrophoneUsageDescription - This app does not require microphone access. - RCTNewArchEnabled - - RCTRootViewBackgroundColor - 4278322180 - UIBackgroundModes - - audio - - UIFileSharingEnabled - - UILaunchStoryboardName - SplashScreen - UIRequiredDeviceCapabilities - - arm64 - - UIRequiresFullScreen - - UIStatusBarStyle - UIStatusBarStyleDefault - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIUserInterfaceStyle - Dark - UIViewControllerBasedStatusBarAppearance - - - + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Nuvio + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.2.11 + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleURLSchemes + + nuvio + com.nuvio.app + + + + CFBundleURLSchemes + + exp+nuvio + + + + CFBundleVersion + 26 + LSMinimumSystemVersion + 12.0 + LSRequiresIPhoneOS + + LSSupportsOpeningDocumentsInPlace + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSBonjourServices + + _http._tcp + _googlecast._tcp + _CC1AD845._googlecast._tcp + + NSLocalNetworkUsageDescription + Allow $(PRODUCT_NAME) to access your local network + NSMicrophoneUsageDescription + This app does not require microphone access. + RCTNewArchEnabled + + RCTRootViewBackgroundColor + 4278322180 + UIBackgroundModes + + audio + + UIFileSharingEnabled + + UILaunchStoryboardName + SplashScreen + UIRequiredDeviceCapabilities + + arm64 + + UIRequiresFullScreen + + UIStatusBarStyle + UIStatusBarStyleDefault + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIUserInterfaceStyle + Dark + UIViewControllerBasedStatusBarAppearance + + + \ No newline at end of file diff --git a/ios/Nuvio/Supporting/Expo.plist b/ios/Nuvio/Supporting/Expo.plist index c7cf5f8..acc66a1 100644 --- a/ios/Nuvio/Supporting/Expo.plist +++ b/ios/Nuvio/Supporting/Expo.plist @@ -9,7 +9,7 @@ EXUpdatesLaunchWaitMs 30000 EXUpdatesRuntimeVersion - 1.2.10 + 1.2.11 EXUpdatesURL https://grim-reyna-tapframe-69970143.koyeb.app/api/manifest diff --git a/ios/Podfile.properties.json b/ios/Podfile.properties.json index 3e0f2e7..417e2e5 100644 --- a/ios/Podfile.properties.json +++ b/ios/Podfile.properties.json @@ -2,4 +2,4 @@ "expo.jsEngine": "hermes", "EX_DEV_CLIENT_NETWORK_INSPECTOR": "true", "newArchEnabled": "true" -} \ No newline at end of file +} diff --git a/src/components/loading/MetadataLoadingScreen.tsx b/src/components/loading/MetadataLoadingScreen.tsx index 426d745..75a2434 100644 --- a/src/components/loading/MetadataLoadingScreen.tsx +++ b/src/components/loading/MetadataLoadingScreen.tsx @@ -19,6 +19,7 @@ import Animated, { interpolate, cancelAnimation, runOnJS, + SharedValue, } from 'react-native-reanimated'; import { useTheme } from '../../contexts/ThemeContext'; @@ -59,7 +60,7 @@ const ShimmerSkeleton = ({ marginBottom?: number; style?: any; delay?: number; - shimmerProgress: Animated.SharedValue; + shimmerProgress: SharedValue; baseColor: string; highlightColor: string; }) => { diff --git a/src/hooks/useTraktAutosync.ts b/src/hooks/useTraktAutosync.ts index fa181e1..4200e1c 100644 --- a/src/hooks/useTraktAutosync.ts +++ b/src/hooks/useTraktAutosync.ts @@ -29,9 +29,9 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { stopWatching, stopWatchingImmediate } = useTraktIntegration(); - + const { settings: autosyncSettings } = useTraktAutosyncSettings(); - + const hasStartedWatching = useRef(false); const hasStopped = useRef(false); // New: Track if we've already stopped for this session const isSessionComplete = useRef(false); // New: Track if session is completely finished (scrobbled) @@ -41,66 +41,106 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { const sessionKey = useRef(null); const unmountCount = useRef(0); const lastStopCall = useRef(0); // New: Track last stop call timestamp - + // Generate a unique session key for this content instance useEffect(() => { const contentKey = options.type === 'movie' ? `movie:${options.imdbId}` : `episode:${options.showImdbId || options.imdbId}:${options.season}:${options.episode}`; sessionKey.current = `${contentKey}:${Date.now()}`; - + // Reset all session state for new content hasStartedWatching.current = false; hasStopped.current = false; isSessionComplete.current = false; isUnmounted.current = false; // Reset unmount flag for new mount lastStopCall.current = 0; - + logger.log(`[TraktAutosync] Session started for: ${sessionKey.current}`); - + return () => { unmountCount.current++; isUnmounted.current = true; // Mark as unmounted to prevent post-unmount operations logger.log(`[TraktAutosync] Component unmount #${unmountCount.current} for: ${sessionKey.current}`); }; }, [options.imdbId, options.season, options.episode, options.type]); - + // Build Trakt content data from options - const buildContentData = useCallback((): TraktContentData => { - // Ensure year is a number and valid - const parseYear = (year: number | string | undefined): number => { - if (!year) return 0; - if (typeof year === 'number') return year; + // Returns null if required fields are missing or invalid + const buildContentData = useCallback((): TraktContentData | null => { + // Parse and validate year - returns undefined for invalid/missing years + const parseYear = (year: number | string | undefined): number | undefined => { + if (year === undefined || year === null || year === '') return undefined; + if (typeof year === 'number') { + // Year must be a reasonable value (between 1800 and current year + 10) + const currentYear = new Date().getFullYear(); + if (year <= 0 || year < 1800 || year > currentYear + 10) { + logger.warn(`[TraktAutosync] Invalid year value: ${year}`); + return undefined; + } + return year; + } const parsed = parseInt(year.toString(), 10); - return isNaN(parsed) ? 0 : parsed; + if (isNaN(parsed) || parsed <= 0) { + logger.warn(`[TraktAutosync] Failed to parse year: ${year}`); + return undefined; + } + // Validate parsed year range + const currentYear = new Date().getFullYear(); + if (parsed < 1800 || parsed > currentYear + 10) { + logger.warn(`[TraktAutosync] Year out of valid range: ${parsed}`); + return undefined; + } + return parsed; }; - + + // Validate required fields early + if (!options.title || options.title.trim() === '') { + logger.error('[TraktAutosync] Cannot build content data: missing or empty title'); + return null; + } + + if (!options.imdbId || options.imdbId.trim() === '') { + logger.error('[TraktAutosync] Cannot build content data: missing or empty imdbId'); + return null; + } + const numericYear = parseYear(options.year); const numericShowYear = parseYear(options.showYear); - - // Validate required fields - if (!options.title || !options.imdbId) { - logger.warn('[TraktAutosync] Missing required fields:', { title: options.title, imdbId: options.imdbId }); + + // Log warning if year is missing (but don't fail - Trakt can sometimes work with IMDb ID alone) + if (numericYear === undefined) { + logger.warn('[TraktAutosync] Year is missing or invalid, proceeding without year'); } - + if (options.type === 'movie') { return { type: 'movie', - imdbId: options.imdbId, - title: options.title, - year: numericYear + imdbId: options.imdbId.trim(), + title: options.title.trim(), + year: numericYear // Can be undefined now }; } else { + // For episodes, also validate season and episode numbers + if (options.season === undefined || options.season === null || options.season < 0) { + logger.error('[TraktAutosync] Cannot build episode content data: invalid season'); + return null; + } + if (options.episode === undefined || options.episode === null || options.episode < 0) { + logger.error('[TraktAutosync] Cannot build episode content data: invalid episode'); + return null; + } + return { type: 'episode', - imdbId: options.imdbId, - title: options.title, + imdbId: options.imdbId.trim(), + title: options.title.trim(), year: numericYear, season: options.season, episode: options.episode, - showTitle: options.showTitle || options.title, + showTitle: (options.showTitle || options.title).trim(), showYear: numericShowYear || numericYear, - showImdbId: options.showImdbId || options.imdbId + showImdbId: (options.showImdbId || options.imdbId).trim() }; } }, [options]); @@ -143,7 +183,13 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { const rawProgress = (currentTime / duration) * 100; const progressPercent = Math.min(100, Math.max(0, rawProgress)); const contentData = buildContentData(); - + + // Skip if content data is invalid + if (!contentData) { + logger.warn('[TraktAutosync] Skipping start: invalid content data'); + return; + } + const success = await startWatching(contentData, progressPercent); if (success) { hasStartedWatching.current = true; @@ -184,6 +230,10 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { if (force) { // IMMEDIATE: User action (pause/unpause) - bypass queue const contentData = buildContentData(); + if (!contentData) { + logger.warn('[TraktAutosync] Skipping progress update: invalid content data'); + return; + } success = await updateProgressImmediate(contentData, progressPercent); if (success) { @@ -212,6 +262,10 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { } const contentData = buildContentData(); + if (!contentData) { + logger.warn('[TraktAutosync] Skipping progress update: invalid content data'); + return; + } success = await updateProgress(contentData, progressPercent, force); if (success) { @@ -335,9 +389,11 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { // If we have valid progress but no started session, force start one first if (!hasStartedWatching.current && progressPercent > 1) { const contentData = buildContentData(); - const success = await startWatching(contentData, progressPercent); - if (success) { - hasStartedWatching.current = true; + if (contentData) { + const success = await startWatching(contentData, progressPercent); + if (success) { + hasStartedWatching.current = true; + } } } @@ -356,6 +412,13 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { const contentData = buildContentData(); + // Skip if content data is invalid + if (!contentData) { + logger.warn('[TraktAutosync] Skipping stop: invalid content data'); + hasStopped.current = false; // Allow retry with valid data + return; + } + // IMMEDIATE: Use immediate method for user-initiated closes, regular method for natural ends const success = useImmediate ? await stopWatchingImmediate(contentData, progressPercent) @@ -394,7 +457,7 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { { forceNotify: true } ); } - } catch {} + } catch { } } logger.log(`[TraktAutosync] ${useImmediate ? 'IMMEDIATE: ' : ''}Successfully stopped watching: ${contentData.title} (${progressPercent.toFixed(1)}% - ${reason})`); diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index f750139..ef1e395 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -71,6 +71,16 @@ import ContinueWatchingSettingsScreen from '../screens/ContinueWatchingSettingsS import ContributorsScreen from '../screens/ContributorsScreen'; import DebridIntegrationScreen from '../screens/DebridIntegrationScreen'; +// Optional Android immersive mode module +let RNImmersiveMode: any = null; +if (Platform.OS === 'android') { + try { + RNImmersiveMode = require('react-native-immersive-mode').default; + } catch { + RNImmersiveMode = null; + } +} + // Stack navigator types export type RootStackParamList = { Onboarding: undefined; @@ -91,9 +101,18 @@ export type RootStackParamList = { Streams: { id: string; type: string; + title?: string; episodeId?: string; episodeThumbnail?: string; fromPlayer?: boolean; + metadata?: { + poster?: string; + banner?: string; + releaseInfo?: string; + genres?: string[]; + }; + resumeTime?: number; + duration?: number; }; PlayerIOS: { uri: string; @@ -1066,8 +1085,10 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta if (Platform.OS === 'android') { // Ensure system navigation bar is shown by default try { - RNImmersiveMode.setBarMode('Normal'); - RNImmersiveMode.fullLayout(false); + if (RNImmersiveMode) { + RNImmersiveMode.setBarMode('Normal'); + RNImmersiveMode.fullLayout(false); + } } catch (error) { console.log('Immersive mode error:', error); } diff --git a/src/screens/CatalogScreen.tsx b/src/screens/CatalogScreen.tsx index aa74637..a29f99f 100644 --- a/src/screens/CatalogScreen.tsx +++ b/src/screens/CatalogScreen.tsx @@ -10,13 +10,14 @@ import { RefreshControl, Dimensions, Platform, - InteractionManager + InteractionManager, + ScrollView } from 'react-native'; import { FlashList } from '@shopify/flash-list'; import { RouteProp } from '@react-navigation/native'; import { StackNavigationProp } from '@react-navigation/stack'; import { RootStackParamList } from '../navigation/AppNavigator'; -import { Meta, stremioService } from '../services/stremioService'; +import { Meta, stremioService, CatalogExtra } from '../services/stremioService'; import { useTheme } from '../contexts/ThemeContext'; import FastImage from '@d11/react-native-fast-image'; import { BlurView } from 'expo-blur'; @@ -65,11 +66,11 @@ const calculateCatalogLayout = (screenWidth: number) => { // Increase padding and spacing on larger screens for proper breathing room const HORIZONTAL_PADDING = screenWidth >= 1600 ? SPACING.xl * 4 : screenWidth >= 1200 ? SPACING.xl * 3 : screenWidth >= 1000 ? SPACING.xl * 2 : SPACING.lg * 2; const ITEM_SPACING = screenWidth >= 1600 ? SPACING.xl : screenWidth >= 1200 ? SPACING.lg : screenWidth >= 1000 ? SPACING.md : SPACING.sm; - + // Calculate how many columns can fit const availableWidth = screenWidth - HORIZONTAL_PADDING; const maxColumns = Math.floor(availableWidth / (MIN_ITEM_WIDTH + ITEM_SPACING)); - + // More flexible column limits for different screen sizes let numColumns; if (screenWidth < 600) { @@ -88,14 +89,14 @@ const calculateCatalogLayout = (screenWidth: number) => { // Ultra-wide: 6-10 columns numColumns = Math.min(Math.max(maxColumns, 6), 10); } - + // Calculate actual item width with proper spacing const totalSpacing = ITEM_SPACING * (numColumns - 1); const itemWidth = (availableWidth - totalSpacing) / numColumns; - + // Ensure item width doesn't exceed maximum const finalItemWidth = Math.floor(Math.min(itemWidth, MAX_ITEM_WIDTH)); - + return { numColumns, itemWidth: finalItemWidth, @@ -154,7 +155,7 @@ const createStyles = (colors: any) => StyleSheet.create({ }, poster: { width: '100%', - aspectRatio: 2/3, + aspectRatio: 2 / 3, borderTopLeftRadius: 12, borderTopRightRadius: 12, backgroundColor: colors.elevation3, @@ -230,7 +231,38 @@ const createStyles = (colors: any) => StyleSheet.create({ fontSize: 11, fontWeight: '600', color: colors.white, - } + }, + // Filter chip bar styles + filterContainer: { + paddingHorizontal: 16, + paddingTop: 4, + paddingBottom: 12, + }, + filterScrollContent: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + filterChip: { + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 20, + backgroundColor: colors.elevation3, + borderWidth: 1, + borderColor: colors.elevation3, + }, + filterChipActive: { + backgroundColor: colors.primary + '30', + borderColor: colors.primary, + }, + filterChipText: { + fontSize: 13, + fontWeight: '500', + color: colors.mediumGray, + }, + filterChipTextActive: { + color: colors.primary, + }, }); const CatalogScreen: React.FC = ({ route, navigation }) => { @@ -253,6 +285,10 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { }); const [mobileColumnsPref, setMobileColumnsPref] = useState<'auto' | 2 | 3>('auto'); const [nowPlayingMovies, setNowPlayingMovies] = useState>(new Set()); + // Filter state for catalog extra properties per protocol + const [catalogExtras, setCatalogExtras] = useState([]); + const [selectedFilters, setSelectedFilters] = useState>({}); + const [activeGenreFilter, setActiveGenreFilter] = useState(genreFilter); const { currentTheme } = useTheme(); const colors = currentTheme.colors; const styles = createStyles(colors); @@ -266,7 +302,7 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { if (pref === '2') setMobileColumnsPref(2); else if (pref === '3') setMobileColumnsPref(3); else setMobileColumnsPref('auto'); - } catch {} + } catch { } })(); }, []); @@ -284,52 +320,63 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { }, []); const { getCustomName, isLoadingCustomNames } = useCustomCatalogNames(); - + // Create display name with proper type suffix const createDisplayName = (catalogName: string) => { if (!catalogName) return ''; - + // Check if the name already includes content type indicators const lowerName = catalogName.toLowerCase(); const contentType = type === 'movie' ? 'Movies' : type === 'series' ? 'TV Shows' : `${type.charAt(0).toUpperCase() + type.slice(1)}s`; - + // If the name already contains type information, return as is if (lowerName.includes('movie') || lowerName.includes('tv') || lowerName.includes('show') || lowerName.includes('series')) { return catalogName; } - + // Otherwise append the content type return `${catalogName} ${contentType}`; }; - - // Use actual catalog name if available, otherwise fallback to custom name or original name - const displayName = actualCatalogName - ? getCustomName(addonId || '', type || '', id || '', createDisplayName(actualCatalogName)) - : getCustomName(addonId || '', type || '', id || '', originalName ? createDisplayName(originalName) : '') || - (genreFilter ? `${genreFilter} ${type === 'movie' ? 'Movies' : 'TV Shows'}` : - `${type.charAt(0).toUpperCase() + type.slice(1)}s`); - // Add effect to get the actual catalog name from addon manifest + // Use actual catalog name if available, otherwise fallback to custom name or original name + const displayName = actualCatalogName + ? getCustomName(addonId || '', type || '', id || '', createDisplayName(actualCatalogName)) + : getCustomName(addonId || '', type || '', id || '', originalName ? createDisplayName(originalName) : '') || + (genreFilter ? `${genreFilter} ${type === 'movie' ? 'Movies' : 'TV Shows'}` : + `${type.charAt(0).toUpperCase() + type.slice(1)}s`); + + // Add effect to get the actual catalog name and filter extras from addon manifest useEffect(() => { - const getActualCatalogName = async () => { + const getCatalogDetails = async () => { if (addonId && type && id) { try { const manifests = await stremioService.getInstalledAddonsAsync(); const addon = manifests.find(a => a.id === addonId); - + if (addon && addon.catalogs) { const catalog = addon.catalogs.find(c => c.type === type && c.id === id); - if (catalog && catalog.name) { - setActualCatalogName(catalog.name); + if (catalog) { + if (catalog.name) { + setActualCatalogName(catalog.name); + } + // Extract filter extras per protocol (genre, etc.) + if (catalog.extra && Array.isArray(catalog.extra)) { + // Only show filterable extras with options (not search/skip) + const filterableExtras = catalog.extra.filter( + extra => extra.options && extra.options.length > 0 && extra.name !== 'skip' + ); + setCatalogExtras(filterableExtras); + logger.log('[CatalogScreen] Loaded catalog extras:', filterableExtras.map(e => e.name)); + } } } } catch (error) { - logger.error('Failed to get actual catalog name:', error); + logger.error('Failed to get catalog details:', error); } } }; - - getActualCatalogName(); + + getCatalogDetails(); }, [addonId, type, id]); // Add effect to get data source preference when component mounts @@ -372,7 +419,7 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { type, id, dataSource, - genreFilter + activeGenreFilter }); try { if (shouldRefresh) { @@ -383,9 +430,9 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { } setError(null); - + // Process the genre filter - ignore "All" and clean up the value - let effectiveGenreFilter = genreFilter; + let effectiveGenreFilter = activeGenreFilter; if (effectiveGenreFilter === 'All') { effectiveGenreFilter = undefined; logger.log('Genre "All" detected, removing genre filter'); @@ -394,7 +441,7 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { effectiveGenreFilter = effectiveGenreFilter.trim(); logger.log(`Using cleaned genre filter: "${effectiveGenreFilter}"`); } - + // Check if using TMDB as data source and not requesting a specific addon if (dataSource === DataSource.TMDB && !addonId) { logger.log('Using TMDB data source for CatalogScreen'); @@ -406,7 +453,7 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { catalogs.forEach(catalog => { allItems.push(...catalog.items); }); - + // Convert StreamingContent to Meta format const metaItems: Meta[] = allItems.map(item => ({ id: item.id, @@ -423,12 +470,12 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { runtime: item.runtime, certification: item.certification, })); - + // Remove duplicates const uniqueItems = metaItems.filter((item, index, self) => index === self.findIndex((t) => t.id === item.id) ); - + InteractionManager.runAfterInteractions(() => { setItems(uniqueItems); setHasMore(false); // TMDB already returns a full set @@ -465,22 +512,22 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { return; } } - + // Use this flag to track if we found and processed any items let foundItems = false; let allItems: Meta[] = []; - + // Get all installed addon manifests directly const manifests = await stremioService.getInstalledAddonsAsync(); - + if (addonId) { // If addon ID is provided, find the specific addon const addon = manifests.find(a => a.id === addonId); - + if (!addon) { throw new Error(`Addon ${addonId} not found`); } - + // Create filters array for genre filtering if provided const filters = effectiveGenreFilter ? [{ title: 'genre', value: effectiveGenreFilter }] : []; @@ -525,60 +572,60 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { } } else if (effectiveGenreFilter) { // Get all addons that have catalogs of the specified type - const typeManifests = manifests.filter(manifest => + const typeManifests = manifests.filter(manifest => manifest.catalogs && manifest.catalogs.some(catalog => catalog.type === type) ); - + // Add debug logging for genre filter logger.log(`Using genre filter: "${effectiveGenreFilter}" for type: ${type}`); - + // For each addon, try to get content with the genre filter for (const manifest of typeManifests) { try { // Find catalogs of this type const typeCatalogs = manifest.catalogs?.filter(catalog => catalog.type === type) || []; - + // For each catalog, try to get content for (const catalog of typeCatalogs) { try { const filters = [{ title: 'genre', value: effectiveGenreFilter }]; - + // Debug logging for each catalog request logger.log(`Requesting from ${manifest.name}, catalog ${catalog.id} with genre "${effectiveGenreFilter}"`); - + const catalogItems = await stremioService.getCatalog(manifest, type, catalog.id, 1, filters); - + if (catalogItems && catalogItems.length > 0) { // Log first few items' genres to debug const sampleItems = catalogItems.slice(0, 3); sampleItems.forEach(item => { logger.log(`Item "${item.name}" has genres: ${JSON.stringify(item.genres)}`); }); - + // Filter items client-side to ensure they contain the requested genre // Some addons might not properly filter by genre on the server let filteredItems = catalogItems; if (effectiveGenreFilter) { const normalizedGenreFilter = effectiveGenreFilter.toLowerCase().trim(); - + filteredItems = catalogItems.filter(item => { // Skip items without genres if (!item.genres || !Array.isArray(item.genres)) { return false; } - + // Check for genre match (exact or substring) return item.genres.some(genre => { const normalizedGenre = genre.toLowerCase().trim(); - return normalizedGenre === normalizedGenreFilter || - normalizedGenre.includes(normalizedGenreFilter) || - normalizedGenreFilter.includes(normalizedGenre); + return normalizedGenre === normalizedGenreFilter || + normalizedGenre.includes(normalizedGenreFilter) || + normalizedGenreFilter.includes(normalizedGenre); }); }); - + logger.log(`Filtered ${catalogItems.length} items to ${filteredItems.length} matching genre "${effectiveGenreFilter}"`); } - + allItems = [...allItems, ...filteredItems]; foundItems = filteredItems.length > 0; } @@ -592,7 +639,7 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { // Continue with other addons } } - + // Remove duplicates by ID const uniqueItems = allItems.filter((item, index, self) => index === self.findIndex((t) => t.id === item.id) @@ -607,7 +654,7 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { }); } } - + if (!foundItems) { InteractionManager.runAfterInteractions(() => { setError("No content found for the selected filters"); @@ -630,7 +677,7 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { }); }); } - }, [addonId, type, id, genreFilter, dataSource]); + }, [addonId, type, id, activeGenreFilter, dataSource]); useEffect(() => { loadItems(true, 1); @@ -641,6 +688,28 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { loadItems(true); }, [loadItems]); + // Handle filter chip selection + const handleFilterChange = useCallback((filterName: string, value: string | undefined) => { + logger.log('[CatalogScreen] Filter changed:', filterName, value); + + if (filterName === 'genre') { + setActiveGenreFilter(value); + } else { + setSelectedFilters(prev => { + if (value === undefined) { + const { [filterName]: _, ...rest } = prev; + return rest; + } + return { ...prev, [filterName]: value }; + }); + } + + // Reset pagination - don't clear items to avoid flash of empty state + // loadItems will replace items when new data arrives + setPage(1); + setLoading(true); + }, []); + const effectiveNumColumns = React.useMemo(() => { const isPhone = screenData.width < 600; // basic breakpoint; tablets generally above this @@ -665,12 +734,12 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { if (!poster || poster.includes('placeholder')) { return 'https://via.placeholder.com/300x450/333333/666666?text=No+Image'; } - + // For TMDB images, use smaller sizes for better performance if (poster.includes('image.tmdb.org')) { return poster.replace(/\/w\d+\//, '/w300/'); } - + return poster; }, []); @@ -679,12 +748,12 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { const isLastInRow = (index + 1) % effectiveNumColumns === 0; // For proper spacing const rightMargin = isLastInRow ? 0 : ((screenData as any).itemSpacing ?? SPACING.sm); - + return ( = ({ route, navigation }) => { - navigation.goBack()} > @@ -806,7 +875,7 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { - navigation.goBack()} > @@ -824,7 +893,7 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { - navigation.goBack()} > @@ -833,7 +902,54 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { {displayName || `${type.charAt(0).toUpperCase() + type.slice(1)}s`} - + + {/* Filter chip bar - shows when catalog has filterable extras */} + {catalogExtras.length > 0 && ( + + + {catalogExtras.map(extra => ( + + {/* All option - clears filter */} + handleFilterChange(extra.name, undefined)} + > + All + + + {/* Filter options from catalog extra */} + {extra.options?.map(option => { + const isActive = extra.name === 'genre' + ? activeGenreFilter === option + : selectedFilters[extra.name] === option; + return ( + handleFilterChange(extra.name, option)} + > + + {option} + + + ); + })} + + ))} + + + )} + {items.length > 0 ? ( { Powered by diff --git a/src/services/stremioService.ts b/src/services/stremioService.ts index a1b1732..9ba4cc3 100644 --- a/src/services/stremioService.ts +++ b/src/services/stremioService.ts @@ -61,33 +61,77 @@ export interface Meta { } export interface Subtitle { - id: string; + id: string; // Required per protocol url: string; lang: string; fps?: number; addon?: string; addonName?: string; - format?: 'srt' | 'vtt' | 'ass' | 'ssa'; // Format hint + format?: 'srt' | 'vtt' | 'ass' | 'ssa'; +} + +// Source object for archive streams per protocol +export interface SourceObject { + url: string; + bytes?: number; } export interface Stream { - name?: string; - title?: string; - url: string; + // Primary stream source - one of these must be provided + url?: string; // Direct HTTP(S)/FTP(S)/RTMP URL + ytId?: string; // YouTube video ID + infoHash?: string; // BitTorrent info hash + externalUrl?: string; // External URL to open in browser + nzbUrl?: string; // Usenet NZB file URL + rarUrls?: SourceObject[]; // RAR archive files + zipUrls?: SourceObject[]; // ZIP archive files + '7zipUrls'?: SourceObject[]; // 7z archive files + tgzUrls?: SourceObject[]; // TGZ archive files + tarUrls?: SourceObject[]; // TAR archive files + + // Stream selection within archives/torrents + fileIdx?: number; // File index in archive/torrent + fileMustInclude?: string; // Regex for file matching in archives + servers?: string[]; // NNTP servers for nzbUrl + + // Display information + name?: string; // Stream name (usually quality) + title?: string; // Stream title/description (deprecated for description) + description?: string; // Stream description + + // Addon identification addon?: string; addonId?: string; addonName?: string; - description?: string; - infoHash?: string; - fileIdx?: number; - behaviorHints?: { - bingeGroup?: string; - notWebReady?: boolean; - [key: string]: any; - }; + + // Stream properties size?: number; isFree?: boolean; isDebrid?: boolean; + quality?: string; + headers?: Record; + + // Embedded subtitles per protocol + subtitles?: Subtitle[]; + + // Additional tracker/DHT sources + sources?: string[]; + + // Complete behavior hints per protocol + behaviorHints?: { + bingeGroup?: string; // Group for binge watching + notWebReady?: boolean; // True if not HTTPS MP4 + countryWhitelist?: string[]; // ISO 3166-1 alpha-3 codes (lowercase) + cached?: boolean; // Debrid cached status + proxyHeaders?: { // Custom headers for stream + request?: Record; + response?: Record; + }; + videoHash?: string; // OpenSubtitles hash + videoSize?: number; // Video file size in bytes + filename?: string; // Video filename + [key: string]: any; + }; } export interface StreamResponse { @@ -119,6 +163,16 @@ interface Catalog { extraSupported?: string[]; extraRequired?: string[]; itemCount?: number; + // Per Stremio protocol - extra properties for filtering + extra?: CatalogExtra[]; +} + +// Extra property definition per protocol +export interface CatalogExtra { + name: string; // Property name (e.g., 'genre', 'search', 'skip') + isRequired?: boolean; // If true, must always be provided + options?: string[]; // Available options (e.g., genre list) + optionsLimit?: number; // Max selections allowed (default 1) } interface ResourceObject { @@ -143,7 +197,32 @@ export interface Manifest { queryParams?: string; behaviorHints?: { configurable?: boolean; + configurationRequired?: boolean; // Per protocol + adult?: boolean; // Adult content flag + p2p?: boolean; // P2P content flag }; + config?: ConfigObject[]; // User configuration + addonCatalogs?: Catalog[]; // Addon catalogs + background?: string; // Background image URL + logo?: string; // Logo URL + contactEmail?: string; // Contact email +} + +// Config object for addon configuration per protocol +interface ConfigObject { + key: string; + type: 'text' | 'number' | 'password' | 'checkbox' | 'select'; + default?: string; + title?: string; + options?: string[]; + required?: boolean; +} + +// Meta Link object per protocol +export interface MetaLink { + name: string; + category: string; // 'actor', 'director', 'writer', etc. + url: string; // External URL or stremio:/// deep link } export interface MetaDetails extends Meta { @@ -154,8 +233,12 @@ export interface MetaDetails extends Meta { season?: number; episode?: number; thumbnail?: string; - streams?: Stream[]; // Embedded streams (used by PPV-style addons) + streams?: Stream[]; // Embedded streams (used by PPV-style addons) + available?: boolean; // Availability flag per protocol + overview?: string; // Episode summary per protocol + trailers?: Stream[]; // Trailer streams per protocol }[]; + links?: MetaLink[]; // Actor/Director/Genre links per protocol } export interface AddonCapabilities { @@ -182,7 +265,7 @@ class StremioService { private readonly STORAGE_KEY = 'stremio-addons'; private readonly ADDON_ORDER_KEY = 'stremio-addon-order'; private readonly MAX_CONCURRENT_REQUESTS = 3; - private readonly DEFAULT_PAGE_SIZE = 50; + private readonly DEFAULT_PAGE_SIZE = 100; // Protocol standard page size private initialized: boolean = false; private initializationPromise: Promise | null = null; private catalogHasMore: Map = new Map(); @@ -739,13 +822,10 @@ class StremioService { } async getCatalog(manifest: Manifest, type: string, id: string, page = 1, filters: CatalogFilter[] = []): Promise { - // Build URLs (path-style skip and query-style skip) and try both for broad addon support + // Build URLs per Stremio protocol: /{resource}/{type}/{id}/{extraArgs}.json + // Extra args (search, genre, skip) go in path segment, NOT query params const encodedId = encodeURIComponent(id); const pageSkip = (page - 1) * this.DEFAULT_PAGE_SIZE; - const filterQuery = (filters || []) - .filter(f => f && f.value) - .map(f => `&${encodeURIComponent(f.title)}=${encodeURIComponent(f.value!)}`) - .join(''); // For all addons if (!manifest.url) { @@ -755,44 +835,68 @@ class StremioService { try { if (__DEV__) console.log(`🔍 [getCatalog] Manifest URL for ${manifest.name}: ${manifest.url}`); const { baseUrl, queryParams } = this.getAddonBaseURL(manifest.url); - // Candidate 1: Path-style skip URL: /catalog/{type}/{id}/skip={N}.json - const urlPathStyle = `${baseUrl}/catalog/${type}/${encodedId}/skip=${pageSkip}.json${queryParams ? `?${queryParams}` : ''}`; - // Add filters to path style (append with & or ? based on presence of queryParams) - const urlPathWithFilters = urlPathStyle + (urlPathStyle.includes('?') ? filterQuery : (filterQuery ? `?${filterQuery.slice(1)}` : '')); - // Candidate 2: Query-style skip URL: /catalog/{type}/{id}.json?skip={N}&limit={PAGE_SIZE} + // Build extraArgs as combined path segment per protocol + // Format: /catalog/{type}/{id}/{extraArgs}.json where extraArgs is like "genre=Action&skip=100" + const extraParts: string[] = []; + + // Add filters to extra args (genre, search, etc.) + if (filters && filters.length > 0) { + filters.filter(f => f && f.value).forEach(f => { + extraParts.push(`${encodeURIComponent(f.title)}=${encodeURIComponent(f.value)}`); + }); + } + + // Add skip for pagination (only if not page 1) + if (pageSkip > 0) { + extraParts.push(`skip=${pageSkip}`); + } + + // Build the extraArgs path segment + const extraArgsPath = extraParts.length > 0 ? `/${extraParts.join('&')}` : ''; + + // Construct URLs per protocol + // Primary: Path-style with extra args in path segment + const urlPathStyle = `${baseUrl}/catalog/${type}/${encodedId}${extraArgsPath}.json${queryParams ? `?${queryParams}` : ''}`; + + // Fallback for page 1 without filters: simple URL + const urlSimple = `${baseUrl}/catalog/${type}/${encodedId}.json${queryParams ? `?${queryParams}` : ''}`; + + // Legacy fallback: Query-style URL (for older addons) + const legacyFilterQuery = (filters || []) + .filter(f => f && f.value) + .map(f => `&${encodeURIComponent(f.title)}=${encodeURIComponent(f.value!)}`) + .join(''); let urlQueryStyle = `${baseUrl}/catalog/${type}/${encodedId}.json?skip=${pageSkip}&limit=${this.DEFAULT_PAGE_SIZE}`; if (queryParams) urlQueryStyle += `&${queryParams}`; - urlQueryStyle += filterQuery; + urlQueryStyle += legacyFilterQuery; - // For page 1, also try simple URL without skip (some addons don't support skip) - const urlSimple = `${baseUrl}/catalog/${type}/${encodedId}.json${queryParams ? `?${queryParams}` : ''}`; - const urlSimpleWithFilters = urlSimple + (urlSimple.includes('?') ? filterQuery : (filterQuery ? `?${filterQuery.slice(1)}` : '')); - - // Try URLs in order of compatibility: simple (page 1 only), path-style, query-style + // Try URLs in order of compatibility let response; try { - // For page 1, try simple URL first (best compatibility) - if (pageSkip === 0) { - if (__DEV__) console.log(`🔍 [getCatalog] Trying simple URL for ${manifest.name}: ${urlSimpleWithFilters}`); - response = await this.retryRequest(async () => axios.get(urlSimpleWithFilters)); + // For page 1 without filters, try simple URL first (best compatibility) + if (pageSkip === 0 && extraParts.length === 0) { + if (__DEV__) console.log(`🔍 [getCatalog] Trying simple URL for ${manifest.name}: ${urlSimple}`); + response = await this.retryRequest(async () => axios.get(urlSimple)); // Check if we got valid metas - if empty, try other styles if (!response?.data?.metas || response.data.metas.length === 0) { throw new Error('Empty response from simple URL'); } } else { - throw new Error('Not page 1, skip to path-style'); + throw new Error('Has extra args, use path-style'); } } catch (e) { try { - if (__DEV__) console.log(`🔍 [getCatalog] Trying path-style URL for ${manifest.name}: ${urlPathWithFilters}`); - response = await this.retryRequest(async () => axios.get(urlPathWithFilters)); + // Try path-style URL (correct per protocol) + if (__DEV__) console.log(`🔍 [getCatalog] Trying path-style URL for ${manifest.name}: ${urlPathStyle}`); + response = await this.retryRequest(async () => axios.get(urlPathStyle)); // Check if we got valid metas - if empty, try query-style if (!response?.data?.metas || response.data.metas.length === 0) { throw new Error('Empty response from path-style URL'); } } catch (e2) { try { + // Try legacy query-style URL as last resort if (__DEV__) console.log(`🔍 [getCatalog] Trying query-style URL for ${manifest.name}: ${urlQueryStyle}`); response = await this.retryRequest(async () => axios.get(urlQueryStyle)); } catch (e3) { @@ -1408,6 +1512,11 @@ class StremioService { return stream.url.url; } + // Handle YouTube video ID per protocol + if (stream.ytId) { + return `https://www.youtube.com/watch?v=${stream.ytId}`; + } + if (stream.infoHash) { const trackers = [ 'udp://tracker.opentrackr.org:1337/announce', @@ -1419,7 +1528,12 @@ class StremioService { 'udp://tracker.coppersurfer.tk:6969/announce', 'udp://tracker.internetwarriors.net:1337/announce' ]; - const trackersString = trackers.map(t => `&tr=${encodeURIComponent(t)}`).join(''); + // Add sources from stream if available per protocol + const additionalTrackers = (stream.sources || []) + .filter((s: string) => s.startsWith('tracker:')) + .map((s: string) => s.replace('tracker:', '')); + const allTrackers = [...trackers, ...additionalTrackers]; + const trackersString = allTrackers.map(t => `&tr=${encodeURIComponent(t)}`).join(''); const encodedTitle = encodeURIComponent(stream.title || stream.name || 'Unknown'); return `magnet:?xt=urn:btih:${stream.infoHash}&dn=${encodedTitle}${trackersString}`; } @@ -1430,8 +1544,20 @@ class StremioService { private processStreams(streams: any[], addon: Manifest): Stream[] { return streams .filter(stream => { - // Basic filtering - ensure there's a way to play (URL or infoHash) and identify (title/name) - const hasPlayableLink = !!(stream.url || stream.infoHash); + // Basic filtering - ensure there's a way to play per protocol + // One of: url, ytId, infoHash, externalUrl, nzbUrl, or archive arrays + const hasPlayableLink = !!( + stream.url || + stream.infoHash || + stream.ytId || + stream.externalUrl || + stream.nzbUrl || + (stream.rarUrls && stream.rarUrls.length > 0) || + (stream.zipUrls && stream.zipUrls.length > 0) || + (stream['7zipUrls'] && stream['7zipUrls'].length > 0) || + (stream.tgzUrls && stream.tgzUrls.length > 0) || + (stream.tarUrls && stream.tarUrls.length > 0) + ); const hasIdentifier = !!(stream.title || stream.name); return stream && hasPlayableLink && hasIdentifier; }) @@ -1439,6 +1565,8 @@ class StremioService { const streamUrl = this.getStreamUrl(stream); const isDirectStreamingUrl = this.isDirectStreamingUrl(streamUrl); const isMagnetStream = streamUrl?.startsWith('magnet:'); + const isExternalUrl = !!stream.externalUrl; + const isYouTube = !!stream.ytId; // Prefer full, untruncated text to preserve complete addon details let displayTitle = stream.title || stream.name || 'Unnamed Stream'; @@ -1453,12 +1581,20 @@ class StremioService { // Extract size: Prefer behaviorHints.videoSize, fallback to top-level size const sizeInBytes = stream.behaviorHints?.videoSize || stream.size || undefined; - // Memory optimization: Minimize behaviorHints to essential data only + // Preserve complete behaviorHints per protocol const behaviorHints: Stream['behaviorHints'] = { - notWebReady: !isDirectStreamingUrl, + notWebReady: !isDirectStreamingUrl || isExternalUrl, cached: stream.behaviorHints?.cached || undefined, bingeGroup: stream.behaviorHints?.bingeGroup || undefined, - // Only include essential torrent data for magnet streams + // Per protocol: Country whitelist for geo-restrictions + countryWhitelist: stream.behaviorHints?.countryWhitelist || undefined, + // Per protocol: Proxy headers for custom stream headers + proxyHeaders: stream.behaviorHints?.proxyHeaders || undefined, + // Per protocol: Video metadata for subtitle matching + videoHash: stream.behaviorHints?.videoHash || undefined, + videoSize: stream.behaviorHints?.videoSize || undefined, + filename: stream.behaviorHints?.filename || undefined, + // Include essential torrent data for magnet streams ...(isMagnetStream ? { infoHash: stream.infoHash || streamUrl?.match(/btih:([a-zA-Z0-9]+)/)?.[1], fileIdx: stream.fileIdx, @@ -1466,20 +1602,49 @@ class StremioService { } : {}), }; - // Explicitly construct the final Stream object with minimal data + // Explicitly construct the final Stream object with all protocol fields const processedStream: Stream = { - url: streamUrl, + // Primary URL (may be empty for ytId/externalUrl streams) + url: streamUrl || undefined, name: name, title: displayTitle, addonName: addon.name, addonId: addon.id, + // Include description as-is to preserve full details description: stream.description, + + // Alternative source types per protocol + ytId: stream.ytId || undefined, + externalUrl: stream.externalUrl || undefined, + nzbUrl: stream.nzbUrl || undefined, + rarUrls: stream.rarUrls || undefined, + zipUrls: stream.zipUrls || undefined, + '7zipUrls': stream['7zipUrls'] || undefined, + tgzUrls: stream.tgzUrls || undefined, + tarUrls: stream.tarUrls || undefined, + servers: stream.servers || undefined, + + // Torrent/archive file selection infoHash: stream.infoHash || undefined, fileIdx: stream.fileIdx, + fileMustInclude: stream.fileMustInclude || undefined, + + // Stream metadata size: sizeInBytes, isFree: stream.isFree, isDebrid: !!(stream.behaviorHints?.cached), + + // Embedded subtitles per protocol + subtitles: stream.subtitles?.map((sub: any, index: number) => ({ + id: sub.id || `${addon.id}-${sub.lang || 'unknown'}-${index}`, + ...sub, + })) || undefined, + + // Additional tracker/DHT sources per protocol + sources: stream.sources || undefined, + + // Complete behavior hints behaviorHints: behaviorHints, }; @@ -1553,7 +1718,9 @@ class StremioService { logger.log(`Fetching subtitles from ${addon.name}: ${url}`); const response = await this.retryRequest(async () => axios.get(url, { timeout: 10000 })); if (response.data && Array.isArray(response.data.subtitles)) { - return response.data.subtitles.map((sub: any) => ({ + return response.data.subtitles.map((sub: any, index: number) => ({ + // Ensure ID is always present per protocol (required field) + id: sub.id || `${addon.id}-${sub.lang || 'unknown'}-${index}`, ...sub, addon: addon.id, addonName: addon.name, @@ -1657,6 +1824,54 @@ class StremioService { return false; } + /** + * Fetch addon catalogs from addons that provide the addon_catalog resource per protocol. + * Returns a list of other addon manifests that can be installed. + */ + async getAddonCatalogs(type: string, id: string): Promise { + await this.ensureInitialized(); + + // Find addons that provide addon_catalog resource + const addons = this.getInstalledAddons().filter(addon => { + if (!addon.resources) return false; + return addon.resources.some(r => + typeof r === 'string' ? r === 'addon_catalog' : (r as any).name === 'addon_catalog' + ); + }); + + if (addons.length === 0) { + logger.log('[getAddonCatalogs] No addons provide addon_catalog resource'); + return []; + } + + const results: AddonCatalogItem[] = []; + + for (const addon of addons) { + try { + const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url || ''); + const url = `${baseUrl}/addon_catalog/${type}/${encodeURIComponent(id)}.json${queryParams ? `?${queryParams}` : ''}`; + + logger.log(`[getAddonCatalogs] Fetching from ${addon.name}: ${url}`); + const response = await this.retryRequest(() => axios.get(url, { timeout: 10000 })); + + if (response.data?.addons && Array.isArray(response.data.addons)) { + results.push(...response.data.addons); + } + } catch (error) { + logger.warn(`[getAddonCatalogs] Failed to fetch from ${addon.name}:`, error); + } + } + + return results; + } + +} + +// Addon catalog item per protocol +export interface AddonCatalogItem { + transportName: string; // 'http' + transportUrl: string; // URL to manifest.json + manifest: Manifest; } export const stremioService = StremioService.getInstance(); diff --git a/src/services/traktService.ts b/src/services/traktService.ts index 1bf5581..3df54ee 100644 --- a/src/services/traktService.ts +++ b/src/services/traktService.ts @@ -251,11 +251,25 @@ export interface TraktScrobbleResponse { alreadyScrobbled?: boolean; } +/** + * Content data for Trakt scrobbling. + * + * Required fields: + * - type: 'movie' or 'episode' + * - imdbId: A valid IMDb ID (with or without 'tt' prefix) + * - title: Non-empty content title + * + * Optional fields: + * - year: Release year (must be valid if provided, e.g., 1800-current year+10) + * - season/episode: Required for episode type + * - showTitle/showYear/showImdbId: Show metadata for episodes + */ export interface TraktContentData { type: 'movie' | 'episode'; imdbId: string; title: string; - year: number; + /** Release year - optional as Trakt can often resolve content via IMDb ID alone */ + year?: number; season?: number; episode?: number; showTitle?: string; @@ -1527,12 +1541,27 @@ export class TraktService { /** * Build scrobble payload for API requests + * Returns null if required data is missing or invalid */ private async buildScrobblePayload(contentData: TraktContentData, progress: number): Promise { try { // Clamp progress between 0 and 100 and round to 2 decimals for API const clampedProgress = Math.min(100, Math.max(0, Math.round(progress * 100) / 100)); + // Helper function to validate year + const isValidYear = (year: number | undefined): year is number => { + if (year === undefined || year === null) return false; + if (typeof year !== 'number' || isNaN(year)) return false; + // Year must be between 1800 and current year + 10 + const currentYear = new Date().getFullYear(); + return year > 0 && year >= 1800 && year <= currentYear + 10; + }; + + // Helper function to validate title + const isValidTitle = (title: string | undefined): title is string => { + return typeof title === 'string' && title.trim().length > 0; + }; + // Enhanced debug logging for payload building logger.log('[TraktService] Building scrobble payload:', { type: contentData.type, @@ -1548,9 +1577,14 @@ export class TraktService { }); if (contentData.type === 'movie') { - if (!contentData.imdbId || !contentData.title) { - logger.error('[TraktService] Missing movie data for scrobbling:', { - imdbId: contentData.imdbId, + // Validate required movie fields + if (!contentData.imdbId || contentData.imdbId.trim() === '') { + logger.error('[TraktService] Missing movie imdbId for scrobbling'); + return null; + } + + if (!isValidTitle(contentData.title)) { + logger.error('[TraktService] Missing or empty movie title for scrobbling:', { title: contentData.title }); return null; @@ -1561,36 +1595,70 @@ export class TraktService { ? contentData.imdbId : `tt${contentData.imdbId}`; + // Build movie payload - only include year if valid + const movieData: { title: string; year?: number; ids: { imdb: string } } = { + title: contentData.title.trim(), + ids: { + imdb: imdbIdWithPrefix + } + }; + + // Only add year if it's valid (prevents year: 0 or invalid years) + if (isValidYear(contentData.year)) { + movieData.year = contentData.year; + } else { + logger.warn('[TraktService] Movie year is missing or invalid, omitting from payload:', { + year: contentData.year + }); + } + const payload = { - movie: { - title: contentData.title, - year: contentData.year, - ids: { - imdb: imdbIdWithPrefix - } - }, + movie: movieData, progress: clampedProgress }; logger.log('[TraktService] Movie payload built:', payload); return payload; } else if (contentData.type === 'episode') { - if (!contentData.season || !contentData.episode || !contentData.showTitle || !contentData.showYear) { - logger.error('[TraktService] Missing episode data for scrobbling:', { - season: contentData.season, - episode: contentData.episode, - showTitle: contentData.showTitle, - showYear: contentData.showYear + // Validate season and episode numbers + if (contentData.season === undefined || contentData.season === null || contentData.season < 0) { + logger.error('[TraktService] Invalid season for episode scrobbling:', { + season: contentData.season }); return null; } + if (contentData.episode === undefined || contentData.episode === null || contentData.episode <= 0) { + logger.error('[TraktService] Invalid episode number for scrobbling:', { + episode: contentData.episode + }); + return null; + } + + if (!isValidTitle(contentData.showTitle)) { + logger.error('[TraktService] Missing or empty show title for episode scrobbling:', { + showTitle: contentData.showTitle + }); + return null; + } + + // Build show data - only include year if valid + const showData: { title: string; year?: number; ids: { imdb?: string } } = { + title: contentData.showTitle.trim(), + ids: {} + }; + + // Only add year if it's valid + if (isValidYear(contentData.showYear)) { + showData.year = contentData.showYear; + } else { + logger.warn('[TraktService] Show year is missing or invalid, omitting from payload:', { + showYear: contentData.showYear + }); + } + const payload: any = { - show: { - title: contentData.showTitle, - year: contentData.showYear, - ids: {} - }, + show: showData, episode: { season: contentData.season, number: contentData.episode @@ -1599,7 +1667,7 @@ export class TraktService { }; // Add show IMDB ID if available - if (contentData.showImdbId) { + if (contentData.showImdbId && contentData.showImdbId.trim() !== '') { const showImdbWithPrefix = contentData.showImdbId.startsWith('tt') ? contentData.showImdbId : `tt${contentData.showImdbId}`; @@ -1607,7 +1675,7 @@ export class TraktService { } // Add episode IMDB ID if available (for specific episode IDs) - if (contentData.imdbId && contentData.imdbId !== contentData.showImdbId) { + if (contentData.imdbId && contentData.imdbId.trim() !== '' && contentData.imdbId !== contentData.showImdbId) { const episodeImdbWithPrefix = contentData.imdbId.startsWith('tt') ? contentData.imdbId : `tt${contentData.imdbId}`; diff --git a/src/types/metadata.ts b/src/types/metadata.ts index be8bc39..0680554 100644 --- a/src/types/metadata.ts +++ b/src/types/metadata.ts @@ -11,42 +11,74 @@ export type RouteParams = { episodeId?: string; }; -// Stream related types +// Stream related types - aligned with Stremio protocol +export interface Subtitle { + id: string; // Required per protocol + url: string; + lang: string; + fps?: number; + addon?: string; + addonName?: string; + format?: 'srt' | 'vtt' | 'ass' | 'ssa'; +} + export interface Stream { + // Primary stream source - one of these must be provided per protocol + url?: string; // Direct HTTP URL (now optional) + ytId?: string; // YouTube video ID + infoHash?: string; // BitTorrent info hash + externalUrl?: string; // External URL to open in browser + + // Display information name?: string; title?: string; - url: string; + description?: string; + + // Addon identification + addon?: string; addonId?: string; addonName?: string; - behaviorHints?: { - cached?: boolean; - [key: string]: any; - }; + + // Stream properties + size?: number; + isFree?: boolean; + isDebrid?: boolean; quality?: string; type?: string; lang?: string; + fileIdx?: number; + headers?: { Referer?: string; 'User-Agent'?: string; Origin?: string; + [key: string]: string | undefined; }; + files?: { file: string; type: string; quality: string; lang: string; }[]; - subtitles?: { - url: string; - lang: string; - }[]; - addon?: string; - description?: string; - infoHash?: string; - fileIdx?: number; - size?: number; - isFree?: boolean; - isDebrid?: boolean; + + subtitles?: Subtitle[]; + sources?: string[]; + + behaviorHints?: { + bingeGroup?: string; + notWebReady?: boolean; + countryWhitelist?: string[]; + cached?: boolean; + proxyHeaders?: { + request?: Record; + response?: Record; + }; + videoHash?: string; + videoSize?: number; + filename?: string; + [key: string]: any; + }; } export interface GroupedStreams { diff --git a/src/types/streams.ts b/src/types/streams.ts index 0b0d609..1c038f2 100644 --- a/src/types/streams.ts +++ b/src/types/streams.ts @@ -1,34 +1,85 @@ -export interface Stream { - name?: string; - title?: string; +// Source object for archive streams per protocol +export interface SourceObject { url: string; + bytes?: number; +} + +export interface Subtitle { + id: string; // Required per protocol + url: string; + lang: string; + fps?: number; + addon?: string; + addonName?: string; + format?: 'srt' | 'vtt' | 'ass' | 'ssa'; +} + +export interface Stream { + // Primary stream source - one of these must be provided + url?: string; // Direct HTTP(S)/FTP(S)/RTMP URL + ytId?: string; // YouTube video ID + infoHash?: string; // BitTorrent info hash + externalUrl?: string; // External URL to open in browser + nzbUrl?: string; // Usenet NZB file URL + rarUrls?: SourceObject[]; // RAR archive files + zipUrls?: SourceObject[]; // ZIP archive files + '7zipUrls'?: SourceObject[]; // 7z archive files + tgzUrls?: SourceObject[]; // TGZ archive files + tarUrls?: SourceObject[]; // TAR archive files + + // Stream selection within archives/torrents + fileIdx?: number; // File index in archive/torrent + fileMustInclude?: string; // Regex for file matching in archives + servers?: string[]; // NNTP servers for nzbUrl + + // Display information + name?: string; // Stream name (usually quality) + title?: string; // Stream title/description (deprecated for description) + description?: string; // Stream description + + // Addon identification + addon?: string; addonId?: string; addonName?: string; - behaviorHints?: { - cached?: boolean; - [key: string]: any; - }; + + // Stream properties + size?: number; + isFree?: boolean; + isDebrid?: boolean; quality?: string; type?: string; lang?: string; - headers?: { [key: string]: string }; + headers?: Record; + + // Legacy files array (for compatibility) files?: { file: string; type: string; quality: string; lang: string; }[]; - subtitles?: { - url: string; - lang: string; - }[]; - addon?: string; - description?: string; - infoHash?: string; - fileIdx?: number; - size?: number; - isFree?: boolean; - isDebrid?: boolean; + + // Embedded subtitles per protocol + subtitles?: Subtitle[]; + + // Additional tracker/DHT sources + sources?: string[]; + + // Complete behavior hints per protocol + behaviorHints?: { + bingeGroup?: string; // Group for binge watching + notWebReady?: boolean; // True if not HTTPS MP4 + countryWhitelist?: string[]; // ISO 3166-1 alpha-3 codes (lowercase) + cached?: boolean; // Debrid cached status + proxyHeaders?: { // Custom headers for stream + request?: Record; + response?: Record; + }; + videoHash?: string; // OpenSubtitles hash + videoSize?: number; // Video file size in bytes + filename?: string; // Video filename + [key: string]: any; + }; } export interface GroupedStreams {