mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
added filter for catalogs
This commit is contained in:
parent
619333c328
commit
60cdf9fe86
14 changed files with 882 additions and 314 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -85,3 +85,4 @@ node_modules
|
|||
expofs.md
|
||||
ios/sentry.properties
|
||||
android/sentry.properties
|
||||
Stremio addons refer
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -2,4 +2,4 @@
|
|||
"expo.jsEngine": "hermes",
|
||||
"EX_DEV_CLIENT_NETWORK_INSPECTOR": "true",
|
||||
"newArchEnabled": "true"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}) => {
|
||||
|
|
|
|||
|
|
@ -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})`);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue