mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-26 20:38:58 +00:00
This update adds the @movie-web/providers package to both package.json and package-lock.json, enhancing the project's capabilities. Additionally, the StremioService class has been refactored to improve the handling of addon base URLs, allowing for the extraction of query parameters and ensuring URLs are correctly formatted. These changes enhance the overall functionality and maintainability of the code.
940 lines
No EOL
31 KiB
TypeScript
940 lines
No EOL
31 KiB
TypeScript
import axios from 'axios';
|
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
import { logger } from '../utils/logger';
|
|
import EventEmitter from 'eventemitter3';
|
|
|
|
// Create an event emitter for addon changes
|
|
export const addonEmitter = new EventEmitter();
|
|
export const ADDON_EVENTS = {
|
|
ORDER_CHANGED: 'order_changed',
|
|
ADDON_ADDED: 'addon_added',
|
|
ADDON_REMOVED: 'addon_removed'
|
|
};
|
|
|
|
// 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 Subtitle {
|
|
id: string;
|
|
url: string;
|
|
lang: string;
|
|
fps?: number;
|
|
addon?: string;
|
|
addonName?: 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;
|
|
}
|
|
|
|
export interface SubtitleResponse {
|
|
subtitles: Subtitle[];
|
|
addon: string;
|
|
addonName: string;
|
|
}
|
|
|
|
// Modify the callback signature to include addon ID
|
|
interface StreamCallback {
|
|
(streams: Stream[] | null, addonId: string | null, addonName: string | null, error: Error | null): void;
|
|
}
|
|
|
|
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 addonOrder: string[] = [];
|
|
private readonly STORAGE_KEY = 'stremio-addons';
|
|
private readonly ADDON_ORDER_KEY = 'stremio-addon-order';
|
|
private readonly DEFAULT_ADDONS = [
|
|
'https://v3-cinemeta.strem.io/manifest.json',
|
|
'https://opensubtitles-v3.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);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Load addon order if exists
|
|
const storedOrder = await AsyncStorage.getItem(this.ADDON_ORDER_KEY);
|
|
if (storedOrder) {
|
|
this.addonOrder = JSON.parse(storedOrder);
|
|
// Filter out any ids that aren't in installedAddons
|
|
this.addonOrder = this.addonOrder.filter(id => this.installedAddons.has(id));
|
|
}
|
|
|
|
// Add any missing addons to the order
|
|
const installedIds = Array.from(this.installedAddons.keys());
|
|
const missingIds = installedIds.filter(id => !this.addonOrder.includes(id));
|
|
this.addonOrder = [...this.addonOrder, ...missingIds];
|
|
|
|
// If no addons, install defaults
|
|
if (this.installedAddons.size === 0) {
|
|
await this.installDefaultAddons();
|
|
}
|
|
|
|
// Ensure order is saved
|
|
await this.saveAddonOrder();
|
|
|
|
this.initialized = true;
|
|
} catch (error) {
|
|
logger.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;
|
|
logger.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);
|
|
logger.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) {
|
|
logger.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) {
|
|
logger.error('Failed to save addons:', error);
|
|
}
|
|
}
|
|
|
|
private async saveAddonOrder(): Promise<void> {
|
|
try {
|
|
await AsyncStorage.setItem(this.ADDON_ORDER_KEY, JSON.stringify(this.addonOrder));
|
|
} catch (error) {
|
|
logger.error('Failed to save addon order:', 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) {
|
|
logger.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);
|
|
|
|
// Add to order if not already present (new addons go to the end)
|
|
if (!this.addonOrder.includes(manifest.id)) {
|
|
this.addonOrder.push(manifest.id);
|
|
}
|
|
|
|
await this.saveInstalledAddons();
|
|
await this.saveAddonOrder();
|
|
// Emit an event that an addon was added
|
|
addonEmitter.emit(ADDON_EVENTS.ADDON_ADDED, manifest.id);
|
|
} else {
|
|
throw new Error('Invalid addon manifest');
|
|
}
|
|
}
|
|
|
|
removeAddon(id: string): void {
|
|
if (this.installedAddons.has(id)) {
|
|
this.installedAddons.delete(id);
|
|
// Remove from order
|
|
this.addonOrder = this.addonOrder.filter(addonId => addonId !== id);
|
|
this.saveInstalledAddons();
|
|
this.saveAddonOrder();
|
|
// Emit an event that an addon was removed
|
|
addonEmitter.emit(ADDON_EVENTS.ADDON_REMOVED, id);
|
|
}
|
|
}
|
|
|
|
getInstalledAddons(): Manifest[] {
|
|
// Return addons in the specified order
|
|
return this.addonOrder
|
|
.filter(id => this.installedAddons.has(id))
|
|
.map(id => this.installedAddons.get(id)!);
|
|
}
|
|
|
|
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) {
|
|
logger.error(`Failed to fetch catalog from ${addon.name}:`, error);
|
|
}
|
|
});
|
|
|
|
await Promise.all(promises);
|
|
return result;
|
|
}
|
|
|
|
private getAddonBaseURL(url: string): { baseUrl: string; queryParams?: string } {
|
|
// Extract query parameters if they exist
|
|
const [baseUrl, queryString] = url.split('?');
|
|
|
|
// Remove trailing manifest.json and slashes
|
|
let cleanBaseUrl = baseUrl.replace(/manifest\.json$/, '').replace(/\/$/, '');
|
|
|
|
// Ensure URL has protocol
|
|
if (!cleanBaseUrl.startsWith('http')) {
|
|
cleanBaseUrl = `https://${cleanBaseUrl}`;
|
|
}
|
|
|
|
logger.log('Addon base URL:', cleanBaseUrl, queryString ? `with query: ${queryString}` : '');
|
|
return { baseUrl: cleanBaseUrl, queryParams: queryString };
|
|
}
|
|
|
|
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) {
|
|
logger.log(`Adding ${filters.length} filters to Cinemeta request`);
|
|
filters.forEach(filter => {
|
|
if (filter.value) {
|
|
logger.log(`Adding filter ${filter.title}=${filter.value}`);
|
|
url += `&${encodeURIComponent(filter.title)}=${encodeURIComponent(filter.value)}`;
|
|
}
|
|
});
|
|
}
|
|
|
|
logger.log(`Cinemeta catalog request URL: ${url}`);
|
|
|
|
const response = await this.retryRequest(async () => {
|
|
return await axios.get(url);
|
|
});
|
|
|
|
if (response.data && response.data.metas && Array.isArray(response.data.metas)) {
|
|
logger.log(`Cinemeta returned ${response.data.metas.length} items`);
|
|
return response.data.metas;
|
|
}
|
|
return [];
|
|
}
|
|
|
|
// For other addons
|
|
if (!manifest.url) {
|
|
throw new Error('Addon URL is missing');
|
|
}
|
|
|
|
try {
|
|
const { baseUrl, queryParams } = 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) {
|
|
logger.log(`Adding ${filters.length} filters to ${manifest.name} request`);
|
|
filters.forEach(filter => {
|
|
if (filter.value) {
|
|
logger.log(`Adding filter ${filter.title}=${filter.value}`);
|
|
url += `&${encodeURIComponent(filter.title)}=${encodeURIComponent(filter.value)}`;
|
|
}
|
|
});
|
|
}
|
|
|
|
logger.log(`${manifest.name} catalog request URL: ${url}`);
|
|
|
|
const response = await this.retryRequest(async () => {
|
|
return await axios.get(url);
|
|
});
|
|
|
|
if (response.data && response.data.metas && Array.isArray(response.data.metas)) {
|
|
logger.log(`${manifest.name} returned ${response.data.metas.length} items`);
|
|
return response.data.metas;
|
|
}
|
|
return [];
|
|
} catch (error) {
|
|
logger.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) {
|
|
logger.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, queryParams } = this.getAddonBaseURL(addon.url || '');
|
|
const url = queryParams ? `${baseUrl}/meta/${type}/${id}.json?${queryParams}` : `${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) {
|
|
logger.warn(`Failed to fetch meta from ${addon.name}:`, error);
|
|
continue; // Try next addon
|
|
}
|
|
}
|
|
|
|
logger.warn('No metadata found from any addon');
|
|
return null;
|
|
} catch (error) {
|
|
logger.error('Error in getMetaDetails:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Modify getStreams to use this.getInstalledAddons() instead of getEnabledAddons
|
|
async getStreams(type: string, id: string, callback?: StreamCallback): Promise<void> {
|
|
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 specifically for TMDB Embed addon
|
|
const tmdbEmbed = addons.find(addon => addon.id === 'org.tmdbembedapi');
|
|
if (tmdbEmbed) {
|
|
logger.log('🔍 [getStreams] Found TMDB Embed Streams addon:', {
|
|
id: tmdbEmbed.id,
|
|
name: tmdbEmbed.name,
|
|
url: tmdbEmbed.url,
|
|
resources: tmdbEmbed.resources,
|
|
types: tmdbEmbed.types
|
|
});
|
|
} else {
|
|
logger.log('⚠️ [getStreams] TMDB Embed Streams addon not found among installed addons');
|
|
}
|
|
|
|
// Find addons that provide streams and sort them by installation order
|
|
const streamAddons = addons
|
|
.filter(addon => {
|
|
if (!addon.resources || !Array.isArray(addon.resources)) {
|
|
logger.log(`⚠️ [getStreams] Addon ${addon.id} has no valid resources array`);
|
|
return false;
|
|
}
|
|
|
|
// Log the detailed resources structure for debugging
|
|
logger.log(`📋 [getStreams] Checking addon ${addon.id} resources:`, JSON.stringify(addon.resources));
|
|
|
|
let hasStreamResource = false;
|
|
|
|
// Iterate through the resources array, checking each element
|
|
for (const resource of addon.resources) {
|
|
// Check if the current element is a ResourceObject
|
|
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)) {
|
|
hasStreamResource = true;
|
|
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
|
|
else if (typeof resource === 'string' && resource === 'stream' && addon.types) {
|
|
if (Array.isArray(addon.types) && addon.types.includes(type)) {
|
|
hasStreamResource = true;
|
|
break; // Found the simple stream resource string and type support
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!hasStreamResource) {
|
|
logger.log(`❌ [getStreams] Addon ${addon.id} does not support streaming ${type}`);
|
|
} else {
|
|
logger.log(`✅ [getStreams] Addon ${addon.id} supports streaming ${type}`);
|
|
}
|
|
|
|
return hasStreamResource;
|
|
});
|
|
|
|
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');
|
|
// Optionally call callback with an empty result or specific status?
|
|
// For now, just return if no addons.
|
|
return;
|
|
}
|
|
|
|
// Process each addon and call the callback individually
|
|
streamAddons.forEach(addon => {
|
|
// Use an IIFE to create scope for async operation inside forEach
|
|
(async () => {
|
|
try {
|
|
if (!addon.url) {
|
|
logger.warn(`⚠️ [getStreams] Addon ${addon.id} has no URL`);
|
|
if (callback) callback(null, addon.id, addon.name, new Error('Addon has no URL'));
|
|
return;
|
|
}
|
|
|
|
const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url);
|
|
const url = queryParams ? `${baseUrl}/stream/${type}/${id}.json?${queryParams}` : `${baseUrl}/stream/${type}/${id}.json`;
|
|
|
|
logger.log(`🔗 [getStreams] Requesting streams from ${addon.name} (${addon.id}): ${url}`);
|
|
|
|
const response = await this.retryRequest(async () => {
|
|
return await axios.get(url);
|
|
});
|
|
|
|
let processedStreams: Stream[] = [];
|
|
if (response.data && response.data.streams) {
|
|
logger.log(`✅ [getStreams] Got ${response.data.streams.length} streams from ${addon.name} (${addon.id})`);
|
|
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})`);
|
|
}
|
|
|
|
if (callback) {
|
|
// Call callback with processed streams (can be empty array)
|
|
callback(processedStreams, addon.id, addon.name, null);
|
|
}
|
|
} catch (error) {
|
|
logger.error(`❌ [getStreams] Failed to get streams from ${addon.name} (${addon.id}):`, error);
|
|
if (callback) {
|
|
// Call callback with error
|
|
callback(null, addon.id, addon.name, error as Error);
|
|
}
|
|
}
|
|
})(); // Immediately invoke the async function
|
|
});
|
|
|
|
// No longer waiting here, callbacks handle results asynchronously
|
|
// Removed: await Promise.all(addonPromises.values());
|
|
// No longer returning aggregated results
|
|
// Removed: return streamResponses;
|
|
}
|
|
|
|
private async fetchStreamsFromAddon(addon: Manifest, type: string, id: string): Promise<StreamResponse | null> {
|
|
if (!addon.url) {
|
|
logger.warn(`Addon ${addon.id} has no URL defined`);
|
|
return null;
|
|
}
|
|
|
|
const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url);
|
|
const streamPath = `/stream/${type}/${id}.json`;
|
|
const url = queryParams ? `${baseUrl}${streamPath}?${queryParams}` : `${baseUrl}${streamPath}`;
|
|
|
|
logger.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 () => {
|
|
logger.log(`Making request to ${url} with timeout ${timeout}ms`);
|
|
return await axios.get(url, {
|
|
timeout,
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'User-Agent': 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Mobile 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);
|
|
logger.log(`Successfully processed ${streams.length} streams from ${addon.id}`);
|
|
|
|
return {
|
|
streams,
|
|
addon: addon.id,
|
|
addonName: addon.name
|
|
};
|
|
} else {
|
|
logger.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
|
|
};
|
|
logger.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 => {
|
|
// Basic filtering - ensure there's a way to play (URL or infoHash) and identify (title/name)
|
|
const hasPlayableLink = !!(stream.url || stream.infoHash);
|
|
const hasIdentifier = !!(stream.title || stream.name);
|
|
return stream && hasPlayableLink && hasIdentifier;
|
|
})
|
|
.map(stream => {
|
|
const streamUrl = this.getStreamUrl(stream);
|
|
const isDirectStreamingUrl = this.isDirectStreamingUrl(streamUrl);
|
|
const isMagnetStream = streamUrl?.startsWith('magnet:');
|
|
|
|
// Determine the best title: Prioritize description if it seems detailed,
|
|
// otherwise fall back to title or name.
|
|
let displayTitle = stream.title || stream.name || 'Unnamed Stream';
|
|
if (stream.description && stream.description.includes('\n') && stream.description.length > (stream.title?.length || 0)) {
|
|
// If description exists, contains newlines (likely formatted metadata),
|
|
// and is longer than the title, prefer it.
|
|
displayTitle = stream.description;
|
|
}
|
|
|
|
// Use the original name field for the primary identifier if available
|
|
const name = stream.name || stream.title || 'Unnamed Stream';
|
|
|
|
// Extract size: Prefer behaviorHints.videoSize, fallback to top-level size
|
|
const sizeInBytes = stream.behaviorHints?.videoSize || stream.size || undefined;
|
|
|
|
// Consolidate behavior hints, prioritizing specific data extraction
|
|
let behaviorHints: Stream['behaviorHints'] = {
|
|
...(stream.behaviorHints || {}), // Start with existing hints
|
|
notWebReady: !isDirectStreamingUrl,
|
|
isMagnetStream,
|
|
// Addon Info
|
|
addonName: addon.name,
|
|
addonId: addon.id,
|
|
// Extracted data (provide defaults or undefined)
|
|
cached: stream.behaviorHints?.cached || undefined, // For RD/AD detection
|
|
filename: stream.behaviorHints?.filename || undefined, // Filename if available
|
|
bingeGroup: stream.behaviorHints?.bingeGroup || undefined,
|
|
// Add size here if extracted
|
|
size: sizeInBytes,
|
|
};
|
|
|
|
// Specific handling for magnet/torrent streams to extract more details
|
|
if (isMagnetStream) {
|
|
behaviorHints = {
|
|
...behaviorHints,
|
|
infoHash: stream.infoHash || streamUrl?.match(/btih:([a-zA-Z0-9]+)/)?.[1],
|
|
fileIdx: stream.fileIdx,
|
|
magnetUrl: streamUrl,
|
|
type: 'torrent',
|
|
sources: stream.sources || [],
|
|
seeders: stream.seeders, // Explicitly map seeders if present
|
|
size: sizeInBytes || stream.seeders, // Use extracted size, fallback for torrents
|
|
title: stream.title, // Torrent title might be different
|
|
};
|
|
}
|
|
|
|
// Explicitly construct the final Stream object
|
|
const processedStream: Stream = {
|
|
url: streamUrl,
|
|
name: name, // Use the original name/title for primary ID
|
|
title: displayTitle, // Use the potentially more detailed title from description
|
|
addonName: addon.name,
|
|
addonId: addon.id,
|
|
// Map other potential top-level fields if they exist
|
|
description: stream.description || undefined, // Keep original description too
|
|
infoHash: stream.infoHash || undefined,
|
|
fileIdx: stream.fileIdx,
|
|
size: sizeInBytes, // Assign the extracted size
|
|
isFree: stream.isFree,
|
|
isDebrid: !!(stream.behaviorHints?.cached), // Map debrid status more reliably
|
|
// Assign the consolidated behaviorHints
|
|
behaviorHints: behaviorHints,
|
|
};
|
|
|
|
return processedStream;
|
|
});
|
|
}
|
|
|
|
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)
|
|
};
|
|
}
|
|
|
|
async getSubtitles(type: string, id: string, videoId?: string): Promise<Subtitle[]> {
|
|
await this.ensureInitialized();
|
|
|
|
// Find the OpenSubtitles v3 addon
|
|
const openSubtitlesAddon = this.getInstalledAddons().find(
|
|
addon => addon.id === 'org.stremio.opensubtitlesv3'
|
|
);
|
|
|
|
if (!openSubtitlesAddon) {
|
|
logger.warn('OpenSubtitles v3 addon not found');
|
|
return [];
|
|
}
|
|
|
|
try {
|
|
const baseUrl = this.getAddonBaseURL(openSubtitlesAddon.url || '').baseUrl;
|
|
|
|
// Construct the query URL with the correct format
|
|
// For series episodes, use the videoId directly which includes series ID + episode info
|
|
let url = '';
|
|
if (type === 'series' && videoId) {
|
|
// For series, the format should be /subtitles/series/tt12345:1:2.json
|
|
url = `${baseUrl}/subtitles/${type}/${videoId}.json`;
|
|
} else {
|
|
// For movies, the format is /subtitles/movie/tt12345.json
|
|
url = `${baseUrl}/subtitles/${type}/${id}.json`;
|
|
}
|
|
|
|
logger.log(`Fetching subtitles from: ${url}`);
|
|
|
|
const response = await this.retryRequest(async () => {
|
|
return await axios.get(url, { timeout: 10000 });
|
|
});
|
|
|
|
if (response.data && response.data.subtitles) {
|
|
// Process and return the subtitles
|
|
return response.data.subtitles.map((sub: any) => ({
|
|
...sub,
|
|
addon: openSubtitlesAddon.id,
|
|
addonName: openSubtitlesAddon.name
|
|
}));
|
|
}
|
|
} catch (error) {
|
|
logger.error('Failed to fetch subtitles:', error);
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
// Add methods to move addons in the order
|
|
moveAddonUp(id: string): boolean {
|
|
const index = this.addonOrder.indexOf(id);
|
|
if (index > 0) {
|
|
// Swap with the previous item
|
|
[this.addonOrder[index - 1], this.addonOrder[index]] =
|
|
[this.addonOrder[index], this.addonOrder[index - 1]];
|
|
this.saveAddonOrder();
|
|
// Emit an event that the order has changed
|
|
addonEmitter.emit(ADDON_EVENTS.ORDER_CHANGED);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
moveAddonDown(id: string): boolean {
|
|
const index = this.addonOrder.indexOf(id);
|
|
if (index >= 0 && index < this.addonOrder.length - 1) {
|
|
// Swap with the next item
|
|
[this.addonOrder[index], this.addonOrder[index + 1]] =
|
|
[this.addonOrder[index + 1], this.addonOrder[index]];
|
|
this.saveAddonOrder();
|
|
// Emit an event that the order has changed
|
|
addonEmitter.emit(ADDON_EVENTS.ORDER_CHANGED);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export const stremioService = StremioService.getInstance();
|
|
export default stremioService; |