NuvioStreaming_backup_24-10-25/src/services/stremioService.ts
2025-04-12 04:07:35 +05:30

703 lines
No EOL
20 KiB
TypeScript

import axios from 'axios';
import AsyncStorage from '@react-native-async-storage/async-storage';
// Basic types for Stremio
export interface Meta {
id: string;
type: string;
name: string;
poster?: string;
background?: string;
logo?: string;
description?: string;
releaseInfo?: string;
imdbRating?: string;
year?: number;
genres?: string[];
runtime?: string;
cast?: string[];
director?: string;
writer?: string;
certification?: string;
}
export interface Stream {
name?: string;
title?: string;
url: string;
addon?: string;
addonId?: string;
addonName?: string;
description?: string;
infoHash?: string;
fileIdx?: number;
behaviorHints?: {
bingeGroup?: string;
notWebReady?: boolean;
[key: string]: any;
};
size?: number;
isFree?: boolean;
isDebrid?: boolean;
}
export interface StreamResponse {
streams: Stream[];
addon: string;
addonName: string;
}
interface CatalogFilter {
title: string;
value: any;
}
interface Catalog {
type: string;
id: string;
name: string;
extraSupported?: string[];
extraRequired?: string[];
itemCount?: number;
}
interface ResourceObject {
name: string;
types: string[];
idPrefixes?: string[];
idPrefix?: string[];
}
export interface Manifest {
id: string;
name: string;
version: string;
description: string;
url?: string;
originalUrl?: string;
catalogs?: Catalog[];
resources?: ResourceObject[];
types?: string[];
idPrefixes?: string[];
manifestVersion?: string;
queryParams?: string;
behaviorHints?: {
configurable?: boolean;
};
}
export interface MetaDetails extends Meta {
videos?: {
id: string;
title: string;
released: string;
season?: number;
episode?: number;
}[];
}
export interface AddonCapabilities {
name: string;
id: string;
version: string;
catalogs: {
type: string;
id: string;
name: string;
}[];
resources: {
name: string;
types: string[];
idPrefixes?: string[];
}[];
types: string[];
}
class StremioService {
private static instance: StremioService;
private installedAddons: Map<string, Manifest> = new Map();
private readonly STORAGE_KEY = 'stremio-addons';
private readonly DEFAULT_ADDONS = [
'https://v3-cinemeta.strem.io/manifest.json'
];
private readonly MAX_CONCURRENT_REQUESTS = 3;
private readonly DEFAULT_PAGE_SIZE = 50;
private initialized: boolean = false;
private initializationPromise: Promise<void> | null = null;
private constructor() {
// Start initialization but don't wait for it
this.initializationPromise = this.initialize();
}
static getInstance(): StremioService {
if (!StremioService.instance) {
StremioService.instance = new StremioService();
}
return StremioService.instance;
}
private async initialize(): Promise<void> {
if (this.initialized) return;
try {
const storedAddons = await AsyncStorage.getItem(this.STORAGE_KEY);
if (storedAddons) {
const parsed = JSON.parse(storedAddons);
// Convert to Map
this.installedAddons = new Map();
for (const addon of parsed) {
if (addon && addon.id) {
this.installedAddons.set(addon.id, addon);
}
}
}
// If no addons, install defaults
if (this.installedAddons.size === 0) {
await this.installDefaultAddons();
}
this.initialized = true;
} catch (error) {
console.error('Failed to initialize addons:', error);
// Install defaults as fallback
await this.installDefaultAddons();
this.initialized = true;
}
}
// Ensure service is initialized before any operation
private async ensureInitialized(): Promise<void> {
if (!this.initialized && this.initializationPromise) {
await this.initializationPromise;
}
}
private async retryRequest<T>(request: () => Promise<T>, retries = 3, delay = 1000): Promise<T> {
let lastError: any;
for (let attempt = 0; attempt < retries + 1; attempt++) {
try {
return await request();
} catch (error: any) {
lastError = error;
console.warn(`Request failed (attempt ${attempt + 1}/${retries + 1}):`, {
message: error.message,
code: error.code,
isAxiosError: error.isAxiosError,
status: error.response?.status,
});
if (attempt < retries) {
const backoffDelay = delay * Math.pow(2, attempt);
console.log(`Retrying in ${backoffDelay}ms...`);
await new Promise(resolve => setTimeout(resolve, backoffDelay));
}
}
}
throw lastError;
}
private async installDefaultAddons(): Promise<void> {
try {
for (const url of this.DEFAULT_ADDONS) {
const manifest = await this.getManifest(url);
if (manifest) {
this.installedAddons.set(manifest.id, manifest);
}
}
await this.saveInstalledAddons();
} catch (error) {
console.error('Failed to install default addons:', error);
}
}
private async saveInstalledAddons(): Promise<void> {
try {
const addonsArray = Array.from(this.installedAddons.values());
await AsyncStorage.setItem(this.STORAGE_KEY, JSON.stringify(addonsArray));
} catch (error) {
console.error('Failed to save addons:', error);
}
}
async getManifest(url: string): Promise<Manifest> {
try {
// Clean up URL - ensure it ends with manifest.json
const manifestUrl = url.endsWith('manifest.json')
? url
: `${url.replace(/\/$/, '')}/manifest.json`;
const response = await this.retryRequest(async () => {
return await axios.get(manifestUrl);
});
const manifest = response.data;
// Add some extra fields for internal use
manifest.originalUrl = url;
manifest.url = url.replace(/manifest\.json$/, '');
// Ensure ID exists
if (!manifest.id) {
manifest.id = this.formatId(url);
}
return manifest;
} catch (error) {
console.error(`Failed to fetch manifest from ${url}:`, error);
throw new Error(`Failed to fetch addon manifest from ${url}`);
}
}
async installAddon(url: string): Promise<void> {
const manifest = await this.getManifest(url);
if (manifest && manifest.id) {
this.installedAddons.set(manifest.id, manifest);
await this.saveInstalledAddons();
} else {
throw new Error('Invalid addon manifest');
}
}
removeAddon(id: string): void {
if (this.installedAddons.has(id)) {
this.installedAddons.delete(id);
this.saveInstalledAddons();
}
}
getInstalledAddons(): Manifest[] {
return Array.from(this.installedAddons.values());
}
async getInstalledAddonsAsync(): Promise<Manifest[]> {
await this.ensureInitialized();
return this.getInstalledAddons();
}
private formatId(id: string): string {
return id.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase();
}
async getAllCatalogs(): Promise<{ [addonId: string]: Meta[] }> {
const result: { [addonId: string]: Meta[] } = {};
const addons = this.getInstalledAddons();
const promises = addons.map(async (addon) => {
if (!addon.catalogs || addon.catalogs.length === 0) return;
const catalog = addon.catalogs[0]; // Just take the first catalog for now
try {
const items = await this.getCatalog(addon, catalog.type, catalog.id);
if (items.length > 0) {
result[addon.id] = items;
}
} catch (error) {
console.error(`Failed to fetch catalog from ${addon.name}:`, error);
}
});
await Promise.all(promises);
return result;
}
private getAddonBaseURL(url: string): string {
// Remove trailing manifest.json if present
let baseUrl = url.replace(/manifest\.json$/, '').replace(/\/$/, '');
// Ensure URL has protocol
if (!baseUrl.startsWith('http')) {
baseUrl = `https://${baseUrl}`;
}
console.log('Addon base URL:', baseUrl);
return baseUrl;
}
async getCatalog(manifest: Manifest, type: string, id: string, page = 1, filters: CatalogFilter[] = []): Promise<Meta[]> {
// Special handling for Cinemeta
if (manifest.id === 'com.linvo.cinemeta') {
const baseUrl = 'https://v3-cinemeta.strem.io';
let url = `${baseUrl}/catalog/${type}/${id}.json`;
// Add paging
url += `?skip=${(page - 1) * this.DEFAULT_PAGE_SIZE}`;
// Add filters
if (filters.length > 0) {
filters.forEach(filter => {
if (filter.value) {
url += `&${encodeURIComponent(filter.title)}=${encodeURIComponent(filter.value)}`;
}
});
}
const response = await this.retryRequest(async () => {
return await axios.get(url);
});
if (response.data && response.data.metas && Array.isArray(response.data.metas)) {
return response.data.metas;
}
return [];
}
// For other addons
if (!manifest.url) {
throw new Error('Addon URL is missing');
}
try {
const baseUrl = this.getAddonBaseURL(manifest.url);
// Build the catalog URL
let url = `${baseUrl}/catalog/${type}/${id}.json`;
// Add paging
url += `?skip=${(page - 1) * this.DEFAULT_PAGE_SIZE}`;
// Add filters
if (filters.length > 0) {
filters.forEach(filter => {
if (filter.value) {
url += `&${encodeURIComponent(filter.title)}=${encodeURIComponent(filter.value)}`;
}
});
}
const response = await this.retryRequest(async () => {
return await axios.get(url);
});
if (response.data && response.data.metas && Array.isArray(response.data.metas)) {
return response.data.metas;
}
return [];
} catch (error) {
console.error(`Failed to fetch catalog from ${manifest.name}:`, error);
throw error;
}
}
async getMetaDetails(type: string, id: string): Promise<MetaDetails | null> {
try {
// Try Cinemeta with different base URLs
const cinemetaUrls = [
'https://v3-cinemeta.strem.io',
'http://v3-cinemeta.strem.io'
];
for (const baseUrl of cinemetaUrls) {
try {
const url = `${baseUrl}/meta/${type}/${id}.json`;
const response = await this.retryRequest(async () => {
return await axios.get(url, { timeout: 10000 });
});
if (response.data && response.data.meta) {
return response.data.meta;
}
} catch (error) {
console.warn(`Failed to fetch meta from ${baseUrl}:`, error);
continue; // Try next URL
}
}
// If Cinemeta fails, try other addons
const addons = this.getInstalledAddons();
for (const addon of addons) {
if (!addon.resources || addon.id === 'com.linvo.cinemeta') continue;
const metaResource = addon.resources.find(
resource => resource.name === 'meta' && resource.types.includes(type)
);
if (!metaResource) continue;
try {
const baseUrl = this.getAddonBaseURL(addon.url || '');
const url = `${baseUrl}/meta/${type}/${id}.json`;
const response = await this.retryRequest(async () => {
return await axios.get(url, { timeout: 10000 });
});
if (response.data && response.data.meta) {
return response.data.meta;
}
} catch (error) {
console.warn(`Failed to fetch meta from ${addon.name}:`, error);
continue; // Try next addon
}
}
console.warn('No metadata found from any addon');
return null;
} catch (error) {
console.error('Error in getMetaDetails:', error);
return null;
}
}
async getStreams(type: string, id: string, callback?: (streams: Stream[] | null, addonName: string | null, error: Error | null) => void): Promise<StreamResponse[]> {
await this.ensureInitialized();
const addons = this.getInstalledAddons();
console.log('Installed addons:', addons.map(a => ({ id: a.id, url: a.url })));
const streamResponses: StreamResponse[] = [];
// Find addons that provide streams and sort them by installation order
const streamAddons = addons
.filter(addon => {
if (!addon.resources) {
console.log(`Addon ${addon.id} has no resources`);
return false;
}
const hasStreamResource = addon.resources.some(
resource => resource.name === 'stream' && resource.types.includes(type)
);
if (!hasStreamResource) {
console.log(`Addon ${addon.id} does not support streaming ${type}`);
}
return hasStreamResource;
});
console.log('Stream capable addons:', streamAddons.map(a => a.id));
if (streamAddons.length === 0) {
console.warn('No addons found that can provide streams');
return [];
}
// Create a map to store promises for each addon
const addonPromises = new Map<string, Promise<void>>();
// Process each addon
for (const addon of streamAddons) {
const promise = (async () => {
try {
if (!addon.url) {
console.warn(`Addon ${addon.id} has no URL`);
return;
}
const baseUrl = this.getAddonBaseURL(addon.url);
const url = `${baseUrl}/stream/${type}/${id}.json`;
const response = await this.retryRequest(async () => {
return await axios.get(url);
});
if (response.data && response.data.streams) {
const processedStreams = this.processStreams(response.data.streams, addon);
if (processedStreams.length > 0) {
streamResponses.push({
addon: addon.id,
addonName: addon.name,
streams: processedStreams
});
}
}
if (callback) {
callback(response.data?.streams || null, addon.name, null);
}
} catch (error) {
console.error(`Failed to get streams from ${addon.name}:`, error);
if (callback) {
callback(null, addon.name, error as Error);
}
}
})();
addonPromises.set(addon.id, promise);
}
// Wait for all promises to complete
await Promise.all(addonPromises.values());
// Sort stream responses to maintain installed addon order
streamResponses.sort((a, b) => {
const indexA = streamAddons.findIndex(addon => addon.id === a.addon);
const indexB = streamAddons.findIndex(addon => addon.id === b.addon);
return indexA - indexB;
});
return streamResponses;
}
private async fetchStreamsFromAddon(addon: Manifest, type: string, id: string): Promise<StreamResponse | null> {
if (!addon.url) {
console.warn(`Addon ${addon.id} has no URL defined`);
return null;
}
const baseUrl = this.getAddonBaseURL(addon.url);
const url = `${baseUrl}/stream/${type}/${id}.json`;
console.log(`Fetching streams from URL: ${url}`);
try {
// Increase timeout for debrid services
const timeout = addon.id.toLowerCase().includes('torrentio') ? 30000 : 10000;
const response = await this.retryRequest(async () => {
console.log(`Making request to ${url} with timeout ${timeout}ms`);
return await axios.get(url, {
timeout,
headers: {
'Accept': 'application/json',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
}
});
}, 5); // Increase retries for stream fetching
if (response.data && response.data.streams && Array.isArray(response.data.streams)) {
const streams = this.processStreams(response.data.streams, addon);
console.log(`Successfully processed ${streams.length} streams from ${addon.id}`);
return {
streams,
addon: addon.id,
addonName: addon.name
};
} else {
console.warn(`Invalid response format from ${addon.id}:`, response.data);
}
} catch (error: any) {
const errorDetails = {
addonId: addon.id,
addonName: addon.name,
url,
message: error.message,
code: error.code,
isAxiosError: error.isAxiosError,
status: error.response?.status,
responseData: error.response?.data
};
console.error('Failed to fetch streams from addon:', errorDetails);
// Re-throw the error with more context
throw new Error(`Failed to fetch streams from ${addon.name}: ${error.message}`);
}
return null;
}
private isDirectStreamingUrl(url?: string): boolean {
return Boolean(
url && (
url.startsWith('http') ||
url.startsWith('https')
)
);
}
private getStreamUrl(stream: any): string {
if (stream.url) return stream.url;
if (stream.infoHash) {
const trackers = [
'udp://tracker.opentrackr.org:1337/announce',
'udp://9.rarbg.com:2810/announce',
'udp://tracker.openbittorrent.com:6969/announce',
'udp://tracker.torrent.eu.org:451/announce',
'udp://open.stealth.si:80/announce',
'udp://tracker.leechers-paradise.org:6969/announce',
'udp://tracker.coppersurfer.tk:6969/announce',
'udp://tracker.internetwarriors.net:1337/announce'
];
const trackersString = trackers.map(t => `&tr=${encodeURIComponent(t)}`).join('');
const encodedTitle = encodeURIComponent(stream.title || stream.name || 'Unknown');
return `magnet:?xt=urn:btih:${stream.infoHash}&dn=${encodedTitle}${trackersString}`;
}
return '';
}
private processStreams(streams: any[], addon: Manifest): Stream[] {
return streams
.filter(stream => {
const isTorrentioStream = stream.infoHash && stream.fileIdx !== undefined;
return stream && (stream.url || isTorrentioStream) && (stream.title || stream.name);
})
.map(stream => {
const isDirectStreamingUrl = this.isDirectStreamingUrl(stream.url);
const streamUrl = this.getStreamUrl(stream);
const isMagnetStream = streamUrl?.startsWith('magnet:');
// Keep original stream data exactly as provided by the addon
return {
...stream,
url: streamUrl,
addonName: addon.name,
addonId: addon.id,
// Preserve original stream metadata
name: stream.name,
title: stream.title,
behaviorHints: {
...stream.behaviorHints,
notWebReady: !isDirectStreamingUrl,
isMagnetStream,
...(isMagnetStream && {
infoHash: stream.infoHash || streamUrl?.match(/btih:([a-zA-Z0-9]+)/)?.[1],
fileIdx: stream.fileIdx,
magnetUrl: streamUrl,
type: 'torrent',
sources: stream.sources || [],
seeders: stream.seeders,
size: stream.size,
title: stream.title,
})
}
};
});
}
getAddonCapabilities(): AddonCapabilities[] {
return this.getInstalledAddons().map(addon => {
return {
name: addon.name,
id: addon.id,
version: addon.version,
catalogs: addon.catalogs || [],
resources: addon.resources || [],
types: addon.types || [],
};
});
}
async getCatalogPreview(addonId: string, type: string, id: string, limit: number = 5): Promise<{
addon: string;
type: string;
id: string;
items: Meta[];
}> {
const addon = this.getInstalledAddons().find(a => a.id === addonId);
if (!addon) {
throw new Error(`Addon ${addonId} not found`);
}
const items = await this.getCatalog(addon, type, id);
return {
addon: addonId,
type,
id,
items: items.slice(0, limit)
};
}
}
export const stremioService = StremioService.getInstance();
export default stremioService;