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.org=tapframe
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";
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app;
PRODUCT_NAME = Nuvio;
PRODUCT_NAME = "Nuvio";
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
@ -508,8 +508,8 @@
"-lc++",
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.hub;
PRODUCT_NAME = Nuvio;
PRODUCT_BUNDLE_IDENTIFIER = "com.nuvio.app";
PRODUCT_NAME = "Nuvio";
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";

View file

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

View file

@ -1,8 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
</dict>
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.associated-domains</key>
<array/>
</dict>
</plist>

View file

@ -1,4 +1,4 @@
defaults.url=https://sentry.io/
defaults.org=tapframe
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 = {};
addonVideos.forEach((video: any) => {
const seasonNumber = video.season;
if (!seasonNumber || seasonNumber < 1) {
return; // Skip season 0, which often contains extras
}
// Use season 0 for videos without season numbers (PPV-style content, specials, etc.)
const seasonNumber = video.season || 0;
const episodeNumber = video.episode || video.number || 1;
if (!groupedAddonEpisodes[seasonNumber]) {
@ -1318,6 +1316,60 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
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 startTime = Date.now();
try {
@ -1478,6 +1530,9 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
if (__DEV__) console.log('🎬 [loadStreams] Using ID for Stremio addons:', stremioId);
processStremioSource(type, stremioId, false);
// Also extract any embedded streams from metadata (PPV-style addons)
extractEmbeddedStreams();
// Monitor scraper completion status instead of using fixed timeout
const checkScrapersCompletion = () => {
setScraperStatuses(currentStatuses => {
@ -1814,8 +1869,10 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
if (metadata && metadata.videos && metadata.videos.length > 0) {
logger.log(`🎬 Metadata updated with ${metadata.videos.length} episodes, reloading series data`);
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 () => {
if (!settings.enrichMetadataWithTMDB) {

View file

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

View file

@ -153,6 +153,8 @@ export interface MetaDetails extends Meta {
released: string;
season?: number;
episode?: number;
thumbnail?: string;
streams?: Stream[]; // Embedded streams (used by PPV-style addons)
}[];
}
@ -291,7 +293,7 @@ class StremioService {
for (const addon of addons) {
// Check if this addon supports collections
const supportsCollections = addon.types?.includes('collections') ||
addon.catalogs?.some(catalog => catalog.type === 'collections');
addon.catalogs?.some(catalog => catalog.type === 'collections');
if (!supportsCollections) continue;
@ -751,6 +753,7 @@ class StremioService {
}
try {
if (__DEV__) console.log(`🔍 [getCatalog] Manifest URL for ${manifest.name}: ${manifest.url}`);
const { baseUrl, queryParams } = this.getAddonBaseURL(manifest.url);
// Candidate 1: Path-style skip URL: /catalog/{type}/{id}/skip={N}.json
const urlPathStyle = `${baseUrl}/catalog/${type}/${encodedId}/skip=${pageSkip}.json${queryParams ? `?${queryParams}` : ''}`;
@ -762,15 +765,40 @@ class StremioService {
if (queryParams) urlQueryStyle += `&${queryParams}`;
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;
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) {
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) {
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 {
const key = `${manifest.id}|${type}|${id}`;
if (typeof hasMore === 'boolean') this.catalogHasMore.set(key, hasMore);
} catch {}
} catch { }
if (response.data.metas && Array.isArray(response.data.metas)) {
return response.data.metas;
}
@ -826,8 +854,8 @@ class StremioService {
if (typeof resource === 'object' && resource !== null && 'name' in resource) {
const typedResource = resource as ResourceObject;
if (typedResource.name === 'meta' &&
Array.isArray(typedResource.types) &&
typedResource.types.includes(type)) {
Array.isArray(typedResource.types) &&
typedResource.types.includes(type)) {
hasMetaSupport = true;
// Check idPrefix support
if (Array.isArray(typedResource.idPrefixes) && typedResource.idPrefixes.length > 0) {
@ -917,8 +945,8 @@ class StremioService {
if (typeof resource === 'object' && resource !== null && 'name' in resource) {
const typedResource = resource as ResourceObject;
if (typedResource.name === 'meta' &&
Array.isArray(typedResource.types) &&
typedResource.types.includes(type)) {
Array.isArray(typedResource.types) &&
typedResource.types.includes(type)) {
hasMetaSupport = true;
// Match idPrefixes if present; otherwise assume support
if (Array.isArray(typedResource.idPrefixes) && typedResource.idPrefixes.length > 0) {
@ -1050,7 +1078,6 @@ class StremioService {
await this.ensureInitialized();
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
try {
@ -1206,9 +1233,6 @@ class StremioService {
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 supportsIdPrefix = false;
@ -1218,18 +1242,16 @@ class StremioService {
if (typeof resource === 'object' && resource !== null && 'name' in resource) {
const typedResource = resource as ResourceObject;
if (typedResource.name === 'stream' &&
Array.isArray(typedResource.types) &&
typedResource.types.includes(type)) {
Array.isArray(typedResource.types) &&
typedResource.types.includes(type)) {
hasStreamResource = true;
// 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) {
supportsIdPrefix = typedResource.idPrefixes.some(p => id.startsWith(p));
logger.log(`🔍 [getStreams] Addon ${addon.id} supports prefixes: ${typedResource.idPrefixes.join(', ')} → matches=${supportsIdPrefix}`);
} else {
// If no idPrefixes specified, assume it supports all prefixes
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
}
@ -1241,11 +1263,9 @@ class StremioService {
// For simple string resources, check addon-level idPrefixes (generic)
if (addon.idPrefixes && Array.isArray(addon.idPrefixes) && addon.idPrefixes.length > 0) {
supportsIdPrefix = addon.idPrefixes.some(p => id.startsWith(p));
logger.log(`🔍 [getStreams] Addon ${addon.id} supports prefixes: ${addon.idPrefixes.join(', ')} → matches=${supportsIdPrefix}`);
} else {
// If no idPrefixes specified, assume it supports all prefixes
supportsIdPrefix = true;
logger.log(`🔍 [getStreams] Addon ${addon.id} has no prefix restrictions, assuming support`);
}
break; // Found the simple stream resource string and type support
}
@ -1254,18 +1274,10 @@ class StremioService {
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;
});
logger.log('📊 [getStreams] Stream capable addons:', streamAddons.map(a => a.id));
if (streamAddons.length === 0) {
logger.warn('⚠️ [getStreams] No addons found that can provide streams');
@ -1276,7 +1288,7 @@ class StremioService {
// Process each addon and call the callback individually
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 () => {
try {
if (!addon.url) {
@ -1301,7 +1313,7 @@ class StremioService {
processedStreams = this.processStreams(response.data.streams, addon);
logger.log(`✅ [getStreams] Processed ${processedStreams.length} valid streams from ${addon.name} (${addon.id})`);
} 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) {
@ -1596,22 +1608,48 @@ class StremioService {
return false;
}
// Check if any installed addons can provide streams
async hasStreamProviders(): Promise<boolean> {
// Check if any installed addons can provide streams (including embedded streams in metadata)
async hasStreamProviders(type?: string): Promise<boolean> {
await this.ensureInitialized();
const addons = Array.from(this.installedAddons.values());
for (const addon of addons) {
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 =>
typeof resource === 'string'
? resource === 'stream'
: resource.name === 'stream'
: (resource as any).name === 'stream'
);
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;
}
}
}
}