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 {