added filter for catalogs

This commit is contained in:
tapframe 2025-12-15 15:37:04 +05:30
parent 619333c328
commit 60cdf9fe86
14 changed files with 882 additions and 314 deletions

1
.gitignore vendored
View file

@ -85,3 +85,4 @@ node_modules
expofs.md
ios/sentry.properties
android/sentry.properties
Stremio addons refer

View file

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

View file

@ -1,103 +1,103 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Nuvio</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.2.11</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>nuvio</string>
<string>com.nuvio.app</string>
</array>
</dict>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>exp+nuvio</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>26</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSBonjourServices</key>
<array>
<string>_http._tcp</string>
<string>_googlecast._tcp</string>
<string>_CC1AD845._googlecast._tcp</string>
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>Allow $(PRODUCT_NAME) to access your local network</string>
<key>NSMicrophoneUsageDescription</key>
<string>This app does not require microphone access.</string>
<key>RCTNewArchEnabled</key>
<true/>
<key>RCTRootViewBackgroundColor</key>
<integer>4278322180</integer>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UIFileSharingEnabled</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>SplashScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UIRequiresFullScreen</key>
<true/>
<key>UIStatusBarStyle</key>
<string>UIStatusBarStyleDefault</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Dark</string>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
</plist>
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Nuvio</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.2.11</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>nuvio</string>
<string>com.nuvio.app</string>
</array>
</dict>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>exp+nuvio</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>26</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSBonjourServices</key>
<array>
<string>_http._tcp</string>
<string>_googlecast._tcp</string>
<string>_CC1AD845._googlecast._tcp</string>
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>Allow $(PRODUCT_NAME) to access your local network</string>
<key>NSMicrophoneUsageDescription</key>
<string>This app does not require microphone access.</string>
<key>RCTNewArchEnabled</key>
<true/>
<key>RCTRootViewBackgroundColor</key>
<integer>4278322180</integer>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UIFileSharingEnabled</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>SplashScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UIRequiresFullScreen</key>
<true/>
<key>UIStatusBarStyle</key>
<string>UIStatusBarStyleDefault</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIUserInterfaceStyle</key>
<string>Dark</string>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
</plist>

View file

@ -9,7 +9,7 @@
<key>EXUpdatesLaunchWaitMs</key>
<integer>30000</integer>
<key>EXUpdatesRuntimeVersion</key>
<string>1.2.10</string>
<string>1.2.11</string>
<key>EXUpdatesURL</key>
<string>https://grim-reyna-tapframe-69970143.koyeb.app/api/manifest</string>
</dict>

View file

@ -2,4 +2,4 @@
"expo.jsEngine": "hermes",
"EX_DEV_CLIENT_NETWORK_INSPECTOR": "true",
"newArchEnabled": "true"
}
}

View file

@ -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<number>;
shimmerProgress: SharedValue<number>;
baseColor: string;
highlightColor: string;
}) => {

View file

@ -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<string | null>(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})`);

View file

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

View file

@ -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<CatalogScreenProps> = ({ route, navigation }) => {
@ -253,6 +285,10 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
});
const [mobileColumnsPref, setMobileColumnsPref] = useState<'auto' | 2 | 3>('auto');
const [nowPlayingMovies, setNowPlayingMovies] = useState<Set<string>>(new Set());
// Filter state for catalog extra properties per protocol
const [catalogExtras, setCatalogExtras] = useState<CatalogExtra[]>([]);
const [selectedFilters, setSelectedFilters] = useState<Record<string, string>>({});
const [activeGenreFilter, setActiveGenreFilter] = useState<string | undefined>(genreFilter);
const { currentTheme } = useTheme();
const colors = currentTheme.colors;
const styles = createStyles(colors);
@ -266,7 +302,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ 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<CatalogScreenProps> = ({ 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<CatalogScreenProps> = ({ route, navigation }) => {
type,
id,
dataSource,
genreFilter
activeGenreFilter
});
try {
if (shouldRefresh) {
@ -383,9 +430,9 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ 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<CatalogScreenProps> = ({ 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<CatalogScreenProps> = ({ 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<CatalogScreenProps> = ({ 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<CatalogScreenProps> = ({ 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<CatalogScreenProps> = ({ 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<CatalogScreenProps> = ({ 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<CatalogScreenProps> = ({ route, navigation }) => {
});
}
}
if (!foundItems) {
InteractionManager.runAfterInteractions(() => {
setError("No content found for the selected filters");
@ -630,7 +677,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
});
});
}
}, [addonId, type, id, genreFilter, dataSource]);
}, [addonId, type, id, activeGenreFilter, dataSource]);
useEffect(() => {
loadItems(true, 1);
@ -641,6 +688,28 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ 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<CatalogScreenProps> = ({ 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<CatalogScreenProps> = ({ route, navigation }) => {
const isLastInRow = (index + 1) % effectiveNumColumns === 0;
// For proper spacing
const rightMargin = isLastInRow ? 0 : ((screenData as any).itemSpacing ?? SPACING.sm);
return (
<TouchableOpacity
style={[
styles.item,
{
{
marginRight: rightMargin,
width: effectiveItemWidth
}
@ -787,7 +856,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
<SafeAreaView style={styles.container}>
<StatusBar barStyle="light-content" />
<View style={styles.header}>
<TouchableOpacity
<TouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
>
@ -806,7 +875,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
<SafeAreaView style={styles.container}>
<StatusBar barStyle="light-content" />
<View style={styles.header}>
<TouchableOpacity
<TouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
>
@ -824,7 +893,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
<SafeAreaView style={styles.container}>
<StatusBar barStyle="light-content" />
<View style={styles.header}>
<TouchableOpacity
<TouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
>
@ -833,7 +902,54 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
</TouchableOpacity>
</View>
<Text style={styles.headerTitle}>{displayName || `${type.charAt(0).toUpperCase() + type.slice(1)}s`}</Text>
{/* Filter chip bar - shows when catalog has filterable extras */}
{catalogExtras.length > 0 && (
<View style={styles.filterContainer}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.filterScrollContent}
>
{catalogExtras.map(extra => (
<React.Fragment key={extra.name}>
{/* All option - clears filter */}
<TouchableOpacity
style={[
styles.filterChip,
(extra.name === 'genre' ? !activeGenreFilter : !selectedFilters[extra.name]) && styles.filterChipActive
]}
onPress={() => handleFilterChange(extra.name, undefined)}
>
<Text style={[
styles.filterChipText,
(extra.name === 'genre' ? !activeGenreFilter : !selectedFilters[extra.name]) && styles.filterChipTextActive
]}>All</Text>
</TouchableOpacity>
{/* Filter options from catalog extra */}
{extra.options?.map(option => {
const isActive = extra.name === 'genre'
? activeGenreFilter === option
: selectedFilters[extra.name] === option;
return (
<TouchableOpacity
key={option}
style={[styles.filterChip, isActive && styles.filterChipActive]}
onPress={() => handleFilterChange(extra.name, option)}
>
<Text style={[styles.filterChipText, isActive && styles.filterChipTextActive]}>
{option}
</Text>
</TouchableOpacity>
);
})}
</React.Fragment>
))}
</ScrollView>
</View>
)}
{items.length > 0 ? (
<FlashList
data={items}

View file

@ -772,7 +772,7 @@ const DebridIntegrationScreen = () => {
<Text style={styles.poweredBy}>Powered by</Text>
<View style={styles.logoRow}>
<Image
source={{ uri: 'https://torbox.app/assets/logo-57adbf99.svg' }}
source={{ uri: 'https://torbox.app/assets/logo-bb7a9579.svg' }}
style={styles.logo}
resizeMode="contain"
/>

View file

@ -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<string, string>;
// 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<string, string>;
response?: Record<string, string>;
};
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<void> | null = null;
private catalogHasMore: Map<string, boolean> = new Map();
@ -739,13 +822,10 @@ class StremioService {
}
async getCatalog(manifest: Manifest, type: string, id: string, page = 1, filters: CatalogFilter[] = []): Promise<Meta[]> {
// 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<AddonCatalogItem[]> {
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();

View file

@ -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<any | null> {
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}`;

View file

@ -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<string, string>;
response?: Record<string, string>;
};
videoHash?: string;
videoSize?: number;
filename?: string;
[key: string]: any;
};
}
export interface GroupedStreams {

View file

@ -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<string, string>;
// 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<string, string>;
response?: Record<string, string>;
};
videoHash?: string; // OpenSubtitles hash
videoSize?: number; // Video file size in bytes
filename?: string; // Video filename
[key: string]: any;
};
}
export interface GroupedStreams {