NuvioStreaming/src/services/stremioService.ts
tapframe ba94a515c8 feat: Prepare for App Store submission
BREAKING CHANGE: Removes all internal providers, torrenting functionality, and default addons to comply with App Store guidelines. The app now starts with a clean slate, requiring users to manually install addons.
2025-06-30 12:52:10 +05:30

1043 lines
No EOL
35 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 | string[];
writer?: string | string[];
certification?: string;
// Extended fields available from some addons
country?: string;
imdb_id?: string;
slug?: string;
released?: string;
trailerStreams?: Array<{
title: string;
ytId: string;
}>;
links?: Array<{
name: string;
category: string;
url: string;
}>;
behaviorHints?: {
defaultVideoId?: string;
hasScheduledVideos?: boolean;
[key: string]: any;
};
app_extras?: {
cast?: Array<{
name: string;
character?: string;
photo?: 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 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];
// Ensure order is saved
await this.saveAddonOrder();
this.initialized = true;
} catch (error) {
logger.error('Failed to initialize addons:', error);
// Initialize with empty state on error
this.installedAddons = new Map();
this.addonOrder = [];
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 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)}`;
}
});
}
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, 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)}`;
}
});
}
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) {
logger.error(`Failed to fetch catalog from ${manifest.name}:`, error);
throw error;
}
}
async getMetaDetails(type: string, id: string, preferredAddonId?: string): Promise<MetaDetails | null> {
try {
const addons = this.getInstalledAddons();
// If a preferred addon is specified, try it first
if (preferredAddonId) {
logger.log(`🎯 Trying preferred addon first: ${preferredAddonId}`);
const preferredAddon = addons.find(addon => addon.id === preferredAddonId);
if (preferredAddon && preferredAddon.resources) {
// Log what URL would be used for debugging
const { baseUrl, queryParams } = this.getAddonBaseURL(preferredAddon.url || '');
const wouldBeUrl = queryParams ? `${baseUrl}/meta/${type}/${id}.json?${queryParams}` : `${baseUrl}/meta/${type}/${id}.json`;
logger.log(`🔍 Would check URL: ${wouldBeUrl} (addon: ${preferredAddon.name})`);
// Log addon resources for debugging
logger.log(`🔍 Addon resources:`, JSON.stringify(preferredAddon.resources, null, 2));
// Check if addon supports meta resource for this type
let hasMetaSupport = false;
for (const resource of preferredAddon.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 === 'meta' &&
Array.isArray(typedResource.types) &&
typedResource.types.includes(type)) {
hasMetaSupport = true;
break;
}
}
// 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) {
if (Array.isArray(preferredAddon.types) && preferredAddon.types.includes(type)) {
hasMetaSupport = true;
break;
}
}
}
logger.log(`🔍 Meta support check: ${hasMetaSupport} (addon types: ${JSON.stringify(preferredAddon.types)})`);
if (hasMetaSupport) {
try {
const response = await this.retryRequest(async () => {
return await axios.get(wouldBeUrl, { timeout: 10000 });
});
if (response.data && response.data.meta) {
return response.data.meta;
}
} catch (error) {
logger.warn(`❌ Failed to fetch meta from preferred addon ${preferredAddon.name}:`, error);
}
} else {
logger.warn(`⚠️ Preferred addon ${preferredAddonId} does not support meta for type ${type}`);
}
} else {
logger.warn(`⚠️ Preferred addon ${preferredAddonId} not found or has no resources`);
}
}
// 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 (excluding the preferred one already tried)
for (const addon of addons) {
if (!addon.resources || addon.id === 'com.linvo.cinemeta' || addon.id === preferredAddonId) continue;
// Check if addon supports meta resource for this type (handles both string and object formats)
let hasMetaSupport = false;
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 === 'meta' &&
Array.isArray(typedResource.types) &&
typedResource.types.includes(type)) {
hasMetaSupport = true;
break;
}
}
// 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) {
if (Array.isArray(addon.types) && addon.types.includes(type)) {
hasMetaSupport = true;
break;
}
}
}
if (!hasMetaSupport) continue;
try {
const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url || '');
const url = queryParams ? `${baseUrl}/meta/${type}/${id}.json?${queryParams}` : `${baseUrl}/meta/${type}/${id}.json`;
logger.log(`HTTP GET: ${url}`);
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} (${addon.id}):`, 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;
}
// Check if any installed addons can provide streams
async hasStreamProviders(): 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
const hasStreamResource = addon.resources.some(resource =>
typeof resource === 'string'
? resource === 'stream'
: resource.name === 'stream'
);
if (hasStreamResource) {
return true;
}
}
}
return false;
}
}
export const stremioService = StremioService.getInstance();
export default stremioService;