mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-11 17:45:38 +00:00
1609 lines
No EOL
59 KiB
TypeScript
1609 lines
No EOL
59 KiB
TypeScript
import axios from 'axios';
|
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
import { logger } from '../utils/logger';
|
|
import EventEmitter from 'eventemitter3';
|
|
import { localScraperService } from './localScraperService';
|
|
import { DEFAULT_SETTINGS, AppSettings } from '../hooks/useSettings';
|
|
import { TMDBService } from './tmdbService';
|
|
|
|
// 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 catalogHasMore: Map<string, boolean> = new Map();
|
|
|
|
private constructor() {
|
|
// Start initialization but don't wait for it
|
|
this.initializationPromise = this.initialize();
|
|
}
|
|
|
|
// Dynamic validator for content IDs based on installed addon capabilities
|
|
public async isValidContentId(type: string, id: string | null | undefined): Promise<boolean> {
|
|
// Ensure addons are initialized before checking types
|
|
await this.ensureInitialized();
|
|
|
|
// Get all supported types from installed addons
|
|
const supportedTypes = this.getAllSupportedTypes();
|
|
const isValidType = supportedTypes.includes(type);
|
|
|
|
const lowerId = (id || '').toLowerCase();
|
|
const isNullishId = !id || lowerId === 'null' || lowerId === 'undefined';
|
|
const providerLikeIds = new Set<string>(['moviebox', 'torbox']);
|
|
const isProviderSlug = providerLikeIds.has(lowerId);
|
|
|
|
if (!isValidType || isNullishId || isProviderSlug) return false;
|
|
|
|
// Get all supported ID prefixes from installed addons
|
|
const supportedPrefixes = this.getAllSupportedIdPrefixes(type);
|
|
|
|
// If no addons declare specific prefixes, allow any non-empty string
|
|
if (supportedPrefixes.length === 0) {
|
|
return true;
|
|
}
|
|
|
|
// Check if the ID matches any supported prefix
|
|
return supportedPrefixes.some(prefix => lowerId.startsWith(prefix.toLowerCase()));
|
|
}
|
|
|
|
// Get all content types supported by installed addons
|
|
public getAllSupportedTypes(): string[] {
|
|
const addons = this.getInstalledAddons();
|
|
const types = new Set<string>();
|
|
|
|
for (const addon of addons) {
|
|
// Check addon-level types
|
|
if (addon.types && Array.isArray(addon.types)) {
|
|
addon.types.forEach(type => types.add(type));
|
|
}
|
|
|
|
// Check resource-level types
|
|
if (addon.resources && Array.isArray(addon.resources)) {
|
|
for (const resource of addon.resources) {
|
|
if (typeof resource === 'object' && resource !== null && 'name' in resource) {
|
|
const typedResource = resource as ResourceObject;
|
|
if (Array.isArray(typedResource.types)) {
|
|
typedResource.types.forEach(type => types.add(type));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check catalog-level types
|
|
if (addon.catalogs && Array.isArray(addon.catalogs)) {
|
|
for (const catalog of addon.catalogs) {
|
|
if (catalog.type) {
|
|
types.add(catalog.type);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return Array.from(types);
|
|
}
|
|
|
|
// Get all ID prefixes supported by installed addons for a given content type
|
|
public getAllSupportedIdPrefixes(type: string): string[] {
|
|
const addons = this.getInstalledAddons();
|
|
const prefixes = new Set<string>();
|
|
|
|
for (const addon of addons) {
|
|
// Check addon-level idPrefixes
|
|
if (addon.idPrefixes && Array.isArray(addon.idPrefixes)) {
|
|
addon.idPrefixes.forEach(prefix => prefixes.add(prefix));
|
|
}
|
|
|
|
// Check resource-level idPrefixes
|
|
if (addon.resources && Array.isArray(addon.resources)) {
|
|
for (const resource of addon.resources) {
|
|
if (typeof resource === 'object' && resource !== null && 'name' in resource) {
|
|
const typedResource = resource as ResourceObject;
|
|
// Only include prefixes for resources that support the content type
|
|
if (Array.isArray(typedResource.types) && typedResource.types.includes(type)) {
|
|
if (Array.isArray(typedResource.idPrefixes)) {
|
|
typedResource.idPrefixes.forEach(prefix => prefixes.add(prefix));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return Array.from(prefixes);
|
|
}
|
|
|
|
// Check if a content ID belongs to a collection addon
|
|
public isCollectionContent(id: string): { isCollection: boolean; addon?: Manifest } {
|
|
const addons = this.getInstalledAddons();
|
|
|
|
for (const addon of addons) {
|
|
// Check if this addon supports collections
|
|
const supportsCollections = addon.types?.includes('collections') ||
|
|
addon.catalogs?.some(catalog => catalog.type === 'collections');
|
|
|
|
if (!supportsCollections) continue;
|
|
|
|
// Check if our ID matches this addon's prefixes
|
|
const addonPrefixes = addon.idPrefixes || [];
|
|
const resourcePrefixes = addon.resources
|
|
?.filter(resource => typeof resource === 'object' && resource !== null && 'name' in resource)
|
|
?.filter(resource => (resource as any).name === 'meta' || (resource as any).name === 'catalog')
|
|
?.flatMap(resource => (resource as any).idPrefixes || []) || [];
|
|
|
|
const allPrefixes = [...addonPrefixes, ...resourcePrefixes];
|
|
if (allPrefixes.some(prefix => id.startsWith(prefix))) {
|
|
return { isCollection: true, addon };
|
|
}
|
|
}
|
|
|
|
return { isCollection: false };
|
|
}
|
|
|
|
static getInstance(): StremioService {
|
|
if (!StremioService.instance) {
|
|
StremioService.instance = new StremioService();
|
|
}
|
|
return StremioService.instance;
|
|
}
|
|
|
|
private async initialize(): Promise<void> {
|
|
if (this.initialized) return;
|
|
|
|
try {
|
|
const scope = (await AsyncStorage.getItem('@user:current')) || 'local';
|
|
// Prefer scoped storage, but fall back to legacy keys to preserve older installs
|
|
let storedAddons = await AsyncStorage.getItem(`@user:${scope}:${this.STORAGE_KEY}`);
|
|
if (!storedAddons) storedAddons = await AsyncStorage.getItem(this.STORAGE_KEY);
|
|
if (!storedAddons) storedAddons = await AsyncStorage.getItem(`@user:local:${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);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Install Cinemeta for new users, but allow existing users to uninstall it
|
|
const cinemetaId = 'com.linvo.cinemeta';
|
|
const hasUserRemovedCinemeta = await this.hasUserRemovedAddon(cinemetaId);
|
|
|
|
if (!this.installedAddons.has(cinemetaId) && !hasUserRemovedCinemeta) {
|
|
try {
|
|
const cinemetaManifest = await this.getManifest('https://v3-cinemeta.strem.io/manifest.json');
|
|
this.installedAddons.set(cinemetaId, cinemetaManifest);
|
|
} catch (error) {
|
|
// Fallback to minimal manifest if fetch fails
|
|
const fallbackManifest: Manifest = {
|
|
id: cinemetaId,
|
|
name: 'Cinemeta',
|
|
version: '3.0.13',
|
|
description: 'Provides metadata for movies and series from TheTVDB, TheMovieDB, etc.',
|
|
url: 'https://v3-cinemeta.strem.io',
|
|
originalUrl: 'https://v3-cinemeta.strem.io/manifest.json',
|
|
types: ['movie', 'series'],
|
|
catalogs: [
|
|
{
|
|
type: 'movie',
|
|
id: 'top',
|
|
name: 'Popular',
|
|
extraSupported: ['search', 'genre', 'skip']
|
|
},
|
|
{
|
|
type: 'series',
|
|
id: 'top',
|
|
name: 'Popular',
|
|
extraSupported: ['search', 'genre', 'skip']
|
|
}
|
|
],
|
|
resources: [
|
|
{
|
|
name: 'catalog',
|
|
types: ['movie', 'series'],
|
|
idPrefixes: ['tt']
|
|
},
|
|
{
|
|
name: 'meta',
|
|
types: ['movie', 'series'],
|
|
idPrefixes: ['tt']
|
|
}
|
|
],
|
|
behaviorHints: {
|
|
configurable: false
|
|
}
|
|
};
|
|
this.installedAddons.set(cinemetaId, fallbackManifest);
|
|
}
|
|
}
|
|
|
|
// Install OpenSubtitles v3 by default unless user has explicitly removed it
|
|
const opensubsId = 'org.stremio.opensubtitlesv3';
|
|
const hasUserRemovedOpenSubtitles = await this.hasUserRemovedAddon(opensubsId);
|
|
|
|
if (!this.installedAddons.has(opensubsId) && !hasUserRemovedOpenSubtitles) {
|
|
try {
|
|
const opensubsManifest = await this.getManifest('https://opensubtitles-v3.strem.io/manifest.json');
|
|
this.installedAddons.set(opensubsId, opensubsManifest);
|
|
} catch (error) {
|
|
const fallbackManifest: Manifest = {
|
|
id: opensubsId,
|
|
name: 'OpenSubtitles v3',
|
|
version: '1.0.0',
|
|
description: 'OpenSubtitles v3 Addon for Stremio',
|
|
url: 'https://opensubtitles-v3.strem.io',
|
|
originalUrl: 'https://opensubtitles-v3.strem.io/manifest.json',
|
|
types: ['movie', 'series'],
|
|
catalogs: [],
|
|
resources: [
|
|
{
|
|
name: 'subtitles',
|
|
types: ['movie', 'series'],
|
|
idPrefixes: ['tt']
|
|
}
|
|
],
|
|
behaviorHints: {
|
|
configurable: false
|
|
}
|
|
};
|
|
this.installedAddons.set(opensubsId, fallbackManifest);
|
|
}
|
|
}
|
|
|
|
// Load addon order if exists (scoped first, then legacy, then @user:local for migration safety)
|
|
let storedOrder = await AsyncStorage.getItem(`@user:${scope}:${this.ADDON_ORDER_KEY}`);
|
|
if (!storedOrder) storedOrder = await AsyncStorage.getItem(this.ADDON_ORDER_KEY);
|
|
if (!storedOrder) storedOrder = await AsyncStorage.getItem(`@user:local:${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 Cinemeta to order only if user hasn't removed it
|
|
const hasUserRemovedCinemetaOrder = await this.hasUserRemovedAddon(cinemetaId);
|
|
if (!this.addonOrder.includes(cinemetaId) && this.installedAddons.has(cinemetaId) && !hasUserRemovedCinemetaOrder) {
|
|
this.addonOrder.push(cinemetaId);
|
|
}
|
|
|
|
// Only add OpenSubtitles to order if user hasn't removed it
|
|
const hasUserRemovedOpenSubtitlesOrder = await this.hasUserRemovedAddon(opensubsId);
|
|
if (!this.addonOrder.includes(opensubsId) && this.installedAddons.has(opensubsId) && !hasUserRemovedOpenSubtitlesOrder) {
|
|
this.addonOrder.push(opensubsId);
|
|
}
|
|
|
|
// 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 and addons are saved
|
|
await this.saveAddonOrder();
|
|
await this.saveInstalledAddons();
|
|
|
|
this.initialized = true;
|
|
} catch (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 = 1, delay = 1000): Promise<T> {
|
|
let lastError: any;
|
|
for (let attempt = 0; attempt < retries + 1; attempt++) {
|
|
try {
|
|
return await request();
|
|
} catch (error: any) {
|
|
lastError = error;
|
|
|
|
// Don't retry on 404 errors (content not found) - these are expected for some content
|
|
if (error.response?.status === 404) {
|
|
throw error;
|
|
}
|
|
|
|
// Only log warnings for non-404 errors to reduce noise
|
|
if (error.response?.status !== 404) {
|
|
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());
|
|
const scope = (await AsyncStorage.getItem('@user:current')) || 'local';
|
|
// Write to both scoped and legacy keys for compatibility
|
|
await Promise.all([
|
|
AsyncStorage.setItem(`@user:${scope}:${this.STORAGE_KEY}`, JSON.stringify(addonsArray)),
|
|
AsyncStorage.setItem(this.STORAGE_KEY, JSON.stringify(addonsArray)),
|
|
]);
|
|
} catch (error) {
|
|
// Continue even if save fails
|
|
}
|
|
}
|
|
|
|
private async saveAddonOrder(): Promise<void> {
|
|
try {
|
|
const scope = (await AsyncStorage.getItem('@user:current')) || 'local';
|
|
// Write to both scoped and legacy keys for compatibility
|
|
await Promise.all([
|
|
AsyncStorage.setItem(`@user:${scope}:${this.ADDON_ORDER_KEY}`, JSON.stringify(this.addonOrder)),
|
|
AsyncStorage.setItem(this.ADDON_ORDER_KEY, JSON.stringify(this.addonOrder)),
|
|
]);
|
|
} catch (error) {
|
|
// Continue even if save fails
|
|
}
|
|
}
|
|
|
|
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);
|
|
|
|
// If addon was previously removed by user, unmark it on reinstall and clean up
|
|
await this.unmarkAddonAsRemovedByUser(manifest.id);
|
|
await this.cleanupRemovedAddonFromStorage(manifest.id);
|
|
|
|
// 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');
|
|
}
|
|
}
|
|
|
|
async removeAddon(id: string): Promise<void> {
|
|
// Allow removal of any addon, including pre-installed ones like Cinemeta
|
|
if (this.installedAddons.has(id)) {
|
|
this.installedAddons.delete(id);
|
|
// Remove from order
|
|
this.addonOrder = this.addonOrder.filter(addonId => addonId !== id);
|
|
|
|
// Track user explicit removal for any addon (tombstone)
|
|
await this.markAddonAsRemovedByUser(id);
|
|
// Proactively clean up any persisted orders/legacy keys for this addon
|
|
await this.cleanupRemovedAddonFromStorage(id);
|
|
|
|
// Persist removals before app possibly exits
|
|
await this.saveInstalledAddons();
|
|
await 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
|
|
const result = this.addonOrder
|
|
.filter(id => this.installedAddons.has(id))
|
|
.map(id => this.installedAddons.get(id)!);
|
|
return result;
|
|
}
|
|
|
|
async getInstalledAddonsAsync(): Promise<Manifest[]> {
|
|
await this.ensureInitialized();
|
|
return this.getInstalledAddons();
|
|
}
|
|
|
|
// Check if an addon is pre-installed and cannot be removed
|
|
isPreInstalledAddon(id: string): boolean {
|
|
// Allow removing all addons, including Cinemeta
|
|
return false;
|
|
}
|
|
|
|
// Check if user has explicitly removed an addon
|
|
async hasUserRemovedAddon(addonId: string): Promise<boolean> {
|
|
try {
|
|
const removedAddons = await AsyncStorage.getItem('user_removed_addons');
|
|
if (!removedAddons) return false;
|
|
const removedList = JSON.parse(removedAddons);
|
|
return Array.isArray(removedList) && removedList.includes(addonId);
|
|
} catch (error) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Mark an addon as removed by user
|
|
private async markAddonAsRemovedByUser(addonId: string): Promise<void> {
|
|
try {
|
|
const removedAddons = await AsyncStorage.getItem('user_removed_addons');
|
|
let removedList = removedAddons ? JSON.parse(removedAddons) : [];
|
|
if (!Array.isArray(removedList)) removedList = [];
|
|
|
|
if (!removedList.includes(addonId)) {
|
|
removedList.push(addonId);
|
|
await AsyncStorage.setItem('user_removed_addons', JSON.stringify(removedList));
|
|
}
|
|
} catch (error) {
|
|
// Silently fail - this is not critical functionality
|
|
}
|
|
}
|
|
|
|
// Remove an addon from the user removed list (allows reinstallation)
|
|
async unmarkAddonAsRemovedByUser(addonId: string): Promise<void> {
|
|
try {
|
|
const removedAddons = await AsyncStorage.getItem('user_removed_addons');
|
|
if (!removedAddons) return;
|
|
|
|
let removedList = JSON.parse(removedAddons);
|
|
if (!Array.isArray(removedList)) return;
|
|
|
|
const updatedList = removedList.filter(id => id !== addonId);
|
|
await AsyncStorage.setItem('user_removed_addons', JSON.stringify(updatedList));
|
|
} catch (error) {
|
|
// Silently fail - this is not critical functionality
|
|
}
|
|
}
|
|
|
|
// Clean up removed addon from all storage locations
|
|
private async cleanupRemovedAddonFromStorage(addonId: string): Promise<void> {
|
|
try {
|
|
const scope = (await AsyncStorage.getItem('@user:current')) || 'local';
|
|
|
|
// Remove from all possible addon order storage keys
|
|
const keys = [
|
|
`@user:${scope}:${this.ADDON_ORDER_KEY}`,
|
|
this.ADDON_ORDER_KEY,
|
|
`@user:local:${this.ADDON_ORDER_KEY}`
|
|
];
|
|
|
|
for (const key of keys) {
|
|
const storedOrder = await AsyncStorage.getItem(key);
|
|
if (storedOrder) {
|
|
const order = JSON.parse(storedOrder);
|
|
if (Array.isArray(order)) {
|
|
const updatedOrder = order.filter(id => id !== addonId);
|
|
await AsyncStorage.setItem(key, JSON.stringify(updatedOrder));
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// Silently fail - this is not critical functionality
|
|
}
|
|
}
|
|
|
|
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}`;
|
|
}
|
|
|
|
return { baseUrl: cleanBaseUrl, queryParams: queryString };
|
|
}
|
|
|
|
async getCatalog(manifest: Manifest, type: string, id: string, page = 1, filters: CatalogFilter[] = []): Promise<Meta[]> {
|
|
// Build URLs (path-style skip and query-style skip) and try both for broad addon support
|
|
const encodedId = encodeURIComponent(id);
|
|
const pageSkip = (page - 1) * this.DEFAULT_PAGE_SIZE;
|
|
const filterQuery = (filters || [])
|
|
.filter(f => f && f.value)
|
|
.map(f => `&${encodeURIComponent(f.title)}=${encodeURIComponent(f.value!)}`)
|
|
.join('');
|
|
|
|
// For all addons
|
|
if (!manifest.url) {
|
|
throw new Error('Addon URL is missing');
|
|
}
|
|
|
|
try {
|
|
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}` : ''}`;
|
|
// Add filters to path style (append with & or ? based on presence of queryParams)
|
|
const urlPathWithFilters = urlPathStyle + (urlPathStyle.includes('?') ? filterQuery : (filterQuery ? `?${filterQuery.slice(1)}` : ''));
|
|
|
|
// Candidate 2: Query-style skip URL: /catalog/{type}/{id}.json?skip={N}&limit={PAGE_SIZE}
|
|
let urlQueryStyle = `${baseUrl}/catalog/${type}/${encodedId}.json?skip=${pageSkip}&limit=${this.DEFAULT_PAGE_SIZE}`;
|
|
if (queryParams) urlQueryStyle += `&${queryParams}`;
|
|
urlQueryStyle += filterQuery;
|
|
|
|
// Try path-style first, then fallback to query-style
|
|
let response;
|
|
try {
|
|
response = await this.retryRequest(async () => axios.get(urlPathWithFilters));
|
|
} catch (e) {
|
|
try {
|
|
response = await this.retryRequest(async () => axios.get(urlQueryStyle));
|
|
} catch (e2) {
|
|
throw e2;
|
|
}
|
|
}
|
|
|
|
if (response && response.data) {
|
|
const hasMore = typeof response.data.hasMore === 'boolean' ? response.data.hasMore : undefined;
|
|
try {
|
|
const key = `${manifest.id}|${type}|${id}`;
|
|
if (typeof hasMore === 'boolean') this.catalogHasMore.set(key, hasMore);
|
|
} catch {}
|
|
if (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;
|
|
}
|
|
}
|
|
|
|
public getCatalogHasMore(manifestId: string, type: string, id: string): boolean | undefined {
|
|
const key = `${manifestId}|${type}|${id}`;
|
|
return this.catalogHasMore.get(key);
|
|
}
|
|
|
|
async getMetaDetails(type: string, id: string, preferredAddonId?: string): Promise<MetaDetails | null> {
|
|
try {
|
|
// Validate content ID first
|
|
const isValidId = await this.isValidContentId(type, id);
|
|
|
|
if (!isValidId) {
|
|
return null;
|
|
}
|
|
|
|
const addons = this.getInstalledAddons();
|
|
|
|
// If a preferred addon is specified, try it first
|
|
if (preferredAddonId) {
|
|
const preferredAddon = addons.find(addon => addon.id === preferredAddonId);
|
|
|
|
if (preferredAddon && preferredAddon.resources) {
|
|
// Build URL for metadata request
|
|
const { baseUrl, queryParams } = this.getAddonBaseURL(preferredAddon.url || '');
|
|
const encodedId = encodeURIComponent(id);
|
|
const url = queryParams ? `${baseUrl}/meta/${type}/${encodedId}.json?${queryParams}` : `${baseUrl}/meta/${type}/${encodedId}.json`;
|
|
|
|
// Check if addon supports meta resource for this type
|
|
let hasMetaSupport = false;
|
|
let supportsIdPrefix = 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;
|
|
// Check idPrefix support
|
|
if (Array.isArray(typedResource.idPrefixes) && typedResource.idPrefixes.length > 0) {
|
|
supportsIdPrefix = typedResource.idPrefixes.some(p => id.startsWith(p));
|
|
} else {
|
|
supportsIdPrefix = 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;
|
|
// Check addon-level idPrefixes
|
|
if (preferredAddon.idPrefixes && Array.isArray(preferredAddon.idPrefixes) && preferredAddon.idPrefixes.length > 0) {
|
|
supportsIdPrefix = preferredAddon.idPrefixes.some(p => id.startsWith(p));
|
|
} else {
|
|
supportsIdPrefix = true;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
// Only require ID prefix compatibility if the addon has declared specific prefixes
|
|
const requiresIdPrefix = preferredAddon.idPrefixes && preferredAddon.idPrefixes.length > 0;
|
|
const isSupported = hasMetaSupport && (!requiresIdPrefix || supportsIdPrefix);
|
|
|
|
if (isSupported) {
|
|
try {
|
|
const response = await this.retryRequest(async () => {
|
|
return await axios.get(url, { timeout: 10000 });
|
|
});
|
|
|
|
|
|
if (response.data && response.data.meta) {
|
|
return response.data.meta;
|
|
} else {
|
|
}
|
|
} catch (error: any) {
|
|
// Continue trying other addons
|
|
}
|
|
} else {
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 encodedId = encodeURIComponent(id);
|
|
const url = `${baseUrl}/meta/${type}/${encodedId}.json`;
|
|
|
|
|
|
const response = await this.retryRequest(async () => {
|
|
return await axios.get(url, { timeout: 10000 });
|
|
});
|
|
|
|
|
|
if (response.data && response.data.meta) {
|
|
return response.data.meta;
|
|
} else {
|
|
}
|
|
} catch (error: any) {
|
|
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 AND idPrefix (handles both string and object formats)
|
|
let hasMetaSupport = false;
|
|
let supportsIdPrefix = 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;
|
|
// Match idPrefixes if present; otherwise assume support
|
|
if (Array.isArray(typedResource.idPrefixes) && typedResource.idPrefixes.length > 0) {
|
|
supportsIdPrefix = typedResource.idPrefixes.some(p => id.startsWith(p));
|
|
} else {
|
|
supportsIdPrefix = 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;
|
|
// For simple resources, check addon-level idPrefixes if present
|
|
if (addon.idPrefixes && Array.isArray(addon.idPrefixes) && addon.idPrefixes.length > 0) {
|
|
supportsIdPrefix = addon.idPrefixes.some(p => id.startsWith(p));
|
|
} else {
|
|
supportsIdPrefix = true;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Require meta support, but allow any ID if addon doesn't declare specific prefixes
|
|
|
|
// Only require ID prefix compatibility if the addon has declared specific prefixes
|
|
const requiresIdPrefix = addon.idPrefixes && addon.idPrefixes.length > 0;
|
|
const isSupported = hasMetaSupport && (!requiresIdPrefix || supportsIdPrefix);
|
|
|
|
if (!isSupported) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url || '');
|
|
const encodedId = encodeURIComponent(id);
|
|
const url = queryParams ? `${baseUrl}/meta/${type}/${encodedId}.json?${queryParams}` : `${baseUrl}/meta/${type}/${encodedId}.json`;
|
|
|
|
|
|
const response = await this.retryRequest(async () => {
|
|
return await axios.get(url, { timeout: 10000 });
|
|
});
|
|
|
|
|
|
if (response.data && response.data.meta) {
|
|
return response.data.meta;
|
|
} else {
|
|
}
|
|
} catch (error: any) {
|
|
continue; // Try next addon
|
|
}
|
|
}
|
|
|
|
return null;
|
|
} catch (error) {
|
|
logger.error('Error in getMetaDetails:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Memory-efficient method to fetch only upcoming episodes within a specific date range
|
|
* This prevents over-fetching all episode data and reduces memory consumption
|
|
*/
|
|
async getUpcomingEpisodes(
|
|
type: string,
|
|
id: string,
|
|
options: {
|
|
daysBack?: number;
|
|
daysAhead?: number;
|
|
maxEpisodes?: number;
|
|
preferredAddonId?: string;
|
|
} = {}
|
|
): Promise<{ seriesName: string; poster: string; episodes: any[] } | null> {
|
|
const { daysBack = 14, daysAhead = 28, maxEpisodes = 50, preferredAddonId } = options;
|
|
|
|
try {
|
|
// Get metadata first (this is lightweight compared to episodes)
|
|
const metadata = await this.getMetaDetails(type, id, preferredAddonId);
|
|
if (!metadata) {
|
|
return null;
|
|
}
|
|
|
|
// If no videos array exists, return basic info
|
|
if (!metadata.videos || metadata.videos.length === 0) {
|
|
return {
|
|
seriesName: metadata.name,
|
|
poster: metadata.poster || '',
|
|
episodes: []
|
|
};
|
|
}
|
|
|
|
const now = new Date();
|
|
const startDate = new Date(now.getTime() - (daysBack * 24 * 60 * 60 * 1000));
|
|
const endDate = new Date(now.getTime() + (daysAhead * 24 * 60 * 60 * 1000));
|
|
|
|
// Filter episodes to only include those within our date range
|
|
// This is done immediately after fetching to reduce memory footprint
|
|
|
|
const filteredEpisodes = metadata.videos
|
|
.filter(video => {
|
|
if (!video.released) {
|
|
logger.log(`[StremioService] Episode ${video.id} has no release date`);
|
|
return false;
|
|
}
|
|
const releaseDate = new Date(video.released);
|
|
const inRange = releaseDate >= startDate && releaseDate <= endDate;
|
|
return inRange;
|
|
})
|
|
.sort((a, b) => new Date(a.released).getTime() - new Date(b.released).getTime())
|
|
.slice(0, maxEpisodes); // Limit number of episodes to prevent memory overflow
|
|
|
|
|
|
return {
|
|
seriesName: metadata.name,
|
|
poster: metadata.poster || '',
|
|
episodes: filteredEpisodes
|
|
};
|
|
} catch (error) {
|
|
logger.error(`[StremioService] Error fetching upcoming episodes for ${id}:`, 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 if local scrapers are enabled and execute them first
|
|
try {
|
|
// Load settings from AsyncStorage directly (scoped with fallback)
|
|
const scope = (await AsyncStorage.getItem('@user:current')) || 'local';
|
|
const settingsJson = (await AsyncStorage.getItem(`@user:${scope}:app_settings`))
|
|
|| (await AsyncStorage.getItem('app_settings'));
|
|
const rawSettings = settingsJson ? JSON.parse(settingsJson) : {};
|
|
const settings: AppSettings = { ...DEFAULT_SETTINGS, ...rawSettings };
|
|
|
|
if (settings.enableLocalScrapers) {
|
|
const hasScrapers = await localScraperService.hasScrapers();
|
|
if (hasScrapers) {
|
|
logger.log('🔧 [getStreams] Executing local scrapers for', type, id);
|
|
|
|
// Map Stremio types to local scraper types
|
|
const scraperType = type === 'series' ? 'tv' : type;
|
|
|
|
// Parse the Stremio ID to extract ID and season/episode info
|
|
let tmdbId: string | null = null;
|
|
let season: number | undefined = undefined;
|
|
let episode: number | undefined = undefined;
|
|
let idType: 'imdb' | 'kitsu' | 'tmdb' = 'imdb';
|
|
|
|
try {
|
|
const idParts = id.split(':');
|
|
let baseId: string;
|
|
|
|
// Handle different episode ID formats
|
|
if (idParts[0] === 'series') {
|
|
// Format: series:imdbId:season:episode or series:kitsu:7442:season:episode
|
|
baseId = idParts[1];
|
|
if (scraperType === 'tv' && idParts.length >= 4) {
|
|
season = parseInt(idParts[2], 10);
|
|
episode = parseInt(idParts[3], 10);
|
|
}
|
|
// Check if it's a kitsu ID
|
|
if (idParts[1] === 'kitsu') {
|
|
idType = 'kitsu';
|
|
baseId = idParts[2]; // kitsu:7442:season:episode -> baseId = 7442
|
|
if (scraperType === 'tv' && idParts.length >= 5) {
|
|
season = parseInt(idParts[3], 10);
|
|
episode = parseInt(idParts[4], 10);
|
|
}
|
|
}
|
|
} else if (idParts[0].startsWith('tt')) {
|
|
// Format: imdbId:season:episode (direct IMDb ID)
|
|
baseId = idParts[0];
|
|
idType = 'imdb';
|
|
if (scraperType === 'tv' && idParts.length >= 3) {
|
|
season = parseInt(idParts[1], 10);
|
|
episode = parseInt(idParts[2], 10);
|
|
}
|
|
} else if (idParts[0] === 'kitsu') {
|
|
// Format: kitsu:7442:season:episode (direct Kitsu ID)
|
|
baseId = idParts[1];
|
|
idType = 'kitsu';
|
|
if (scraperType === 'tv' && idParts.length >= 4) {
|
|
season = parseInt(idParts[2], 10);
|
|
episode = parseInt(idParts[3], 10);
|
|
}
|
|
} else if (idParts[0] === 'tmdb') {
|
|
// Format: tmdb:286801:season:episode (direct TMDB ID)
|
|
baseId = idParts[1];
|
|
idType = 'tmdb';
|
|
if (scraperType === 'tv' && idParts.length >= 4) {
|
|
season = parseInt(idParts[2], 10);
|
|
episode = parseInt(idParts[3], 10);
|
|
}
|
|
} else {
|
|
// Fallback: assume first part is the ID
|
|
baseId = idParts[0];
|
|
if (scraperType === 'tv' && idParts.length >= 3) {
|
|
season = parseInt(idParts[1], 10);
|
|
episode = parseInt(idParts[2], 10);
|
|
}
|
|
}
|
|
|
|
// Handle ID conversion for local scrapers (they need TMDB ID)
|
|
if (idType === 'imdb') {
|
|
// Convert IMDb ID to TMDB ID
|
|
const tmdbService = TMDBService.getInstance();
|
|
const tmdbIdNumber = await tmdbService.findTMDBIdByIMDB(baseId);
|
|
if (tmdbIdNumber) {
|
|
tmdbId = tmdbIdNumber.toString();
|
|
} else {
|
|
logger.log('🔧 [getStreams] Skipping local scrapers: could not convert IMDb to TMDB for', baseId);
|
|
}
|
|
} else if (idType === 'tmdb') {
|
|
// Already have TMDB ID, use it directly
|
|
tmdbId = baseId;
|
|
logger.log('🔧 [getStreams] Using TMDB ID directly for local scrapers:', tmdbId);
|
|
} else if (idType === 'kitsu') {
|
|
// For kitsu IDs, skip local scrapers as they don't support kitsu
|
|
logger.log('🔧 [getStreams] Skipping local scrapers for kitsu ID:', baseId);
|
|
} else {
|
|
// For other ID types, try to use as TMDB ID
|
|
tmdbId = baseId;
|
|
logger.log('🔧 [getStreams] Using base ID as TMDB ID for local scrapers:', tmdbId);
|
|
}
|
|
} catch (error) {
|
|
logger.warn('🔧 [getStreams] Skipping local scrapers due to ID parsing error:', error);
|
|
}
|
|
|
|
// Execute local scrapers asynchronously with TMDB ID (when available)
|
|
if (tmdbId) {
|
|
localScraperService.getStreams(scraperType, tmdbId, season, episode, (streams, scraperId, scraperName, error) => {
|
|
// Always call callback to ensure UI updates, regardless of result
|
|
if (callback) {
|
|
if (error) {
|
|
callback(null, scraperId, scraperName, error);
|
|
} else if (streams && streams.length > 0) {
|
|
callback(streams, scraperId, scraperName, null);
|
|
} else {
|
|
// Handle case where scraper completed successfully but returned no streams
|
|
// This ensures the scraper is removed from "fetching" state in UI
|
|
callback([], scraperId, scraperName, null);
|
|
}
|
|
}
|
|
});
|
|
} else {
|
|
logger.log('🔧 [getStreams] Local scrapers not executed for this ID/type; continuing with Stremio addons');
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// Continue even if local scrapers fail
|
|
}
|
|
|
|
// Check specifically for TMDB Embed addon
|
|
const tmdbEmbed = addons.find(addon => addon.id === 'org.tmdbembedapi');
|
|
if (!tmdbEmbed) {
|
|
// TMDB Embed addon not found
|
|
}
|
|
|
|
// 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;
|
|
let supportsIdPrefix = 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;
|
|
|
|
// 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
|
|
}
|
|
}
|
|
// 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;
|
|
// 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
|
|
}
|
|
}
|
|
}
|
|
|
|
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');
|
|
// 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 encodedId = encodeURIComponent(id);
|
|
const url = queryParams ? `${baseUrl}/stream/${type}/${encodedId}.json?${queryParams}` : `${baseUrl}/stream/${type}/${encodedId}.json`;
|
|
|
|
logger.log(`🔗 [getStreams] Requesting streams from ${addon.name} (${addon.id}): ${url}`);
|
|
|
|
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) {
|
|
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 encodedId = encodeURIComponent(id);
|
|
const streamPath = `/stream/${type}/${encodedId}.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') ? 60000 : 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
|
|
};
|
|
// 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:');
|
|
|
|
// Prefer full, untruncated text to preserve complete addon details
|
|
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 and is likely the formatted metadata, prefer it as-is
|
|
displayTitle = stream.description;
|
|
}
|
|
|
|
// Use full name for primary identifier if available
|
|
let 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;
|
|
|
|
// Memory optimization: Minimize behaviorHints to essential data only
|
|
const behaviorHints: Stream['behaviorHints'] = {
|
|
notWebReady: !isDirectStreamingUrl,
|
|
cached: stream.behaviorHints?.cached || undefined,
|
|
bingeGroup: stream.behaviorHints?.bingeGroup || undefined,
|
|
// Only include essential torrent data for magnet streams
|
|
...(isMagnetStream ? {
|
|
infoHash: stream.infoHash || streamUrl?.match(/btih:([a-zA-Z0-9]+)/)?.[1],
|
|
fileIdx: stream.fileIdx,
|
|
type: 'torrent',
|
|
} : {}),
|
|
};
|
|
|
|
// Explicitly construct the final Stream object with minimal data
|
|
const processedStream: Stream = {
|
|
url: streamUrl,
|
|
name: name,
|
|
title: displayTitle,
|
|
addonName: addon.name,
|
|
addonId: addon.id,
|
|
// Include description as-is to preserve full details
|
|
description: stream.description,
|
|
infoHash: stream.infoHash || undefined,
|
|
fileIdx: stream.fileIdx,
|
|
size: sizeInBytes,
|
|
isFree: stream.isFree,
|
|
isDebrid: !!(stream.behaviorHints?.cached),
|
|
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();
|
|
// Collect from all installed addons that expose a subtitles resource
|
|
const addons = this.getInstalledAddons();
|
|
const subtitleAddons = addons.filter(addon => {
|
|
if (!addon.resources) return false;
|
|
return addon.resources.some((resource: any) => {
|
|
if (typeof resource === 'string') return resource === 'subtitles';
|
|
return resource && resource.name === 'subtitles';
|
|
});
|
|
});
|
|
|
|
if (subtitleAddons.length === 0) {
|
|
logger.warn('No subtitle-capable addons installed');
|
|
return [];
|
|
}
|
|
|
|
const requests = subtitleAddons.map(async (addon) => {
|
|
if (!addon.url) return [] as Subtitle[];
|
|
try {
|
|
const { baseUrl } = this.getAddonBaseURL(addon.url || '');
|
|
let url = '';
|
|
if (type === 'series' && videoId) {
|
|
const episodeInfo = encodeURIComponent(videoId.replace('series:', ''));
|
|
url = `${baseUrl}/subtitles/series/${episodeInfo}.json`;
|
|
} else {
|
|
const encodedId = encodeURIComponent(id);
|
|
url = `${baseUrl}/subtitles/${type}/${encodedId}.json`;
|
|
}
|
|
logger.log(`Fetching subtitles from ${addon.name}: ${url}`);
|
|
const response = await this.retryRequest(async () => axios.get(url, { timeout: 10000 }));
|
|
if (response.data && Array.isArray(response.data.subtitles)) {
|
|
return response.data.subtitles.map((sub: any) => ({
|
|
...sub,
|
|
addon: addon.id,
|
|
addonName: addon.name,
|
|
})) as Subtitle[];
|
|
}
|
|
} catch (error) {
|
|
logger.error(`Failed to fetch subtitles from ${addon.name}:`, error);
|
|
}
|
|
return [] as Subtitle[];
|
|
});
|
|
|
|
const all = await Promise.all(requests);
|
|
// Flatten and de-duplicate by URL
|
|
const merged = ([] as Subtitle[]).concat(...all);
|
|
const seen = new Set<string>();
|
|
const deduped = merged.filter(s => {
|
|
const key = s.url;
|
|
if (!key) return false;
|
|
if (seen.has(key)) return false;
|
|
seen.add(key);
|
|
return true;
|
|
});
|
|
return deduped;
|
|
}
|
|
|
|
// 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; |