mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-27 11:23:02 +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
|
expofs.md
|
||||||
ios/sentry.properties
|
ios/sentry.properties
|
||||||
android/sentry.properties
|
android/sentry.properties
|
||||||
|
Stremio addons refer
|
||||||
|
|
@ -475,7 +475,7 @@
|
||||||
);
|
);
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app;
|
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app;
|
||||||
PRODUCT_NAME = Nuvio;
|
PRODUCT_NAME = "Nuvio";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
|
|
@ -506,8 +506,8 @@
|
||||||
"-lc++",
|
"-lc++",
|
||||||
);
|
);
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.hub;
|
PRODUCT_BUNDLE_IDENTIFIER = "com.nuvio.app";
|
||||||
PRODUCT_NAME = Nuvio;
|
PRODUCT_NAME = "Nuvio";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
|
|
||||||
|
|
@ -1,103 +1,103 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?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">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>Nuvio</string>
|
<string>Nuvio</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>$(PRODUCT_NAME)</string>
|
<string>$(PRODUCT_NAME)</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.2.11</string>
|
<string>1.2.11</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
<key>CFBundleURLSchemes</key>
|
<key>CFBundleURLSchemes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>nuvio</string>
|
<string>nuvio</string>
|
||||||
<string>com.nuvio.app</string>
|
<string>com.nuvio.app</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
<dict>
|
<dict>
|
||||||
<key>CFBundleURLSchemes</key>
|
<key>CFBundleURLSchemes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>exp+nuvio</string>
|
<string>exp+nuvio</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>26</string>
|
<string>26</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>12.0</string>
|
<string>12.0</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSAppTransportSecurity</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSAllowsArbitraryLoads</key>
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
<key>NSBonjourServices</key>
|
<key>NSBonjourServices</key>
|
||||||
<array>
|
<array>
|
||||||
<string>_http._tcp</string>
|
<string>_http._tcp</string>
|
||||||
<string>_googlecast._tcp</string>
|
<string>_googlecast._tcp</string>
|
||||||
<string>_CC1AD845._googlecast._tcp</string>
|
<string>_CC1AD845._googlecast._tcp</string>
|
||||||
</array>
|
</array>
|
||||||
<key>NSLocalNetworkUsageDescription</key>
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
<string>Allow $(PRODUCT_NAME) to access your local network</string>
|
<string>Allow $(PRODUCT_NAME) to access your local network</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
<string>This app does not require microphone access.</string>
|
<string>This app does not require microphone access.</string>
|
||||||
<key>RCTNewArchEnabled</key>
|
<key>RCTNewArchEnabled</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>RCTRootViewBackgroundColor</key>
|
<key>RCTRootViewBackgroundColor</key>
|
||||||
<integer>4278322180</integer>
|
<integer>4278322180</integer>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>audio</string>
|
<string>audio</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UIFileSharingEnabled</key>
|
<key>UIFileSharingEnabled</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
<string>SplashScreen</string>
|
<string>SplashScreen</string>
|
||||||
<key>UIRequiredDeviceCapabilities</key>
|
<key>UIRequiredDeviceCapabilities</key>
|
||||||
<array>
|
<array>
|
||||||
<string>arm64</string>
|
<string>arm64</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UIRequiresFullScreen</key>
|
<key>UIRequiresFullScreen</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>UIStatusBarStyle</key>
|
<key>UIStatusBarStyle</key>
|
||||||
<string>UIStatusBarStyleDefault</string>
|
<string>UIStatusBarStyleDefault</string>
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
<array>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
<array>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UIUserInterfaceStyle</key>
|
<key>UIUserInterfaceStyle</key>
|
||||||
<string>Dark</string>
|
<string>Dark</string>
|
||||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||||
<false/>
|
<false/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
<key>EXUpdatesLaunchWaitMs</key>
|
<key>EXUpdatesLaunchWaitMs</key>
|
||||||
<integer>30000</integer>
|
<integer>30000</integer>
|
||||||
<key>EXUpdatesRuntimeVersion</key>
|
<key>EXUpdatesRuntimeVersion</key>
|
||||||
<string>1.2.10</string>
|
<string>1.2.11</string>
|
||||||
<key>EXUpdatesURL</key>
|
<key>EXUpdatesURL</key>
|
||||||
<string>https://grim-reyna-tapframe-69970143.koyeb.app/api/manifest</string>
|
<string>https://grim-reyna-tapframe-69970143.koyeb.app/api/manifest</string>
|
||||||
</dict>
|
</dict>
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import Animated, {
|
||||||
interpolate,
|
interpolate,
|
||||||
cancelAnimation,
|
cancelAnimation,
|
||||||
runOnJS,
|
runOnJS,
|
||||||
|
SharedValue,
|
||||||
} from 'react-native-reanimated';
|
} from 'react-native-reanimated';
|
||||||
import { useTheme } from '../../contexts/ThemeContext';
|
import { useTheme } from '../../contexts/ThemeContext';
|
||||||
|
|
||||||
|
|
@ -59,7 +60,7 @@ const ShimmerSkeleton = ({
|
||||||
marginBottom?: number;
|
marginBottom?: number;
|
||||||
style?: any;
|
style?: any;
|
||||||
delay?: number;
|
delay?: number;
|
||||||
shimmerProgress: Animated.SharedValue<number>;
|
shimmerProgress: SharedValue<number>;
|
||||||
baseColor: string;
|
baseColor: string;
|
||||||
highlightColor: string;
|
highlightColor: string;
|
||||||
}) => {
|
}) => {
|
||||||
|
|
|
||||||
|
|
@ -66,41 +66,81 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
||||||
}, [options.imdbId, options.season, options.episode, options.type]);
|
}, [options.imdbId, options.season, options.episode, options.type]);
|
||||||
|
|
||||||
// Build Trakt content data from options
|
// Build Trakt content data from options
|
||||||
const buildContentData = useCallback((): TraktContentData => {
|
// Returns null if required fields are missing or invalid
|
||||||
// Ensure year is a number and valid
|
const buildContentData = useCallback((): TraktContentData | null => {
|
||||||
const parseYear = (year: number | string | undefined): number => {
|
// Parse and validate year - returns undefined for invalid/missing years
|
||||||
if (!year) return 0;
|
const parseYear = (year: number | string | undefined): number | undefined => {
|
||||||
if (typeof year === 'number') return year;
|
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);
|
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 numericYear = parseYear(options.year);
|
||||||
const numericShowYear = parseYear(options.showYear);
|
const numericShowYear = parseYear(options.showYear);
|
||||||
|
|
||||||
// Validate required fields
|
// Log warning if year is missing (but don't fail - Trakt can sometimes work with IMDb ID alone)
|
||||||
if (!options.title || !options.imdbId) {
|
if (numericYear === undefined) {
|
||||||
logger.warn('[TraktAutosync] Missing required fields:', { title: options.title, imdbId: options.imdbId });
|
logger.warn('[TraktAutosync] Year is missing or invalid, proceeding without year');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.type === 'movie') {
|
if (options.type === 'movie') {
|
||||||
return {
|
return {
|
||||||
type: 'movie',
|
type: 'movie',
|
||||||
imdbId: options.imdbId,
|
imdbId: options.imdbId.trim(),
|
||||||
title: options.title,
|
title: options.title.trim(),
|
||||||
year: numericYear
|
year: numericYear // Can be undefined now
|
||||||
};
|
};
|
||||||
} else {
|
} 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 {
|
return {
|
||||||
type: 'episode',
|
type: 'episode',
|
||||||
imdbId: options.imdbId,
|
imdbId: options.imdbId.trim(),
|
||||||
title: options.title,
|
title: options.title.trim(),
|
||||||
year: numericYear,
|
year: numericYear,
|
||||||
season: options.season,
|
season: options.season,
|
||||||
episode: options.episode,
|
episode: options.episode,
|
||||||
showTitle: options.showTitle || options.title,
|
showTitle: (options.showTitle || options.title).trim(),
|
||||||
showYear: numericShowYear || numericYear,
|
showYear: numericShowYear || numericYear,
|
||||||
showImdbId: options.showImdbId || options.imdbId
|
showImdbId: (options.showImdbId || options.imdbId).trim()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [options]);
|
}, [options]);
|
||||||
|
|
@ -144,6 +184,12 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
||||||
const progressPercent = Math.min(100, Math.max(0, rawProgress));
|
const progressPercent = Math.min(100, Math.max(0, rawProgress));
|
||||||
const contentData = buildContentData();
|
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);
|
const success = await startWatching(contentData, progressPercent);
|
||||||
if (success) {
|
if (success) {
|
||||||
hasStartedWatching.current = true;
|
hasStartedWatching.current = true;
|
||||||
|
|
@ -184,6 +230,10 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
||||||
if (force) {
|
if (force) {
|
||||||
// IMMEDIATE: User action (pause/unpause) - bypass queue
|
// IMMEDIATE: User action (pause/unpause) - bypass queue
|
||||||
const contentData = buildContentData();
|
const contentData = buildContentData();
|
||||||
|
if (!contentData) {
|
||||||
|
logger.warn('[TraktAutosync] Skipping progress update: invalid content data');
|
||||||
|
return;
|
||||||
|
}
|
||||||
success = await updateProgressImmediate(contentData, progressPercent);
|
success = await updateProgressImmediate(contentData, progressPercent);
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
|
|
@ -212,6 +262,10 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const contentData = buildContentData();
|
const contentData = buildContentData();
|
||||||
|
if (!contentData) {
|
||||||
|
logger.warn('[TraktAutosync] Skipping progress update: invalid content data');
|
||||||
|
return;
|
||||||
|
}
|
||||||
success = await updateProgress(contentData, progressPercent, force);
|
success = await updateProgress(contentData, progressPercent, force);
|
||||||
|
|
||||||
if (success) {
|
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 we have valid progress but no started session, force start one first
|
||||||
if (!hasStartedWatching.current && progressPercent > 1) {
|
if (!hasStartedWatching.current && progressPercent > 1) {
|
||||||
const contentData = buildContentData();
|
const contentData = buildContentData();
|
||||||
const success = await startWatching(contentData, progressPercent);
|
if (contentData) {
|
||||||
if (success) {
|
const success = await startWatching(contentData, progressPercent);
|
||||||
hasStartedWatching.current = true;
|
if (success) {
|
||||||
|
hasStartedWatching.current = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -356,6 +412,13 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
||||||
|
|
||||||
const contentData = buildContentData();
|
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
|
// IMMEDIATE: Use immediate method for user-initiated closes, regular method for natural ends
|
||||||
const success = useImmediate
|
const success = useImmediate
|
||||||
? await stopWatchingImmediate(contentData, progressPercent)
|
? await stopWatchingImmediate(contentData, progressPercent)
|
||||||
|
|
@ -394,7 +457,7 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
||||||
{ forceNotify: true }
|
{ forceNotify: true }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.log(`[TraktAutosync] ${useImmediate ? 'IMMEDIATE: ' : ''}Successfully stopped watching: ${contentData.title} (${progressPercent.toFixed(1)}% - ${reason})`);
|
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 ContributorsScreen from '../screens/ContributorsScreen';
|
||||||
import DebridIntegrationScreen from '../screens/DebridIntegrationScreen';
|
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
|
// Stack navigator types
|
||||||
export type RootStackParamList = {
|
export type RootStackParamList = {
|
||||||
Onboarding: undefined;
|
Onboarding: undefined;
|
||||||
|
|
@ -91,9 +101,18 @@ export type RootStackParamList = {
|
||||||
Streams: {
|
Streams: {
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
title?: string;
|
||||||
episodeId?: string;
|
episodeId?: string;
|
||||||
episodeThumbnail?: string;
|
episodeThumbnail?: string;
|
||||||
fromPlayer?: boolean;
|
fromPlayer?: boolean;
|
||||||
|
metadata?: {
|
||||||
|
poster?: string;
|
||||||
|
banner?: string;
|
||||||
|
releaseInfo?: string;
|
||||||
|
genres?: string[];
|
||||||
|
};
|
||||||
|
resumeTime?: number;
|
||||||
|
duration?: number;
|
||||||
};
|
};
|
||||||
PlayerIOS: {
|
PlayerIOS: {
|
||||||
uri: string;
|
uri: string;
|
||||||
|
|
@ -1066,8 +1085,10 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
||||||
if (Platform.OS === 'android') {
|
if (Platform.OS === 'android') {
|
||||||
// Ensure system navigation bar is shown by default
|
// Ensure system navigation bar is shown by default
|
||||||
try {
|
try {
|
||||||
RNImmersiveMode.setBarMode('Normal');
|
if (RNImmersiveMode) {
|
||||||
RNImmersiveMode.fullLayout(false);
|
RNImmersiveMode.setBarMode('Normal');
|
||||||
|
RNImmersiveMode.fullLayout(false);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('Immersive mode error:', error);
|
console.log('Immersive mode error:', error);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,14 @@ import {
|
||||||
RefreshControl,
|
RefreshControl,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
Platform,
|
Platform,
|
||||||
InteractionManager
|
InteractionManager,
|
||||||
|
ScrollView
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { FlashList } from '@shopify/flash-list';
|
import { FlashList } from '@shopify/flash-list';
|
||||||
import { RouteProp } from '@react-navigation/native';
|
import { RouteProp } from '@react-navigation/native';
|
||||||
import { StackNavigationProp } from '@react-navigation/stack';
|
import { StackNavigationProp } from '@react-navigation/stack';
|
||||||
import { RootStackParamList } from '../navigation/AppNavigator';
|
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||||
import { Meta, stremioService } from '../services/stremioService';
|
import { Meta, stremioService, CatalogExtra } from '../services/stremioService';
|
||||||
import { useTheme } from '../contexts/ThemeContext';
|
import { useTheme } from '../contexts/ThemeContext';
|
||||||
import FastImage from '@d11/react-native-fast-image';
|
import FastImage from '@d11/react-native-fast-image';
|
||||||
import { BlurView } from 'expo-blur';
|
import { BlurView } from 'expo-blur';
|
||||||
|
|
@ -154,7 +155,7 @@ const createStyles = (colors: any) => StyleSheet.create({
|
||||||
},
|
},
|
||||||
poster: {
|
poster: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
aspectRatio: 2/3,
|
aspectRatio: 2 / 3,
|
||||||
borderTopLeftRadius: 12,
|
borderTopLeftRadius: 12,
|
||||||
borderTopRightRadius: 12,
|
borderTopRightRadius: 12,
|
||||||
backgroundColor: colors.elevation3,
|
backgroundColor: colors.elevation3,
|
||||||
|
|
@ -230,7 +231,38 @@ const createStyles = (colors: any) => StyleSheet.create({
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
color: colors.white,
|
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 }) => {
|
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 [mobileColumnsPref, setMobileColumnsPref] = useState<'auto' | 2 | 3>('auto');
|
||||||
const [nowPlayingMovies, setNowPlayingMovies] = useState<Set<string>>(new Set());
|
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 { currentTheme } = useTheme();
|
||||||
const colors = currentTheme.colors;
|
const colors = currentTheme.colors;
|
||||||
const styles = createStyles(colors);
|
const styles = createStyles(colors);
|
||||||
|
|
@ -266,7 +302,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
||||||
if (pref === '2') setMobileColumnsPref(2);
|
if (pref === '2') setMobileColumnsPref(2);
|
||||||
else if (pref === '3') setMobileColumnsPref(3);
|
else if (pref === '3') setMobileColumnsPref(3);
|
||||||
else setMobileColumnsPref('auto');
|
else setMobileColumnsPref('auto');
|
||||||
} catch {}
|
} catch { }
|
||||||
})();
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -306,12 +342,12 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
||||||
const displayName = actualCatalogName
|
const displayName = actualCatalogName
|
||||||
? getCustomName(addonId || '', type || '', id || '', createDisplayName(actualCatalogName))
|
? getCustomName(addonId || '', type || '', id || '', createDisplayName(actualCatalogName))
|
||||||
: getCustomName(addonId || '', type || '', id || '', originalName ? createDisplayName(originalName) : '') ||
|
: getCustomName(addonId || '', type || '', id || '', originalName ? createDisplayName(originalName) : '') ||
|
||||||
(genreFilter ? `${genreFilter} ${type === 'movie' ? 'Movies' : 'TV Shows'}` :
|
(genreFilter ? `${genreFilter} ${type === 'movie' ? 'Movies' : 'TV Shows'}` :
|
||||||
`${type.charAt(0).toUpperCase() + type.slice(1)}s`);
|
`${type.charAt(0).toUpperCase() + type.slice(1)}s`);
|
||||||
|
|
||||||
// Add effect to get the actual catalog name from addon manifest
|
// Add effect to get the actual catalog name and filter extras from addon manifest
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const getActualCatalogName = async () => {
|
const getCatalogDetails = async () => {
|
||||||
if (addonId && type && id) {
|
if (addonId && type && id) {
|
||||||
try {
|
try {
|
||||||
const manifests = await stremioService.getInstalledAddonsAsync();
|
const manifests = await stremioService.getInstalledAddonsAsync();
|
||||||
|
|
@ -319,17 +355,28 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
||||||
|
|
||||||
if (addon && addon.catalogs) {
|
if (addon && addon.catalogs) {
|
||||||
const catalog = addon.catalogs.find(c => c.type === type && c.id === id);
|
const catalog = addon.catalogs.find(c => c.type === type && c.id === id);
|
||||||
if (catalog && catalog.name) {
|
if (catalog) {
|
||||||
setActualCatalogName(catalog.name);
|
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) {
|
} catch (error) {
|
||||||
logger.error('Failed to get actual catalog name:', error);
|
logger.error('Failed to get catalog details:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
getActualCatalogName();
|
getCatalogDetails();
|
||||||
}, [addonId, type, id]);
|
}, [addonId, type, id]);
|
||||||
|
|
||||||
// Add effect to get data source preference when component mounts
|
// Add effect to get data source preference when component mounts
|
||||||
|
|
@ -372,7 +419,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
||||||
type,
|
type,
|
||||||
id,
|
id,
|
||||||
dataSource,
|
dataSource,
|
||||||
genreFilter
|
activeGenreFilter
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
if (shouldRefresh) {
|
if (shouldRefresh) {
|
||||||
|
|
@ -385,7 +432,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// Process the genre filter - ignore "All" and clean up the value
|
// Process the genre filter - ignore "All" and clean up the value
|
||||||
let effectiveGenreFilter = genreFilter;
|
let effectiveGenreFilter = activeGenreFilter;
|
||||||
if (effectiveGenreFilter === 'All') {
|
if (effectiveGenreFilter === 'All') {
|
||||||
effectiveGenreFilter = undefined;
|
effectiveGenreFilter = undefined;
|
||||||
logger.log('Genre "All" detected, removing genre filter');
|
logger.log('Genre "All" detected, removing genre filter');
|
||||||
|
|
@ -571,8 +618,8 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
||||||
return item.genres.some(genre => {
|
return item.genres.some(genre => {
|
||||||
const normalizedGenre = genre.toLowerCase().trim();
|
const normalizedGenre = genre.toLowerCase().trim();
|
||||||
return normalizedGenre === normalizedGenreFilter ||
|
return normalizedGenre === normalizedGenreFilter ||
|
||||||
normalizedGenre.includes(normalizedGenreFilter) ||
|
normalizedGenre.includes(normalizedGenreFilter) ||
|
||||||
normalizedGenreFilter.includes(normalizedGenre);
|
normalizedGenreFilter.includes(normalizedGenre);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -630,7 +677,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [addonId, type, id, genreFilter, dataSource]);
|
}, [addonId, type, id, activeGenreFilter, dataSource]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadItems(true, 1);
|
loadItems(true, 1);
|
||||||
|
|
@ -641,6 +688,28 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
||||||
loadItems(true);
|
loadItems(true);
|
||||||
}, [loadItems]);
|
}, [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 effectiveNumColumns = React.useMemo(() => {
|
||||||
const isPhone = screenData.width < 600; // basic breakpoint; tablets generally above this
|
const isPhone = screenData.width < 600; // basic breakpoint; tablets generally above this
|
||||||
|
|
@ -834,6 +903,53 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.headerTitle}>{displayName || `${type.charAt(0).toUpperCase() + type.slice(1)}s`}</Text>
|
<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 ? (
|
{items.length > 0 ? (
|
||||||
<FlashList
|
<FlashList
|
||||||
data={items}
|
data={items}
|
||||||
|
|
|
||||||
|
|
@ -772,7 +772,7 @@ const DebridIntegrationScreen = () => {
|
||||||
<Text style={styles.poweredBy}>Powered by</Text>
|
<Text style={styles.poweredBy}>Powered by</Text>
|
||||||
<View style={styles.logoRow}>
|
<View style={styles.logoRow}>
|
||||||
<Image
|
<Image
|
||||||
source={{ uri: 'https://torbox.app/assets/logo-57adbf99.svg' }}
|
source={{ uri: 'https://torbox.app/assets/logo-bb7a9579.svg' }}
|
||||||
style={styles.logo}
|
style={styles.logo}
|
||||||
resizeMode="contain"
|
resizeMode="contain"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -61,33 +61,77 @@ export interface Meta {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Subtitle {
|
export interface Subtitle {
|
||||||
id: string;
|
id: string; // Required per protocol
|
||||||
url: string;
|
url: string;
|
||||||
lang: string;
|
lang: string;
|
||||||
fps?: number;
|
fps?: number;
|
||||||
addon?: string;
|
addon?: string;
|
||||||
addonName?: 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 {
|
export interface Stream {
|
||||||
name?: string;
|
// Primary stream source - one of these must be provided
|
||||||
title?: string;
|
url?: string; // Direct HTTP(S)/FTP(S)/RTMP URL
|
||||||
url: string;
|
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;
|
addon?: string;
|
||||||
addonId?: string;
|
addonId?: string;
|
||||||
addonName?: string;
|
addonName?: string;
|
||||||
description?: string;
|
|
||||||
infoHash?: string;
|
// Stream properties
|
||||||
fileIdx?: number;
|
|
||||||
behaviorHints?: {
|
|
||||||
bingeGroup?: string;
|
|
||||||
notWebReady?: boolean;
|
|
||||||
[key: string]: any;
|
|
||||||
};
|
|
||||||
size?: number;
|
size?: number;
|
||||||
isFree?: boolean;
|
isFree?: boolean;
|
||||||
isDebrid?: 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 {
|
export interface StreamResponse {
|
||||||
|
|
@ -119,6 +163,16 @@ interface Catalog {
|
||||||
extraSupported?: string[];
|
extraSupported?: string[];
|
||||||
extraRequired?: string[];
|
extraRequired?: string[];
|
||||||
itemCount?: number;
|
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 {
|
interface ResourceObject {
|
||||||
|
|
@ -143,7 +197,32 @@ export interface Manifest {
|
||||||
queryParams?: string;
|
queryParams?: string;
|
||||||
behaviorHints?: {
|
behaviorHints?: {
|
||||||
configurable?: boolean;
|
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 {
|
export interface MetaDetails extends Meta {
|
||||||
|
|
@ -154,8 +233,12 @@ export interface MetaDetails extends Meta {
|
||||||
season?: number;
|
season?: number;
|
||||||
episode?: number;
|
episode?: number;
|
||||||
thumbnail?: string;
|
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 {
|
export interface AddonCapabilities {
|
||||||
|
|
@ -182,7 +265,7 @@ class StremioService {
|
||||||
private readonly STORAGE_KEY = 'stremio-addons';
|
private readonly STORAGE_KEY = 'stremio-addons';
|
||||||
private readonly ADDON_ORDER_KEY = 'stremio-addon-order';
|
private readonly ADDON_ORDER_KEY = 'stremio-addon-order';
|
||||||
private readonly MAX_CONCURRENT_REQUESTS = 3;
|
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 initialized: boolean = false;
|
||||||
private initializationPromise: Promise<void> | null = null;
|
private initializationPromise: Promise<void> | null = null;
|
||||||
private catalogHasMore: Map<string, boolean> = new Map();
|
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[]> {
|
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 encodedId = encodeURIComponent(id);
|
||||||
const pageSkip = (page - 1) * this.DEFAULT_PAGE_SIZE;
|
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
|
// For all addons
|
||||||
if (!manifest.url) {
|
if (!manifest.url) {
|
||||||
|
|
@ -755,44 +835,68 @@ class StremioService {
|
||||||
try {
|
try {
|
||||||
if (__DEV__) console.log(`🔍 [getCatalog] Manifest URL for ${manifest.name}: ${manifest.url}`);
|
if (__DEV__) console.log(`🔍 [getCatalog] Manifest URL for ${manifest.name}: ${manifest.url}`);
|
||||||
const { baseUrl, queryParams } = this.getAddonBaseURL(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}`;
|
let urlQueryStyle = `${baseUrl}/catalog/${type}/${encodedId}.json?skip=${pageSkip}&limit=${this.DEFAULT_PAGE_SIZE}`;
|
||||||
if (queryParams) urlQueryStyle += `&${queryParams}`;
|
if (queryParams) urlQueryStyle += `&${queryParams}`;
|
||||||
urlQueryStyle += filterQuery;
|
urlQueryStyle += legacyFilterQuery;
|
||||||
|
|
||||||
// For page 1, also try simple URL without skip (some addons don't support skip)
|
// Try URLs in order of compatibility
|
||||||
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
|
|
||||||
let response;
|
let response;
|
||||||
try {
|
try {
|
||||||
// For page 1, try simple URL first (best compatibility)
|
// For page 1 without filters, try simple URL first (best compatibility)
|
||||||
if (pageSkip === 0) {
|
if (pageSkip === 0 && extraParts.length === 0) {
|
||||||
if (__DEV__) console.log(`🔍 [getCatalog] Trying simple URL for ${manifest.name}: ${urlSimpleWithFilters}`);
|
if (__DEV__) console.log(`🔍 [getCatalog] Trying simple URL for ${manifest.name}: ${urlSimple}`);
|
||||||
response = await this.retryRequest(async () => axios.get(urlSimpleWithFilters));
|
response = await this.retryRequest(async () => axios.get(urlSimple));
|
||||||
// Check if we got valid metas - if empty, try other styles
|
// Check if we got valid metas - if empty, try other styles
|
||||||
if (!response?.data?.metas || response.data.metas.length === 0) {
|
if (!response?.data?.metas || response.data.metas.length === 0) {
|
||||||
throw new Error('Empty response from simple URL');
|
throw new Error('Empty response from simple URL');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Not page 1, skip to path-style');
|
throw new Error('Has extra args, use path-style');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
try {
|
try {
|
||||||
if (__DEV__) console.log(`🔍 [getCatalog] Trying path-style URL for ${manifest.name}: ${urlPathWithFilters}`);
|
// Try path-style URL (correct per protocol)
|
||||||
response = await this.retryRequest(async () => axios.get(urlPathWithFilters));
|
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
|
// Check if we got valid metas - if empty, try query-style
|
||||||
if (!response?.data?.metas || response.data.metas.length === 0) {
|
if (!response?.data?.metas || response.data.metas.length === 0) {
|
||||||
throw new Error('Empty response from path-style URL');
|
throw new Error('Empty response from path-style URL');
|
||||||
}
|
}
|
||||||
} catch (e2) {
|
} catch (e2) {
|
||||||
try {
|
try {
|
||||||
|
// Try legacy query-style URL as last resort
|
||||||
if (__DEV__) console.log(`🔍 [getCatalog] Trying query-style URL for ${manifest.name}: ${urlQueryStyle}`);
|
if (__DEV__) console.log(`🔍 [getCatalog] Trying query-style URL for ${manifest.name}: ${urlQueryStyle}`);
|
||||||
response = await this.retryRequest(async () => axios.get(urlQueryStyle));
|
response = await this.retryRequest(async () => axios.get(urlQueryStyle));
|
||||||
} catch (e3) {
|
} catch (e3) {
|
||||||
|
|
@ -1408,6 +1512,11 @@ class StremioService {
|
||||||
return stream.url.url;
|
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) {
|
if (stream.infoHash) {
|
||||||
const trackers = [
|
const trackers = [
|
||||||
'udp://tracker.opentrackr.org:1337/announce',
|
'udp://tracker.opentrackr.org:1337/announce',
|
||||||
|
|
@ -1419,7 +1528,12 @@ class StremioService {
|
||||||
'udp://tracker.coppersurfer.tk:6969/announce',
|
'udp://tracker.coppersurfer.tk:6969/announce',
|
||||||
'udp://tracker.internetwarriors.net:1337/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');
|
const encodedTitle = encodeURIComponent(stream.title || stream.name || 'Unknown');
|
||||||
return `magnet:?xt=urn:btih:${stream.infoHash}&dn=${encodedTitle}${trackersString}`;
|
return `magnet:?xt=urn:btih:${stream.infoHash}&dn=${encodedTitle}${trackersString}`;
|
||||||
}
|
}
|
||||||
|
|
@ -1430,8 +1544,20 @@ class StremioService {
|
||||||
private processStreams(streams: any[], addon: Manifest): Stream[] {
|
private processStreams(streams: any[], addon: Manifest): Stream[] {
|
||||||
return streams
|
return streams
|
||||||
.filter(stream => {
|
.filter(stream => {
|
||||||
// Basic filtering - ensure there's a way to play (URL or infoHash) and identify (title/name)
|
// Basic filtering - ensure there's a way to play per protocol
|
||||||
const hasPlayableLink = !!(stream.url || stream.infoHash);
|
// 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);
|
const hasIdentifier = !!(stream.title || stream.name);
|
||||||
return stream && hasPlayableLink && hasIdentifier;
|
return stream && hasPlayableLink && hasIdentifier;
|
||||||
})
|
})
|
||||||
|
|
@ -1439,6 +1565,8 @@ class StremioService {
|
||||||
const streamUrl = this.getStreamUrl(stream);
|
const streamUrl = this.getStreamUrl(stream);
|
||||||
const isDirectStreamingUrl = this.isDirectStreamingUrl(streamUrl);
|
const isDirectStreamingUrl = this.isDirectStreamingUrl(streamUrl);
|
||||||
const isMagnetStream = streamUrl?.startsWith('magnet:');
|
const isMagnetStream = streamUrl?.startsWith('magnet:');
|
||||||
|
const isExternalUrl = !!stream.externalUrl;
|
||||||
|
const isYouTube = !!stream.ytId;
|
||||||
|
|
||||||
// Prefer full, untruncated text to preserve complete addon details
|
// Prefer full, untruncated text to preserve complete addon details
|
||||||
let displayTitle = stream.title || stream.name || 'Unnamed Stream';
|
let displayTitle = stream.title || stream.name || 'Unnamed Stream';
|
||||||
|
|
@ -1453,12 +1581,20 @@ class StremioService {
|
||||||
// Extract size: Prefer behaviorHints.videoSize, fallback to top-level size
|
// Extract size: Prefer behaviorHints.videoSize, fallback to top-level size
|
||||||
const sizeInBytes = stream.behaviorHints?.videoSize || stream.size || undefined;
|
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'] = {
|
const behaviorHints: Stream['behaviorHints'] = {
|
||||||
notWebReady: !isDirectStreamingUrl,
|
notWebReady: !isDirectStreamingUrl || isExternalUrl,
|
||||||
cached: stream.behaviorHints?.cached || undefined,
|
cached: stream.behaviorHints?.cached || undefined,
|
||||||
bingeGroup: stream.behaviorHints?.bingeGroup || 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 ? {
|
...(isMagnetStream ? {
|
||||||
infoHash: stream.infoHash || streamUrl?.match(/btih:([a-zA-Z0-9]+)/)?.[1],
|
infoHash: stream.infoHash || streamUrl?.match(/btih:([a-zA-Z0-9]+)/)?.[1],
|
||||||
fileIdx: stream.fileIdx,
|
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 = {
|
const processedStream: Stream = {
|
||||||
url: streamUrl,
|
// Primary URL (may be empty for ytId/externalUrl streams)
|
||||||
|
url: streamUrl || undefined,
|
||||||
name: name,
|
name: name,
|
||||||
title: displayTitle,
|
title: displayTitle,
|
||||||
addonName: addon.name,
|
addonName: addon.name,
|
||||||
addonId: addon.id,
|
addonId: addon.id,
|
||||||
|
|
||||||
// Include description as-is to preserve full details
|
// Include description as-is to preserve full details
|
||||||
description: stream.description,
|
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,
|
infoHash: stream.infoHash || undefined,
|
||||||
fileIdx: stream.fileIdx,
|
fileIdx: stream.fileIdx,
|
||||||
|
fileMustInclude: stream.fileMustInclude || undefined,
|
||||||
|
|
||||||
|
// Stream metadata
|
||||||
size: sizeInBytes,
|
size: sizeInBytes,
|
||||||
isFree: stream.isFree,
|
isFree: stream.isFree,
|
||||||
isDebrid: !!(stream.behaviorHints?.cached),
|
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,
|
behaviorHints: behaviorHints,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1553,7 +1718,9 @@ class StremioService {
|
||||||
logger.log(`Fetching subtitles from ${addon.name}: ${url}`);
|
logger.log(`Fetching subtitles from ${addon.name}: ${url}`);
|
||||||
const response = await this.retryRequest(async () => axios.get(url, { timeout: 10000 }));
|
const response = await this.retryRequest(async () => axios.get(url, { timeout: 10000 }));
|
||||||
if (response.data && Array.isArray(response.data.subtitles)) {
|
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,
|
...sub,
|
||||||
addon: addon.id,
|
addon: addon.id,
|
||||||
addonName: addon.name,
|
addonName: addon.name,
|
||||||
|
|
@ -1657,6 +1824,54 @@ class StremioService {
|
||||||
return false;
|
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();
|
export const stremioService = StremioService.getInstance();
|
||||||
|
|
|
||||||
|
|
@ -251,11 +251,25 @@ export interface TraktScrobbleResponse {
|
||||||
alreadyScrobbled?: boolean;
|
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 {
|
export interface TraktContentData {
|
||||||
type: 'movie' | 'episode';
|
type: 'movie' | 'episode';
|
||||||
imdbId: string;
|
imdbId: string;
|
||||||
title: string;
|
title: string;
|
||||||
year: number;
|
/** Release year - optional as Trakt can often resolve content via IMDb ID alone */
|
||||||
|
year?: number;
|
||||||
season?: number;
|
season?: number;
|
||||||
episode?: number;
|
episode?: number;
|
||||||
showTitle?: string;
|
showTitle?: string;
|
||||||
|
|
@ -1527,12 +1541,27 @@ export class TraktService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build scrobble payload for API requests
|
* 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> {
|
private async buildScrobblePayload(contentData: TraktContentData, progress: number): Promise<any | null> {
|
||||||
try {
|
try {
|
||||||
// Clamp progress between 0 and 100 and round to 2 decimals for API
|
// 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));
|
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
|
// Enhanced debug logging for payload building
|
||||||
logger.log('[TraktService] Building scrobble payload:', {
|
logger.log('[TraktService] Building scrobble payload:', {
|
||||||
type: contentData.type,
|
type: contentData.type,
|
||||||
|
|
@ -1548,9 +1577,14 @@ export class TraktService {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (contentData.type === 'movie') {
|
if (contentData.type === 'movie') {
|
||||||
if (!contentData.imdbId || !contentData.title) {
|
// Validate required movie fields
|
||||||
logger.error('[TraktService] Missing movie data for scrobbling:', {
|
if (!contentData.imdbId || contentData.imdbId.trim() === '') {
|
||||||
imdbId: contentData.imdbId,
|
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
|
title: contentData.title
|
||||||
});
|
});
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -1561,36 +1595,70 @@ export class TraktService {
|
||||||
? contentData.imdbId
|
? contentData.imdbId
|
||||||
: `tt${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 = {
|
const payload = {
|
||||||
movie: {
|
movie: movieData,
|
||||||
title: contentData.title,
|
|
||||||
year: contentData.year,
|
|
||||||
ids: {
|
|
||||||
imdb: imdbIdWithPrefix
|
|
||||||
}
|
|
||||||
},
|
|
||||||
progress: clampedProgress
|
progress: clampedProgress
|
||||||
};
|
};
|
||||||
|
|
||||||
logger.log('[TraktService] Movie payload built:', payload);
|
logger.log('[TraktService] Movie payload built:', payload);
|
||||||
return payload;
|
return payload;
|
||||||
} else if (contentData.type === 'episode') {
|
} else if (contentData.type === 'episode') {
|
||||||
if (!contentData.season || !contentData.episode || !contentData.showTitle || !contentData.showYear) {
|
// Validate season and episode numbers
|
||||||
logger.error('[TraktService] Missing episode data for scrobbling:', {
|
if (contentData.season === undefined || contentData.season === null || contentData.season < 0) {
|
||||||
season: contentData.season,
|
logger.error('[TraktService] Invalid season for episode scrobbling:', {
|
||||||
episode: contentData.episode,
|
season: contentData.season
|
||||||
showTitle: contentData.showTitle,
|
|
||||||
showYear: contentData.showYear
|
|
||||||
});
|
});
|
||||||
return null;
|
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 = {
|
const payload: any = {
|
||||||
show: {
|
show: showData,
|
||||||
title: contentData.showTitle,
|
|
||||||
year: contentData.showYear,
|
|
||||||
ids: {}
|
|
||||||
},
|
|
||||||
episode: {
|
episode: {
|
||||||
season: contentData.season,
|
season: contentData.season,
|
||||||
number: contentData.episode
|
number: contentData.episode
|
||||||
|
|
@ -1599,7 +1667,7 @@ export class TraktService {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add show IMDB ID if available
|
// Add show IMDB ID if available
|
||||||
if (contentData.showImdbId) {
|
if (contentData.showImdbId && contentData.showImdbId.trim() !== '') {
|
||||||
const showImdbWithPrefix = contentData.showImdbId.startsWith('tt')
|
const showImdbWithPrefix = contentData.showImdbId.startsWith('tt')
|
||||||
? contentData.showImdbId
|
? contentData.showImdbId
|
||||||
: `tt${contentData.showImdbId}`;
|
: `tt${contentData.showImdbId}`;
|
||||||
|
|
@ -1607,7 +1675,7 @@ export class TraktService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add episode IMDB ID if available (for specific episode IDs)
|
// 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')
|
const episodeImdbWithPrefix = contentData.imdbId.startsWith('tt')
|
||||||
? contentData.imdbId
|
? contentData.imdbId
|
||||||
: `tt${contentData.imdbId}`;
|
: `tt${contentData.imdbId}`;
|
||||||
|
|
|
||||||
|
|
@ -11,42 +11,74 @@ export type RouteParams = {
|
||||||
episodeId?: string;
|
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 {
|
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;
|
name?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
url: string;
|
description?: string;
|
||||||
|
|
||||||
|
// Addon identification
|
||||||
|
addon?: string;
|
||||||
addonId?: string;
|
addonId?: string;
|
||||||
addonName?: string;
|
addonName?: string;
|
||||||
behaviorHints?: {
|
|
||||||
cached?: boolean;
|
// Stream properties
|
||||||
[key: string]: any;
|
size?: number;
|
||||||
};
|
isFree?: boolean;
|
||||||
|
isDebrid?: boolean;
|
||||||
quality?: string;
|
quality?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
lang?: string;
|
lang?: string;
|
||||||
|
fileIdx?: number;
|
||||||
|
|
||||||
headers?: {
|
headers?: {
|
||||||
Referer?: string;
|
Referer?: string;
|
||||||
'User-Agent'?: string;
|
'User-Agent'?: string;
|
||||||
Origin?: string;
|
Origin?: string;
|
||||||
|
[key: string]: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
files?: {
|
files?: {
|
||||||
file: string;
|
file: string;
|
||||||
type: string;
|
type: string;
|
||||||
quality: string;
|
quality: string;
|
||||||
lang: string;
|
lang: string;
|
||||||
}[];
|
}[];
|
||||||
subtitles?: {
|
|
||||||
url: string;
|
subtitles?: Subtitle[];
|
||||||
lang: string;
|
sources?: string[];
|
||||||
}[];
|
|
||||||
addon?: string;
|
behaviorHints?: {
|
||||||
description?: string;
|
bingeGroup?: string;
|
||||||
infoHash?: string;
|
notWebReady?: boolean;
|
||||||
fileIdx?: number;
|
countryWhitelist?: string[];
|
||||||
size?: number;
|
cached?: boolean;
|
||||||
isFree?: boolean;
|
proxyHeaders?: {
|
||||||
isDebrid?: boolean;
|
request?: Record<string, string>;
|
||||||
|
response?: Record<string, string>;
|
||||||
|
};
|
||||||
|
videoHash?: string;
|
||||||
|
videoSize?: number;
|
||||||
|
filename?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GroupedStreams {
|
export interface GroupedStreams {
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,85 @@
|
||||||
export interface Stream {
|
// Source object for archive streams per protocol
|
||||||
name?: string;
|
export interface SourceObject {
|
||||||
title?: string;
|
|
||||||
url: string;
|
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;
|
addonId?: string;
|
||||||
addonName?: string;
|
addonName?: string;
|
||||||
behaviorHints?: {
|
|
||||||
cached?: boolean;
|
// Stream properties
|
||||||
[key: string]: any;
|
size?: number;
|
||||||
};
|
isFree?: boolean;
|
||||||
|
isDebrid?: boolean;
|
||||||
quality?: string;
|
quality?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
lang?: string;
|
lang?: string;
|
||||||
headers?: { [key: string]: string };
|
headers?: Record<string, string>;
|
||||||
|
|
||||||
|
// Legacy files array (for compatibility)
|
||||||
files?: {
|
files?: {
|
||||||
file: string;
|
file: string;
|
||||||
type: string;
|
type: string;
|
||||||
quality: string;
|
quality: string;
|
||||||
lang: string;
|
lang: string;
|
||||||
}[];
|
}[];
|
||||||
subtitles?: {
|
|
||||||
url: string;
|
// Embedded subtitles per protocol
|
||||||
lang: string;
|
subtitles?: Subtitle[];
|
||||||
}[];
|
|
||||||
addon?: string;
|
// Additional tracker/DHT sources
|
||||||
description?: string;
|
sources?: string[];
|
||||||
infoHash?: string;
|
|
||||||
fileIdx?: number;
|
// Complete behavior hints per protocol
|
||||||
size?: number;
|
behaviorHints?: {
|
||||||
isFree?: boolean;
|
bingeGroup?: string; // Group for binge watching
|
||||||
isDebrid?: boolean;
|
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 {
|
export interface GroupedStreams {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue