embedded streams fix

This commit is contained in:
tapframe 2025-12-14 14:10:15 +05:30
parent 3c35b99759
commit c01528b309
8 changed files with 375 additions and 274 deletions

View file

@ -1,4 +1,4 @@
defaults.url=https://sentry.io/ defaults.url=https://sentry.io/
defaults.org=tapframe defaults.org=tapframe
defaults.project=react-native defaults.project=react-native
auth.token=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 # Using SENTRY_AUTH_TOKEN environment variable

View file

@ -477,7 +477,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;
@ -508,8 +508,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";

View file

@ -1,99 +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.10</string> <string>1.2.10</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>25</string> <string>25</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>RCTNewArchEnabled</key> <key>NSLocalNetworkUsageDescription</key>
<true/> <string>Allow $(PRODUCT_NAME) to access your local network</string>
<key>RCTRootViewBackgroundColor</key> <key>NSMicrophoneUsageDescription</key>
<integer>4278322180</integer> <string>This app does not require microphone access.</string>
<key>UIBackgroundModes</key> <key>RCTNewArchEnabled</key>
<array> <true/>
<string>audio</string> <key>RCTRootViewBackgroundColor</key>
</array> <integer>4278322180</integer>
<key>UIFileSharingEnabled</key> <key>UIBackgroundModes</key>
<true/> <array>
<key>UILaunchStoryboardName</key> <string>audio</string>
<string>SplashScreen</string> </array>
<key>UIRequiredDeviceCapabilities</key> <key>UIFileSharingEnabled</key>
<array> <true/>
<string>arm64</string> <key>UILaunchStoryboardName</key>
</array> <string>SplashScreen</string>
<key>UIRequiresFullScreen</key> <key>UIRequiredDeviceCapabilities</key>
<true/> <array>
<key>UIStatusBarStyle</key> <string>arm64</string>
<string>UIStatusBarStyleDefault</string> </array>
<key>UISupportedInterfaceOrientations</key> <key>UIRequiresFullScreen</key>
<array> <true/>
<string>UIInterfaceOrientationPortrait</string> <key>UIStatusBarStyle</key>
<string>UIInterfaceOrientationPortraitUpsideDown</string> <string>UIStatusBarStyleDefault</string>
<string>UIInterfaceOrientationLandscapeLeft</string> <key>UISupportedInterfaceOrientations</key>
<string>UIInterfaceOrientationLandscapeRight</string> <array>
</array> <string>UIInterfaceOrientationPortrait</string>
<key>UISupportedInterfaceOrientations~ipad</key> <string>UIInterfaceOrientationPortraitUpsideDown</string>
<array> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationLandscapeRight</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string> </array>
<string>UIInterfaceOrientationLandscapeLeft</string> <key>UISupportedInterfaceOrientations~ipad</key>
<string>UIInterfaceOrientationLandscapeRight</string> <array>
</array> <string>UIInterfaceOrientationPortrait</string>
<key>UIUserInterfaceStyle</key> <string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>Dark</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<key>UIViewControllerBasedStatusBarAppearance</key> <string>UIInterfaceOrientationLandscapeRight</string>
<false/> </array>
</dict> <key>UIUserInterfaceStyle</key>
</plist> <string>Dark</string>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
</plist>

View file

@ -1,8 +1,10 @@
<?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>aps-environment</key> <key>aps-environment</key>
<string>development</string> <string>development</string>
</dict> <key>com.apple.developer.associated-domains</key>
</plist> <array/>
</dict>
</plist>

View file

@ -1,4 +1,4 @@
defaults.url=https://sentry.io/ defaults.url=https://sentry.io/
defaults.org=tapframe defaults.org=tapframe
defaults.project=react-native defaults.project=react-native
auth.token=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 # Using SENTRY_AUTH_TOKEN environment variable

View file

@ -1064,10 +1064,8 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
const groupedAddonEpisodes: GroupedEpisodes = {}; const groupedAddonEpisodes: GroupedEpisodes = {};
addonVideos.forEach((video: any) => { addonVideos.forEach((video: any) => {
const seasonNumber = video.season; // Use season 0 for videos without season numbers (PPV-style content, specials, etc.)
if (!seasonNumber || seasonNumber < 1) { const seasonNumber = video.season || 0;
return; // Skip season 0, which often contains extras
}
const episodeNumber = video.episode || video.number || 1; const episodeNumber = video.episode || video.number || 1;
if (!groupedAddonEpisodes[seasonNumber]) { if (!groupedAddonEpisodes[seasonNumber]) {
@ -1318,6 +1316,60 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
setError(null); setError(null);
}; };
// Extract embedded streams from metadata videos (used by PPV-style addons)
const extractEmbeddedStreams = useCallback(() => {
if (!metadata?.videos) return;
// Check if any video has embedded streams
const videosWithStreams = (metadata.videos as any[]).filter(
(video: any) => video.streams && Array.isArray(video.streams) && video.streams.length > 0
);
if (videosWithStreams.length === 0) return;
// Get the addon info from metadata if available
const addonId = (metadata as any).addonId || 'embedded';
const addonName = (metadata as any).addonName || metadata.name || 'Embedded Streams';
// Extract all streams from videos
const embeddedStreams: Stream[] = [];
for (const video of videosWithStreams) {
for (const stream of video.streams) {
embeddedStreams.push({
...stream,
name: stream.name || stream.title || video.title,
title: stream.title || video.title,
addonId,
addonName,
});
}
}
if (embeddedStreams.length > 0) {
if (__DEV__) console.log(`✅ [extractEmbeddedStreams] Found ${embeddedStreams.length} embedded streams from ${addonName}`);
// Add to grouped streams
setGroupedStreams(prevStreams => ({
...prevStreams,
[addonId]: {
addonName,
streams: embeddedStreams,
},
}));
// Track addon response order
setAddonResponseOrder(prevOrder => {
if (!prevOrder.includes(addonId)) {
return [...prevOrder, addonId];
}
return prevOrder;
});
// Mark loading as complete since we have streams
setLoadingStreams(false);
}
}, [metadata]);
const loadStreams = async () => { const loadStreams = async () => {
const startTime = Date.now(); const startTime = Date.now();
try { try {
@ -1478,6 +1530,9 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
if (__DEV__) console.log('🎬 [loadStreams] Using ID for Stremio addons:', stremioId); if (__DEV__) console.log('🎬 [loadStreams] Using ID for Stremio addons:', stremioId);
processStremioSource(type, stremioId, false); processStremioSource(type, stremioId, false);
// Also extract any embedded streams from metadata (PPV-style addons)
extractEmbeddedStreams();
// Monitor scraper completion status instead of using fixed timeout // Monitor scraper completion status instead of using fixed timeout
const checkScrapersCompletion = () => { const checkScrapersCompletion = () => {
setScraperStatuses(currentStatuses => { setScraperStatuses(currentStatuses => {
@ -1814,8 +1869,10 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
if (metadata && metadata.videos && metadata.videos.length > 0) { if (metadata && metadata.videos && metadata.videos.length > 0) {
logger.log(`🎬 Metadata updated with ${metadata.videos.length} episodes, reloading series data`); logger.log(`🎬 Metadata updated with ${metadata.videos.length} episodes, reloading series data`);
loadSeriesData().catch((error) => { if (__DEV__) console.error(error); }); loadSeriesData().catch((error) => { if (__DEV__) console.error(error); });
// Also extract embedded streams from metadata videos (PPV-style addons)
extractEmbeddedStreams();
} }
}, [metadata?.videos, type]); }, [metadata?.videos, type, extractEmbeddedStreams]);
const loadRecommendations = useCallback(async () => { const loadRecommendations = useCallback(async () => {
if (!settings.enrichMetadataWithTMDB) { if (!settings.enrichMetadataWithTMDB) {

View file

@ -410,9 +410,9 @@ export const StreamsScreen = () => {
isLoadingStreamsRef.current = true; isLoadingStreamsRef.current = true;
try { try {
// Check for Stremio addons // Check for Stremio addons that support this content type (including embedded streams)
const hasStremioProviders = await stremioService.hasStreamProviders(); const hasStremioProviders = await stremioService.hasStreamProviders(type);
if (__DEV__) console.log('[StreamsScreen] hasStremioProviders:', hasStremioProviders); if (__DEV__) console.log('[StreamsScreen] hasStremioProviders:', hasStremioProviders, 'for type:', type);
// Check for local scrapers (only if enabled in settings) // Check for local scrapers (only if enabled in settings)
const hasLocalScrapers = settings.enableLocalScrapers && await localScraperService.hasScrapers(); const hasLocalScrapers = settings.enableLocalScrapers && await localScraperService.hasScrapers();

View file

@ -153,6 +153,8 @@ export interface MetaDetails extends Meta {
released: string; released: string;
season?: number; season?: number;
episode?: number; episode?: number;
thumbnail?: string;
streams?: Stream[]; // Embedded streams (used by PPV-style addons)
}[]; }[];
} }
@ -194,26 +196,26 @@ class StremioService {
public async isValidContentId(type: string, id: string | null | undefined): Promise<boolean> { public async isValidContentId(type: string, id: string | null | undefined): Promise<boolean> {
// Ensure addons are initialized before checking types // Ensure addons are initialized before checking types
await this.ensureInitialized(); await this.ensureInitialized();
// Get all supported types from installed addons // Get all supported types from installed addons
const supportedTypes = this.getAllSupportedTypes(); const supportedTypes = this.getAllSupportedTypes();
const isValidType = supportedTypes.includes(type); const isValidType = supportedTypes.includes(type);
const lowerId = (id || '').toLowerCase(); const lowerId = (id || '').toLowerCase();
const isNullishId = !id || lowerId === 'null' || lowerId === 'undefined'; const isNullishId = !id || lowerId === 'null' || lowerId === 'undefined';
const providerLikeIds = new Set<string>(['moviebox', 'torbox']); const providerLikeIds = new Set<string>(['moviebox', 'torbox']);
const isProviderSlug = providerLikeIds.has(lowerId); const isProviderSlug = providerLikeIds.has(lowerId);
if (!isValidType || isNullishId || isProviderSlug) return false; if (!isValidType || isNullishId || isProviderSlug) return false;
// Get all supported ID prefixes from installed addons // Get all supported ID prefixes from installed addons
const supportedPrefixes = this.getAllSupportedIdPrefixes(type); const supportedPrefixes = this.getAllSupportedIdPrefixes(type);
// If no addons declare specific prefixes, allow any non-empty string // If no addons declare specific prefixes, allow any non-empty string
if (supportedPrefixes.length === 0) { if (supportedPrefixes.length === 0) {
return true; return true;
} }
// Check if the ID matches any supported prefix // Check if the ID matches any supported prefix
return supportedPrefixes.some(prefix => lowerId.startsWith(prefix.toLowerCase())); return supportedPrefixes.some(prefix => lowerId.startsWith(prefix.toLowerCase()));
} }
@ -222,13 +224,13 @@ class StremioService {
public getAllSupportedTypes(): string[] { public getAllSupportedTypes(): string[] {
const addons = this.getInstalledAddons(); const addons = this.getInstalledAddons();
const types = new Set<string>(); const types = new Set<string>();
for (const addon of addons) { for (const addon of addons) {
// Check addon-level types // Check addon-level types
if (addon.types && Array.isArray(addon.types)) { if (addon.types && Array.isArray(addon.types)) {
addon.types.forEach(type => types.add(type)); addon.types.forEach(type => types.add(type));
} }
// Check resource-level types // Check resource-level types
if (addon.resources && Array.isArray(addon.resources)) { if (addon.resources && Array.isArray(addon.resources)) {
for (const resource of addon.resources) { for (const resource of addon.resources) {
@ -240,7 +242,7 @@ class StremioService {
} }
} }
} }
// Check catalog-level types // Check catalog-level types
if (addon.catalogs && Array.isArray(addon.catalogs)) { if (addon.catalogs && Array.isArray(addon.catalogs)) {
for (const catalog of addon.catalogs) { for (const catalog of addon.catalogs) {
@ -250,7 +252,7 @@ class StremioService {
} }
} }
} }
return Array.from(types); return Array.from(types);
} }
@ -258,13 +260,13 @@ class StremioService {
public getAllSupportedIdPrefixes(type: string): string[] { public getAllSupportedIdPrefixes(type: string): string[] {
const addons = this.getInstalledAddons(); const addons = this.getInstalledAddons();
const prefixes = new Set<string>(); const prefixes = new Set<string>();
for (const addon of addons) { for (const addon of addons) {
// Check addon-level idPrefixes // Check addon-level idPrefixes
if (addon.idPrefixes && Array.isArray(addon.idPrefixes)) { if (addon.idPrefixes && Array.isArray(addon.idPrefixes)) {
addon.idPrefixes.forEach(prefix => prefixes.add(prefix)); addon.idPrefixes.forEach(prefix => prefixes.add(prefix));
} }
// Check resource-level idPrefixes // Check resource-level idPrefixes
if (addon.resources && Array.isArray(addon.resources)) { if (addon.resources && Array.isArray(addon.resources)) {
for (const resource of addon.resources) { for (const resource of addon.resources) {
@ -280,34 +282,34 @@ class StremioService {
} }
} }
} }
return Array.from(prefixes); return Array.from(prefixes);
} }
// Check if a content ID belongs to a collection addon // Check if a content ID belongs to a collection addon
public isCollectionContent(id: string): { isCollection: boolean; addon?: Manifest } { public isCollectionContent(id: string): { isCollection: boolean; addon?: Manifest } {
const addons = this.getInstalledAddons(); const addons = this.getInstalledAddons();
for (const addon of addons) { for (const addon of addons) {
// Check if this addon supports collections // Check if this addon supports collections
const supportsCollections = addon.types?.includes('collections') || const supportsCollections = addon.types?.includes('collections') ||
addon.catalogs?.some(catalog => catalog.type === 'collections'); addon.catalogs?.some(catalog => catalog.type === 'collections');
if (!supportsCollections) continue; if (!supportsCollections) continue;
// Check if our ID matches this addon's prefixes // Check if our ID matches this addon's prefixes
const addonPrefixes = addon.idPrefixes || []; const addonPrefixes = addon.idPrefixes || [];
const resourcePrefixes = addon.resources const resourcePrefixes = addon.resources
?.filter(resource => typeof resource === 'object' && resource !== null && 'name' in resource) ?.filter(resource => typeof resource === 'object' && resource !== null && 'name' in resource)
?.filter(resource => (resource as any).name === 'meta' || (resource as any).name === 'catalog') ?.filter(resource => (resource as any).name === 'meta' || (resource as any).name === 'catalog')
?.flatMap(resource => (resource as any).idPrefixes || []) || []; ?.flatMap(resource => (resource as any).idPrefixes || []) || [];
const allPrefixes = [...addonPrefixes, ...resourcePrefixes]; const allPrefixes = [...addonPrefixes, ...resourcePrefixes];
if (allPrefixes.some(prefix => id.startsWith(prefix))) { if (allPrefixes.some(prefix => id.startsWith(prefix))) {
return { isCollection: true, addon }; return { isCollection: true, addon };
} }
} }
return { isCollection: false }; return { isCollection: false };
} }
@ -320,17 +322,17 @@ class StremioService {
private async initialize(): Promise<void> { private async initialize(): Promise<void> {
if (this.initialized) return; if (this.initialized) return;
try { try {
const scope = (await mmkvStorage.getItem('@user:current')) || 'local'; const scope = (await mmkvStorage.getItem('@user:current')) || 'local';
// Prefer scoped storage, but fall back to legacy keys to preserve older installs // Prefer scoped storage, but fall back to legacy keys to preserve older installs
let storedAddons = await mmkvStorage.getItem(`@user:${scope}:${this.STORAGE_KEY}`); let storedAddons = await mmkvStorage.getItem(`@user:${scope}:${this.STORAGE_KEY}`);
if (!storedAddons) storedAddons = await mmkvStorage.getItem(this.STORAGE_KEY); if (!storedAddons) storedAddons = await mmkvStorage.getItem(this.STORAGE_KEY);
if (!storedAddons) storedAddons = await mmkvStorage.getItem(`@user:local:${this.STORAGE_KEY}`); if (!storedAddons) storedAddons = await mmkvStorage.getItem(`@user:local:${this.STORAGE_KEY}`);
if (storedAddons) { if (storedAddons) {
const parsed = JSON.parse(storedAddons); const parsed = JSON.parse(storedAddons);
// Convert to Map // Convert to Map
this.installedAddons = new Map(); this.installedAddons = new Map();
for (const addon of parsed) { for (const addon of parsed) {
@ -339,11 +341,11 @@ class StremioService {
} }
} }
} }
// Install Cinemeta for new users, but allow existing users to uninstall it // Install Cinemeta for new users, but allow existing users to uninstall it
const cinemetaId = 'com.linvo.cinemeta'; const cinemetaId = 'com.linvo.cinemeta';
const hasUserRemovedCinemeta = await this.hasUserRemovedAddon(cinemetaId); const hasUserRemovedCinemeta = await this.hasUserRemovedAddon(cinemetaId);
if (!this.installedAddons.has(cinemetaId) && !hasUserRemovedCinemeta) { if (!this.installedAddons.has(cinemetaId) && !hasUserRemovedCinemeta) {
try { try {
const cinemetaManifest = await this.getManifest('https://v3-cinemeta.strem.io/manifest.json'); const cinemetaManifest = await this.getManifest('https://v3-cinemeta.strem.io/manifest.json');
@ -395,7 +397,7 @@ class StremioService {
// Install OpenSubtitles v3 by default unless user has explicitly removed it // Install OpenSubtitles v3 by default unless user has explicitly removed it
const opensubsId = 'org.stremio.opensubtitlesv3'; const opensubsId = 'org.stremio.opensubtitlesv3';
const hasUserRemovedOpenSubtitles = await this.hasUserRemovedAddon(opensubsId); const hasUserRemovedOpenSubtitles = await this.hasUserRemovedAddon(opensubsId);
if (!this.installedAddons.has(opensubsId) && !hasUserRemovedOpenSubtitles) { if (!this.installedAddons.has(opensubsId) && !hasUserRemovedOpenSubtitles) {
try { try {
const opensubsManifest = await this.getManifest('https://opensubtitles-v3.strem.io/manifest.json'); const opensubsManifest = await this.getManifest('https://opensubtitles-v3.strem.io/manifest.json');
@ -424,7 +426,7 @@ class StremioService {
this.installedAddons.set(opensubsId, fallbackManifest); this.installedAddons.set(opensubsId, fallbackManifest);
} }
} }
// Load addon order if exists (scoped first, then legacy, then @user:local for migration safety) // Load addon order if exists (scoped first, then legacy, then @user:local for migration safety)
let storedOrder = await mmkvStorage.getItem(`@user:${scope}:${this.ADDON_ORDER_KEY}`); let storedOrder = await mmkvStorage.getItem(`@user:${scope}:${this.ADDON_ORDER_KEY}`);
if (!storedOrder) storedOrder = await mmkvStorage.getItem(this.ADDON_ORDER_KEY); if (!storedOrder) storedOrder = await mmkvStorage.getItem(this.ADDON_ORDER_KEY);
@ -434,28 +436,28 @@ class StremioService {
// Filter out any ids that aren't in installedAddons // Filter out any ids that aren't in installedAddons
this.addonOrder = this.addonOrder.filter(id => this.installedAddons.has(id)); this.addonOrder = this.addonOrder.filter(id => this.installedAddons.has(id));
} }
// Add Cinemeta to order only if user hasn't removed it // Add Cinemeta to order only if user hasn't removed it
const hasUserRemovedCinemetaOrder = await this.hasUserRemovedAddon(cinemetaId); const hasUserRemovedCinemetaOrder = await this.hasUserRemovedAddon(cinemetaId);
if (!this.addonOrder.includes(cinemetaId) && this.installedAddons.has(cinemetaId) && !hasUserRemovedCinemetaOrder) { if (!this.addonOrder.includes(cinemetaId) && this.installedAddons.has(cinemetaId) && !hasUserRemovedCinemetaOrder) {
this.addonOrder.push(cinemetaId); this.addonOrder.push(cinemetaId);
} }
// Only add OpenSubtitles to order if user hasn't removed it // Only add OpenSubtitles to order if user hasn't removed it
const hasUserRemovedOpenSubtitlesOrder = await this.hasUserRemovedAddon(opensubsId); const hasUserRemovedOpenSubtitlesOrder = await this.hasUserRemovedAddon(opensubsId);
if (!this.addonOrder.includes(opensubsId) && this.installedAddons.has(opensubsId) && !hasUserRemovedOpenSubtitlesOrder) { if (!this.addonOrder.includes(opensubsId) && this.installedAddons.has(opensubsId) && !hasUserRemovedOpenSubtitlesOrder) {
this.addonOrder.push(opensubsId); this.addonOrder.push(opensubsId);
} }
// Add any missing addons to the order // Add any missing addons to the order
const installedIds = Array.from(this.installedAddons.keys()); const installedIds = Array.from(this.installedAddons.keys());
const missingIds = installedIds.filter(id => !this.addonOrder.includes(id)); const missingIds = installedIds.filter(id => !this.addonOrder.includes(id));
this.addonOrder = [...this.addonOrder, ...missingIds]; this.addonOrder = [...this.addonOrder, ...missingIds];
// Ensure order and addons are saved // Ensure order and addons are saved
await this.saveAddonOrder(); await this.saveAddonOrder();
await this.saveInstalledAddons(); await this.saveInstalledAddons();
this.initialized = true; this.initialized = true;
} catch (error) { } catch (error) {
// Initialize with empty state on error // Initialize with empty state on error
@ -479,12 +481,12 @@ class StremioService {
return await request(); return await request();
} catch (error: any) { } catch (error: any) {
lastError = error; lastError = error;
// Don't retry on 404 errors (content not found) - these are expected for some content // Don't retry on 404 errors (content not found) - these are expected for some content
if (error.response?.status === 404) { if (error.response?.status === 404) {
throw error; throw error;
} }
// Only log warnings for non-404 errors to reduce noise // Only log warnings for non-404 errors to reduce noise
if (error.response?.status !== 404) { if (error.response?.status !== 404) {
logger.warn(`Request failed (attempt ${attempt + 1}/${retries + 1}):`, { logger.warn(`Request failed (attempt ${attempt + 1}/${retries + 1}):`, {
@ -494,7 +496,7 @@ class StremioService {
status: error.response?.status, status: error.response?.status,
}); });
} }
if (attempt < retries) { if (attempt < retries) {
const backoffDelay = delay * Math.pow(2, attempt); const backoffDelay = delay * Math.pow(2, attempt);
logger.log(`Retrying in ${backoffDelay}ms...`); logger.log(`Retrying in ${backoffDelay}ms...`);
@ -535,25 +537,25 @@ class StremioService {
async getManifest(url: string): Promise<Manifest> { async getManifest(url: string): Promise<Manifest> {
try { try {
// Clean up URL - ensure it ends with manifest.json // Clean up URL - ensure it ends with manifest.json
const manifestUrl = url.endsWith('manifest.json') const manifestUrl = url.endsWith('manifest.json')
? url ? url
: `${url.replace(/\/$/, '')}/manifest.json`; : `${url.replace(/\/$/, '')}/manifest.json`;
const response = await this.retryRequest(async () => { const response = await this.retryRequest(async () => {
return await axios.get(manifestUrl); return await axios.get(manifestUrl);
}); });
const manifest = response.data; const manifest = response.data;
// Add some extra fields for internal use // Add some extra fields for internal use
manifest.originalUrl = url; manifest.originalUrl = url;
manifest.url = url.replace(/manifest\.json$/, ''); manifest.url = url.replace(/manifest\.json$/, '');
// Ensure ID exists // Ensure ID exists
if (!manifest.id) { if (!manifest.id) {
manifest.id = this.formatId(url); manifest.id = this.formatId(url);
} }
return manifest; return manifest;
} catch (error) { } catch (error) {
logger.error(`Failed to fetch manifest from ${url}:`, error); logger.error(`Failed to fetch manifest from ${url}:`, error);
@ -565,16 +567,16 @@ class StremioService {
const manifest = await this.getManifest(url); const manifest = await this.getManifest(url);
if (manifest && manifest.id) { if (manifest && manifest.id) {
this.installedAddons.set(manifest.id, manifest); this.installedAddons.set(manifest.id, manifest);
// If addon was previously removed by user, unmark it on reinstall and clean up // If addon was previously removed by user, unmark it on reinstall and clean up
await this.unmarkAddonAsRemovedByUser(manifest.id); await this.unmarkAddonAsRemovedByUser(manifest.id);
await this.cleanupRemovedAddonFromStorage(manifest.id); await this.cleanupRemovedAddonFromStorage(manifest.id);
// Add to order if not already present (new addons go to the end) // Add to order if not already present (new addons go to the end)
if (!this.addonOrder.includes(manifest.id)) { if (!this.addonOrder.includes(manifest.id)) {
this.addonOrder.push(manifest.id); this.addonOrder.push(manifest.id);
} }
await this.saveInstalledAddons(); await this.saveInstalledAddons();
await this.saveAddonOrder(); await this.saveAddonOrder();
// Emit an event that an addon was added // Emit an event that an addon was added
@ -641,7 +643,7 @@ class StremioService {
const removedAddons = await mmkvStorage.getItem('user_removed_addons'); const removedAddons = await mmkvStorage.getItem('user_removed_addons');
let removedList = removedAddons ? JSON.parse(removedAddons) : []; let removedList = removedAddons ? JSON.parse(removedAddons) : [];
if (!Array.isArray(removedList)) removedList = []; if (!Array.isArray(removedList)) removedList = [];
if (!removedList.includes(addonId)) { if (!removedList.includes(addonId)) {
removedList.push(addonId); removedList.push(addonId);
await mmkvStorage.setItem('user_removed_addons', JSON.stringify(removedList)); await mmkvStorage.setItem('user_removed_addons', JSON.stringify(removedList));
@ -656,10 +658,10 @@ class StremioService {
try { try {
const removedAddons = await mmkvStorage.getItem('user_removed_addons'); const removedAddons = await mmkvStorage.getItem('user_removed_addons');
if (!removedAddons) return; if (!removedAddons) return;
let removedList = JSON.parse(removedAddons); let removedList = JSON.parse(removedAddons);
if (!Array.isArray(removedList)) return; if (!Array.isArray(removedList)) return;
const updatedList = removedList.filter(id => id !== addonId); const updatedList = removedList.filter(id => id !== addonId);
await mmkvStorage.setItem('user_removed_addons', JSON.stringify(updatedList)); await mmkvStorage.setItem('user_removed_addons', JSON.stringify(updatedList));
} catch (error) { } catch (error) {
@ -671,14 +673,14 @@ class StremioService {
private async cleanupRemovedAddonFromStorage(addonId: string): Promise<void> { private async cleanupRemovedAddonFromStorage(addonId: string): Promise<void> {
try { try {
const scope = (await mmkvStorage.getItem('@user:current')) || 'local'; const scope = (await mmkvStorage.getItem('@user:current')) || 'local';
// Remove from all possible addon order storage keys // Remove from all possible addon order storage keys
const keys = [ const keys = [
`@user:${scope}:${this.ADDON_ORDER_KEY}`, `@user:${scope}:${this.ADDON_ORDER_KEY}`,
this.ADDON_ORDER_KEY, this.ADDON_ORDER_KEY,
`@user:local:${this.ADDON_ORDER_KEY}` `@user:local:${this.ADDON_ORDER_KEY}`
]; ];
for (const key of keys) { for (const key of keys) {
const storedOrder = await mmkvStorage.getItem(key); const storedOrder = await mmkvStorage.getItem(key);
if (storedOrder) { if (storedOrder) {
@ -701,12 +703,12 @@ class StremioService {
async getAllCatalogs(): Promise<{ [addonId: string]: Meta[] }> { async getAllCatalogs(): Promise<{ [addonId: string]: Meta[] }> {
const result: { [addonId: string]: Meta[] } = {}; const result: { [addonId: string]: Meta[] } = {};
const addons = this.getInstalledAddons(); const addons = this.getInstalledAddons();
const promises = addons.map(async (addon) => { const promises = addons.map(async (addon) => {
if (!addon.catalogs || addon.catalogs.length === 0) return; if (!addon.catalogs || addon.catalogs.length === 0) return;
const catalog = addon.catalogs[0]; // Just take the first catalog for now const catalog = addon.catalogs[0]; // Just take the first catalog for now
try { try {
const items = await this.getCatalog(addon, catalog.type, catalog.id); const items = await this.getCatalog(addon, catalog.type, catalog.id);
if (items.length > 0) { if (items.length > 0) {
@ -716,7 +718,7 @@ class StremioService {
logger.error(`Failed to fetch catalog from ${addon.name}:`, error); logger.error(`Failed to fetch catalog from ${addon.name}:`, error);
} }
}); });
await Promise.all(promises); await Promise.all(promises);
return result; return result;
} }
@ -724,15 +726,15 @@ class StremioService {
private getAddonBaseURL(url: string): { baseUrl: string; queryParams?: string } { private getAddonBaseURL(url: string): { baseUrl: string; queryParams?: string } {
// Extract query parameters if they exist // Extract query parameters if they exist
const [baseUrl, queryString] = url.split('?'); const [baseUrl, queryString] = url.split('?');
// Remove trailing manifest.json and slashes // Remove trailing manifest.json and slashes
let cleanBaseUrl = baseUrl.replace(/manifest\.json$/, '').replace(/\/$/, ''); let cleanBaseUrl = baseUrl.replace(/manifest\.json$/, '').replace(/\/$/, '');
// Ensure URL has protocol // Ensure URL has protocol
if (!cleanBaseUrl.startsWith('http')) { if (!cleanBaseUrl.startsWith('http')) {
cleanBaseUrl = `https://${cleanBaseUrl}`; cleanBaseUrl = `https://${cleanBaseUrl}`;
} }
return { baseUrl: cleanBaseUrl, queryParams: queryString }; return { baseUrl: cleanBaseUrl, queryParams: queryString };
} }
@ -744,13 +746,14 @@ class StremioService {
.filter(f => f && f.value) .filter(f => f && f.value)
.map(f => `&${encodeURIComponent(f.title)}=${encodeURIComponent(f.value!)}`) .map(f => `&${encodeURIComponent(f.title)}=${encodeURIComponent(f.value!)}`)
.join(''); .join('');
// For all addons // For all addons
if (!manifest.url) { if (!manifest.url) {
throw new Error('Addon URL is missing'); throw new Error('Addon URL is missing');
} }
try { try {
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 // Candidate 1: Path-style skip URL: /catalog/{type}/{id}/skip={N}.json
const urlPathStyle = `${baseUrl}/catalog/${type}/${encodedId}/skip=${pageSkip}.json${queryParams ? `?${queryParams}` : ''}`; const urlPathStyle = `${baseUrl}/catalog/${type}/${encodedId}/skip=${pageSkip}.json${queryParams ? `?${queryParams}` : ''}`;
@ -762,15 +765,40 @@ class StremioService {
if (queryParams) urlQueryStyle += `&${queryParams}`; if (queryParams) urlQueryStyle += `&${queryParams}`;
urlQueryStyle += filterQuery; urlQueryStyle += filterQuery;
// Try path-style first, then fallback to query-style // 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
let response; let response;
try { try {
response = await this.retryRequest(async () => axios.get(urlPathWithFilters)); // 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));
// 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');
}
} catch (e) { } catch (e) {
try { try {
response = await this.retryRequest(async () => axios.get(urlQueryStyle)); if (__DEV__) console.log(`🔍 [getCatalog] Trying path-style URL for ${manifest.name}: ${urlPathWithFilters}`);
response = await this.retryRequest(async () => axios.get(urlPathWithFilters));
// 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) { } catch (e2) {
throw e2; try {
if (__DEV__) console.log(`🔍 [getCatalog] Trying query-style URL for ${manifest.name}: ${urlQueryStyle}`);
response = await this.retryRequest(async () => axios.get(urlQueryStyle));
} catch (e3) {
if (__DEV__) console.log(`❌ [getCatalog] All URL styles failed for ${manifest.name}`);
throw e3;
}
} }
} }
@ -779,7 +807,7 @@ class StremioService {
try { try {
const key = `${manifest.id}|${type}|${id}`; const key = `${manifest.id}|${type}|${id}`;
if (typeof hasMore === 'boolean') this.catalogHasMore.set(key, hasMore); if (typeof hasMore === 'boolean') this.catalogHasMore.set(key, hasMore);
} catch {} } catch { }
if (response.data.metas && Array.isArray(response.data.metas)) { if (response.data.metas && Array.isArray(response.data.metas)) {
return response.data.metas; return response.data.metas;
} }
@ -800,13 +828,13 @@ class StremioService {
try { try {
// Validate content ID first // Validate content ID first
const isValidId = await this.isValidContentId(type, id); const isValidId = await this.isValidContentId(type, id);
if (!isValidId) { if (!isValidId) {
return null; return null;
} }
const addons = this.getInstalledAddons(); const addons = this.getInstalledAddons();
// If a preferred addon is specified, try it first // If a preferred addon is specified, try it first
if (preferredAddonId) { if (preferredAddonId) {
const preferredAddon = addons.find(addon => addon.id === preferredAddonId); const preferredAddon = addons.find(addon => addon.id === preferredAddonId);
@ -820,14 +848,14 @@ class StremioService {
// Check if addon supports meta resource for this type // Check if addon supports meta resource for this type
let hasMetaSupport = false; let hasMetaSupport = false;
let supportsIdPrefix = false; let supportsIdPrefix = false;
for (const resource of preferredAddon.resources) { for (const resource of preferredAddon.resources) {
// Check if the current element is a ResourceObject // Check if the current element is a ResourceObject
if (typeof resource === 'object' && resource !== null && 'name' in resource) { if (typeof resource === 'object' && resource !== null && 'name' in resource) {
const typedResource = resource as ResourceObject; const typedResource = resource as ResourceObject;
if (typedResource.name === 'meta' && if (typedResource.name === 'meta' &&
Array.isArray(typedResource.types) && Array.isArray(typedResource.types) &&
typedResource.types.includes(type)) { typedResource.types.includes(type)) {
hasMetaSupport = true; hasMetaSupport = true;
// Check idPrefix support // Check idPrefix support
if (Array.isArray(typedResource.idPrefixes) && typedResource.idPrefixes.length > 0) { if (Array.isArray(typedResource.idPrefixes) && typedResource.idPrefixes.length > 0) {
@ -837,7 +865,7 @@ class StremioService {
} }
break; break;
} }
} }
// Check if the element is the simple string "meta" AND the addon has a top-level types array // Check if the element is the simple string "meta" AND the addon has a top-level types array
else if (typeof resource === 'string' && resource === 'meta' && preferredAddon.types) { else if (typeof resource === 'string' && resource === 'meta' && preferredAddon.types) {
if (Array.isArray(preferredAddon.types) && preferredAddon.types.includes(type)) { if (Array.isArray(preferredAddon.types) && preferredAddon.types.includes(type)) {
@ -852,19 +880,19 @@ class StremioService {
} }
} }
} }
// Only require ID prefix compatibility if the addon has declared specific prefixes // Only require ID prefix compatibility if the addon has declared specific prefixes
const requiresIdPrefix = preferredAddon.idPrefixes && preferredAddon.idPrefixes.length > 0; const requiresIdPrefix = preferredAddon.idPrefixes && preferredAddon.idPrefixes.length > 0;
const isSupported = hasMetaSupport && (!requiresIdPrefix || supportsIdPrefix); const isSupported = hasMetaSupport && (!requiresIdPrefix || supportsIdPrefix);
if (isSupported) { if (isSupported) {
try { try {
const response = await this.retryRequest(async () => { const response = await this.retryRequest(async () => {
return await axios.get(url, { timeout: 10000 }); return await axios.get(url, { timeout: 10000 });
}); });
if (response.data && response.data.meta) { if (response.data && response.data.meta) {
return response.data.meta; return response.data.meta;
} else { } else {
@ -876,25 +904,25 @@ class StremioService {
} }
} }
} }
// Try Cinemeta with different base URLs // Try Cinemeta with different base URLs
const cinemetaUrls = [ const cinemetaUrls = [
'https://v3-cinemeta.strem.io', 'https://v3-cinemeta.strem.io',
'http://v3-cinemeta.strem.io' 'http://v3-cinemeta.strem.io'
]; ];
for (const baseUrl of cinemetaUrls) { for (const baseUrl of cinemetaUrls) {
try { try {
const encodedId = encodeURIComponent(id); const encodedId = encodeURIComponent(id);
const url = `${baseUrl}/meta/${type}/${encodedId}.json`; const url = `${baseUrl}/meta/${type}/${encodedId}.json`;
const response = await this.retryRequest(async () => { const response = await this.retryRequest(async () => {
return await axios.get(url, { timeout: 10000 }); return await axios.get(url, { timeout: 10000 });
}); });
if (response.data && response.data.meta) { if (response.data && response.data.meta) {
return response.data.meta; return response.data.meta;
} else { } else {
@ -907,18 +935,18 @@ class StremioService {
// If Cinemeta fails, try other addons (excluding the preferred one already tried) // If Cinemeta fails, try other addons (excluding the preferred one already tried)
for (const addon of addons) { for (const addon of addons) {
if (!addon.resources || addon.id === 'com.linvo.cinemeta' || addon.id === preferredAddonId) continue; if (!addon.resources || addon.id === 'com.linvo.cinemeta' || addon.id === preferredAddonId) continue;
// Check if addon supports meta resource for this type AND idPrefix (handles both string and object formats) // Check if addon supports meta resource for this type AND idPrefix (handles both string and object formats)
let hasMetaSupport = false; let hasMetaSupport = false;
let supportsIdPrefix = false; let supportsIdPrefix = false;
for (const resource of addon.resources) { for (const resource of addon.resources) {
// Check if the current element is a ResourceObject // Check if the current element is a ResourceObject
if (typeof resource === 'object' && resource !== null && 'name' in resource) { if (typeof resource === 'object' && resource !== null && 'name' in resource) {
const typedResource = resource as ResourceObject; const typedResource = resource as ResourceObject;
if (typedResource.name === 'meta' && if (typedResource.name === 'meta' &&
Array.isArray(typedResource.types) && Array.isArray(typedResource.types) &&
typedResource.types.includes(type)) { typedResource.types.includes(type)) {
hasMetaSupport = true; hasMetaSupport = true;
// Match idPrefixes if present; otherwise assume support // Match idPrefixes if present; otherwise assume support
if (Array.isArray(typedResource.idPrefixes) && typedResource.idPrefixes.length > 0) { if (Array.isArray(typedResource.idPrefixes) && typedResource.idPrefixes.length > 0) {
@ -928,7 +956,7 @@ class StremioService {
} }
break; break;
} }
} }
// Check if the element is the simple string "meta" AND the addon has a top-level types array // Check if the element is the simple string "meta" AND the addon has a top-level types array
else if (typeof resource === 'string' && resource === 'meta' && addon.types) { else if (typeof resource === 'string' && resource === 'meta' && addon.types) {
if (Array.isArray(addon.types) && addon.types.includes(type)) { if (Array.isArray(addon.types) && addon.types.includes(type)) {
@ -943,28 +971,28 @@ class StremioService {
} }
} }
} }
// Require meta support, but allow any ID if addon doesn't declare specific prefixes // Require meta support, but allow any ID if addon doesn't declare specific prefixes
// Only require ID prefix compatibility if the addon has declared specific prefixes // Only require ID prefix compatibility if the addon has declared specific prefixes
const requiresIdPrefix = addon.idPrefixes && addon.idPrefixes.length > 0; const requiresIdPrefix = addon.idPrefixes && addon.idPrefixes.length > 0;
const isSupported = hasMetaSupport && (!requiresIdPrefix || supportsIdPrefix); const isSupported = hasMetaSupport && (!requiresIdPrefix || supportsIdPrefix);
if (!isSupported) { if (!isSupported) {
continue; continue;
} }
try { try {
const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url || ''); const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url || '');
const encodedId = encodeURIComponent(id); const encodedId = encodeURIComponent(id);
const url = queryParams ? `${baseUrl}/meta/${type}/${encodedId}.json?${queryParams}` : `${baseUrl}/meta/${type}/${encodedId}.json`; const url = queryParams ? `${baseUrl}/meta/${type}/${encodedId}.json?${queryParams}` : `${baseUrl}/meta/${type}/${encodedId}.json`;
const response = await this.retryRequest(async () => { const response = await this.retryRequest(async () => {
return await axios.get(url, { timeout: 10000 }); return await axios.get(url, { timeout: 10000 });
}); });
if (response.data && response.data.meta) { if (response.data && response.data.meta) {
return response.data.meta; return response.data.meta;
} else { } else {
@ -973,7 +1001,7 @@ class StremioService {
continue; // Try next addon continue; // Try next addon
} }
} }
return null; return null;
} catch (error) { } catch (error) {
logger.error('Error in getMetaDetails:', error); logger.error('Error in getMetaDetails:', error);
@ -986,8 +1014,8 @@ class StremioService {
* This prevents over-fetching all episode data and reduces memory consumption * This prevents over-fetching all episode data and reduces memory consumption
*/ */
async getUpcomingEpisodes( async getUpcomingEpisodes(
type: string, type: string,
id: string, id: string,
options: { options: {
daysBack?: number; daysBack?: number;
daysAhead?: number; daysAhead?: number;
@ -996,7 +1024,7 @@ class StremioService {
} = {} } = {}
): Promise<{ seriesName: string; poster: string; episodes: any[] } | null> { ): Promise<{ seriesName: string; poster: string; episodes: any[] } | null> {
const { daysBack = 14, daysAhead = 28, maxEpisodes = 50, preferredAddonId } = options; const { daysBack = 14, daysAhead = 28, maxEpisodes = 50, preferredAddonId } = options;
try { try {
// Get metadata first (this is lightweight compared to episodes) // Get metadata first (this is lightweight compared to episodes)
const metadata = await this.getMetaDetails(type, id, preferredAddonId); const metadata = await this.getMetaDetails(type, id, preferredAddonId);
@ -1048,10 +1076,9 @@ class StremioService {
// Modify getStreams to use this.getInstalledAddons() instead of getEnabledAddons // Modify getStreams to use this.getInstalledAddons() instead of getEnabledAddons
async getStreams(type: string, id: string, callback?: StreamCallback): Promise<void> { async getStreams(type: string, id: string, callback?: StreamCallback): Promise<void> {
await this.ensureInitialized(); await this.ensureInitialized();
const addons = this.getInstalledAddons(); const addons = this.getInstalledAddons();
logger.log('📌 [getStreams] Installed addons:', addons.map(a => ({ id: a.id, name: a.name, url: a.url })));
// Check if local scrapers are enabled and execute them first // Check if local scrapers are enabled and execute them first
try { try {
// Load settings from AsyncStorage directly (scoped with fallback) // Load settings from AsyncStorage directly (scoped with fallback)
@ -1060,25 +1087,25 @@ class StremioService {
|| (await mmkvStorage.getItem('app_settings')); || (await mmkvStorage.getItem('app_settings'));
const rawSettings = settingsJson ? JSON.parse(settingsJson) : {}; const rawSettings = settingsJson ? JSON.parse(settingsJson) : {};
const settings: AppSettings = { ...DEFAULT_SETTINGS, ...rawSettings }; const settings: AppSettings = { ...DEFAULT_SETTINGS, ...rawSettings };
if (settings.enableLocalScrapers) { if (settings.enableLocalScrapers) {
const hasScrapers = await localScraperService.hasScrapers(); const hasScrapers = await localScraperService.hasScrapers();
if (hasScrapers) { if (hasScrapers) {
logger.log('🔧 [getStreams] Executing local scrapers for', type, id); logger.log('🔧 [getStreams] Executing local scrapers for', type, id);
// Map Stremio types to local scraper types // Map Stremio types to local scraper types
const scraperType = type === 'series' ? 'tv' : type; const scraperType = type === 'series' ? 'tv' : type;
// Parse the Stremio ID to extract ID and season/episode info // Parse the Stremio ID to extract ID and season/episode info
let tmdbId: string | null = null; let tmdbId: string | null = null;
let season: number | undefined = undefined; let season: number | undefined = undefined;
let episode: number | undefined = undefined; let episode: number | undefined = undefined;
let idType: 'imdb' | 'kitsu' | 'tmdb' = 'imdb'; let idType: 'imdb' | 'kitsu' | 'tmdb' = 'imdb';
try { try {
const idParts = id.split(':'); const idParts = id.split(':');
let baseId: string; let baseId: string;
// Handle different episode ID formats // Handle different episode ID formats
if (idParts[0] === 'series') { if (idParts[0] === 'series') {
// Format: series:imdbId:season:episode or series:kitsu:7442:season:episode // Format: series:imdbId:season:episode or series:kitsu:7442:season:episode
@ -1128,7 +1155,7 @@ class StremioService {
episode = parseInt(idParts[2], 10); episode = parseInt(idParts[2], 10);
} }
} }
// Handle ID conversion for local scrapers (they need TMDB ID) // Handle ID conversion for local scrapers (they need TMDB ID)
if (idType === 'imdb') { if (idType === 'imdb') {
// Convert IMDb ID to TMDB ID // Convert IMDb ID to TMDB ID
@ -1154,7 +1181,7 @@ class StremioService {
} catch (error) { } catch (error) {
logger.warn('🔧 [getStreams] Skipping local scrapers due to ID parsing error:', error); logger.warn('🔧 [getStreams] Skipping local scrapers due to ID parsing error:', error);
} }
// Execute local scrapers asynchronously with TMDB ID (when available) // Execute local scrapers asynchronously with TMDB ID (when available)
if (tmdbId) { if (tmdbId) {
localScraperService.getStreams(scraperType, tmdbId, season, episode, (streams, scraperId, scraperName, error) => { localScraperService.getStreams(scraperType, tmdbId, season, episode, (streams, scraperId, scraperName, error) => {
@ -1191,13 +1218,13 @@ class StremioService {
} catch (error) { } catch (error) {
// Continue even if local scrapers fail // Continue even if local scrapers fail
} }
// Check specifically for TMDB Embed addon // Check specifically for TMDB Embed addon
const tmdbEmbed = addons.find(addon => addon.id === 'org.tmdbembedapi'); const tmdbEmbed = addons.find(addon => addon.id === 'org.tmdbembedapi');
if (!tmdbEmbed) { if (!tmdbEmbed) {
// TMDB Embed addon not found // TMDB Embed addon not found
} }
// Find addons that provide streams and sort them by installation order // Find addons that provide streams and sort them by installation order
const streamAddons = addons const streamAddons = addons
.filter(addon => { .filter(addon => {
@ -1205,35 +1232,30 @@ class StremioService {
logger.log(`⚠️ [getStreams] Addon ${addon.id} has no valid resources array`); logger.log(`⚠️ [getStreams] Addon ${addon.id} has no valid resources array`);
return false; return false;
} }
// Log the detailed resources structure for debugging
logger.log(`📋 [getStreams] Checking addon ${addon.id} resources:`, JSON.stringify(addon.resources));
let hasStreamResource = false; let hasStreamResource = false;
let supportsIdPrefix = false; let supportsIdPrefix = false;
// Iterate through the resources array, checking each element // Iterate through the resources array, checking each element
for (const resource of addon.resources) { for (const resource of addon.resources) {
// Check if the current element is a ResourceObject // Check if the current element is a ResourceObject
if (typeof resource === 'object' && resource !== null && 'name' in resource) { if (typeof resource === 'object' && resource !== null && 'name' in resource) {
const typedResource = resource as ResourceObject; const typedResource = resource as ResourceObject;
if (typedResource.name === 'stream' && if (typedResource.name === 'stream' &&
Array.isArray(typedResource.types) && Array.isArray(typedResource.types) &&
typedResource.types.includes(type)) { typedResource.types.includes(type)) {
hasStreamResource = true; hasStreamResource = true;
// Check if this addon supports the ID prefix (generic: any prefix that matches start of id) // Check if this addon supports the ID prefix (generic: any prefix that matches start of id)
if (Array.isArray(typedResource.idPrefixes) && typedResource.idPrefixes.length > 0) { if (Array.isArray(typedResource.idPrefixes) && typedResource.idPrefixes.length > 0) {
supportsIdPrefix = typedResource.idPrefixes.some(p => id.startsWith(p)); supportsIdPrefix = typedResource.idPrefixes.some(p => id.startsWith(p));
logger.log(`🔍 [getStreams] Addon ${addon.id} supports prefixes: ${typedResource.idPrefixes.join(', ')} → matches=${supportsIdPrefix}`);
} else { } else {
// If no idPrefixes specified, assume it supports all prefixes // If no idPrefixes specified, assume it supports all prefixes
supportsIdPrefix = true; supportsIdPrefix = true;
logger.log(`🔍 [getStreams] Addon ${addon.id} has no prefix restrictions, assuming support`);
} }
break; // Found the stream resource object, no need to check further break; // Found the stream resource object, no need to check further
} }
} }
// Check if the element is the simple string "stream" AND the addon has a top-level types array // Check if the element is the simple string "stream" AND the addon has a top-level types array
else if (typeof resource === 'string' && resource === 'stream' && addon.types) { else if (typeof resource === 'string' && resource === 'stream' && addon.types) {
if (Array.isArray(addon.types) && addon.types.includes(type)) { if (Array.isArray(addon.types) && addon.types.includes(type)) {
@ -1241,32 +1263,22 @@ class StremioService {
// For simple string resources, check addon-level idPrefixes (generic) // For simple string resources, check addon-level idPrefixes (generic)
if (addon.idPrefixes && Array.isArray(addon.idPrefixes) && addon.idPrefixes.length > 0) { if (addon.idPrefixes && Array.isArray(addon.idPrefixes) && addon.idPrefixes.length > 0) {
supportsIdPrefix = addon.idPrefixes.some(p => id.startsWith(p)); supportsIdPrefix = addon.idPrefixes.some(p => id.startsWith(p));
logger.log(`🔍 [getStreams] Addon ${addon.id} supports prefixes: ${addon.idPrefixes.join(', ')} → matches=${supportsIdPrefix}`);
} else { } else {
// If no idPrefixes specified, assume it supports all prefixes // If no idPrefixes specified, assume it supports all prefixes
supportsIdPrefix = true; supportsIdPrefix = true;
logger.log(`🔍 [getStreams] Addon ${addon.id} has no prefix restrictions, assuming support`);
} }
break; // Found the simple stream resource string and type support break; // Found the simple stream resource string and type support
} }
} }
} }
const canHandleRequest = hasStreamResource && supportsIdPrefix; const canHandleRequest = hasStreamResource && supportsIdPrefix;
if (!hasStreamResource) {
logger.log(`❌ [getStreams] Addon ${addon.id} does not support streaming ${type}`);
} else if (!supportsIdPrefix) {
logger.log(`❌ [getStreams] Addon ${addon.id} supports ${type} but its idPrefixes did not match id=${id}`);
} else {
logger.log(`✅ [getStreams] Addon ${addon.id} supports streaming ${type} for id=${id}`);
}
return canHandleRequest; return canHandleRequest;
}); });
logger.log('📊 [getStreams] Stream capable addons:', streamAddons.map(a => a.id));
if (streamAddons.length === 0) { if (streamAddons.length === 0) {
logger.warn('⚠️ [getStreams] No addons found that can provide streams'); logger.warn('⚠️ [getStreams] No addons found that can provide streams');
// Optionally call callback with an empty result or specific status? // Optionally call callback with an empty result or specific status?
@ -1276,7 +1288,7 @@ class StremioService {
// Process each addon and call the callback individually // Process each addon and call the callback individually
streamAddons.forEach(addon => { streamAddons.forEach(addon => {
// Use an IIFE to create scope for async operation inside forEach // Use an IIFE to create scope for async operation inside forEach
(async () => { (async () => {
try { try {
if (!addon.url) { if (!addon.url) {
@ -1288,9 +1300,9 @@ class StremioService {
const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url); const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url);
const encodedId = encodeURIComponent(id); const encodedId = encodeURIComponent(id);
const url = queryParams ? `${baseUrl}/stream/${type}/${encodedId}.json?${queryParams}` : `${baseUrl}/stream/${type}/${encodedId}.json`; const url = queryParams ? `${baseUrl}/stream/${type}/${encodedId}.json?${queryParams}` : `${baseUrl}/stream/${type}/${encodedId}.json`;
logger.log(`🔗 [getStreams] Requesting streams from ${addon.name} (${addon.id}): ${url}`); logger.log(`🔗 [getStreams] Requesting streams from ${addon.name} (${addon.id}): ${url}`);
const response = await this.retryRequest(async () => { const response = await this.retryRequest(async () => {
return await axios.get(url); return await axios.get(url);
}); });
@ -1301,7 +1313,7 @@ class StremioService {
processedStreams = this.processStreams(response.data.streams, addon); processedStreams = this.processStreams(response.data.streams, addon);
logger.log(`✅ [getStreams] Processed ${processedStreams.length} valid streams from ${addon.name} (${addon.id})`); logger.log(`✅ [getStreams] Processed ${processedStreams.length} valid streams from ${addon.name} (${addon.id})`);
} else { } else {
logger.log(`⚠️ [getStreams] No streams found in response from ${addon.name} (${addon.id})`); logger.log(`⚠️ [getStreams] No streams found in response from ${addon.name} (${addon.id})`);
} }
if (callback) { if (callback) {
@ -1328,21 +1340,21 @@ class StremioService {
logger.warn(`Addon ${addon.id} has no URL defined`); logger.warn(`Addon ${addon.id} has no URL defined`);
return null; return null;
} }
const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url); const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url);
const encodedId = encodeURIComponent(id); const encodedId = encodeURIComponent(id);
const streamPath = `/stream/${type}/${encodedId}.json`; const streamPath = `/stream/${type}/${encodedId}.json`;
const url = queryParams ? `${baseUrl}${streamPath}?${queryParams}` : `${baseUrl}${streamPath}`; const url = queryParams ? `${baseUrl}${streamPath}?${queryParams}` : `${baseUrl}${streamPath}`;
logger.log(`Fetching streams from URL: ${url}`); logger.log(`Fetching streams from URL: ${url}`);
try { try {
// Increase timeout for debrid services // Increase timeout for debrid services
const timeout = addon.id.toLowerCase().includes('torrentio') ? 60000 : 10000; const timeout = addon.id.toLowerCase().includes('torrentio') ? 60000 : 10000;
const response = await this.retryRequest(async () => { const response = await this.retryRequest(async () => {
logger.log(`Making request to ${url} with timeout ${timeout}ms`); logger.log(`Making request to ${url} with timeout ${timeout}ms`);
return await axios.get(url, { return await axios.get(url, {
timeout, timeout,
headers: { headers: {
'Accept': 'application/json', 'Accept': 'application/json',
@ -1350,11 +1362,11 @@ class StremioService {
} }
}); });
}, 5); // Increase retries for stream fetching }, 5); // Increase retries for stream fetching
if (response.data && response.data.streams && Array.isArray(response.data.streams)) { if (response.data && response.data.streams && Array.isArray(response.data.streams)) {
const streams = this.processStreams(response.data.streams, addon); const streams = this.processStreams(response.data.streams, addon);
logger.log(`Successfully processed ${streams.length} streams from ${addon.id}`); logger.log(`Successfully processed ${streams.length} streams from ${addon.id}`);
return { return {
streams, streams,
addon: addon.id, addon: addon.id,
@ -1377,7 +1389,7 @@ class StremioService {
// Re-throw the error with more context // Re-throw the error with more context
throw new Error(`Failed to fetch streams from ${addon.name}: ${error.message}`); throw new Error(`Failed to fetch streams from ${addon.name}: ${error.message}`);
} }
return null; return null;
} }
@ -1495,11 +1507,11 @@ class StremioService {
items: Meta[]; items: Meta[];
}> { }> {
const addon = this.getInstalledAddons().find(a => a.id === addonId); const addon = this.getInstalledAddons().find(a => a.id === addonId);
if (!addon) { if (!addon) {
throw new Error(`Addon ${addonId} not found`); throw new Error(`Addon ${addonId} not found`);
} }
const items = await this.getCatalog(addon, type, id); const items = await this.getCatalog(addon, type, id);
return { return {
addon: addonId, addon: addonId,
@ -1596,22 +1608,48 @@ class StremioService {
return false; return false;
} }
// Check if any installed addons can provide streams // Check if any installed addons can provide streams (including embedded streams in metadata)
async hasStreamProviders(): Promise<boolean> { async hasStreamProviders(type?: string): Promise<boolean> {
await this.ensureInitialized(); await this.ensureInitialized();
const addons = Array.from(this.installedAddons.values()); const addons = Array.from(this.installedAddons.values());
for (const addon of addons) { for (const addon of addons) {
if (addon.resources && Array.isArray(addon.resources)) { if (addon.resources && Array.isArray(addon.resources)) {
// Check for 'stream' resource in the modern format // Check for explicit 'stream' resource
const hasStreamResource = addon.resources.some(resource => const hasStreamResource = addon.resources.some(resource =>
typeof resource === 'string' typeof resource === 'string'
? resource === 'stream' ? resource === 'stream'
: resource.name === 'stream' : (resource as any).name === 'stream'
); );
if (hasStreamResource) { if (hasStreamResource) {
return true; // If type specified, also check if addon supports this type
if (type) {
const supportsType = addon.types?.includes(type) ||
addon.resources.some(resource =>
typeof resource === 'object' &&
(resource as any).name === 'stream' &&
(resource as any).types?.includes(type)
);
if (supportsType) return true;
} else {
return true;
}
}
// Also check for addons with meta resource that support the type
// These addons might provide embedded streams within metadata
if (type) {
const hasMetaResource = addon.resources.some(resource =>
typeof resource === 'string'
? resource === 'meta'
: (resource as any).name === 'meta'
);
if (hasMetaResource && addon.types?.includes(type)) {
// This addon provides meta for the type - might have embedded streams
return true;
}
} }
} }
} }