mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
1878 lines
No EOL
69 KiB
TypeScript
1878 lines
No EOL
69 KiB
TypeScript
import axios from 'axios';
|
|
import { mmkvStorage } from './mmkvStorage';
|
|
import { logger } from '../utils/logger';
|
|
import EventEmitter from 'eventemitter3';
|
|
import { localScraperService } from './pluginService';
|
|
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; // Required per protocol
|
|
url: string;
|
|
lang: string;
|
|
fps?: number;
|
|
addon?: string;
|
|
addonName?: string;
|
|
format?: 'srt' | 'vtt' | 'ass' | 'ssa';
|
|
}
|
|
|
|
// Source object for archive streams per protocol
|
|
export interface SourceObject {
|
|
url: string;
|
|
bytes?: number;
|
|
}
|
|
|
|
export interface Stream {
|
|
// Primary stream source - one of these must be provided
|
|
url?: string; // Direct HTTP(S)/FTP(S)/RTMP URL
|
|
ytId?: string; // YouTube video ID
|
|
infoHash?: string; // BitTorrent info hash
|
|
externalUrl?: string; // External URL to open in browser
|
|
nzbUrl?: string; // Usenet NZB file URL
|
|
rarUrls?: SourceObject[]; // RAR archive files
|
|
zipUrls?: SourceObject[]; // ZIP archive files
|
|
'7zipUrls'?: SourceObject[]; // 7z archive files
|
|
tgzUrls?: SourceObject[]; // TGZ archive files
|
|
tarUrls?: SourceObject[]; // TAR archive files
|
|
|
|
// Stream selection within archives/torrents
|
|
fileIdx?: number; // File index in archive/torrent
|
|
fileMustInclude?: string; // Regex for file matching in archives
|
|
servers?: string[]; // NNTP servers for nzbUrl
|
|
|
|
// Display information
|
|
name?: string; // Stream name (usually quality)
|
|
title?: string; // Stream title/description (deprecated for description)
|
|
description?: string; // Stream description
|
|
|
|
// Addon identification
|
|
addon?: string;
|
|
addonId?: string;
|
|
addonName?: string;
|
|
|
|
// Stream properties
|
|
size?: number;
|
|
isFree?: boolean;
|
|
isDebrid?: boolean;
|
|
quality?: string;
|
|
headers?: Record<string, string>;
|
|
|
|
// Embedded subtitles per protocol
|
|
subtitles?: Subtitle[];
|
|
|
|
// Additional tracker/DHT sources
|
|
sources?: string[];
|
|
|
|
// Complete behavior hints per protocol
|
|
behaviorHints?: {
|
|
bingeGroup?: string; // Group for binge watching
|
|
notWebReady?: boolean; // True if not HTTPS MP4
|
|
countryWhitelist?: string[]; // ISO 3166-1 alpha-3 codes (lowercase)
|
|
cached?: boolean; // Debrid cached status
|
|
proxyHeaders?: { // Custom headers for stream
|
|
request?: Record<string, string>;
|
|
response?: Record<string, string>;
|
|
};
|
|
videoHash?: string; // OpenSubtitles hash
|
|
videoSize?: number; // Video file size in bytes
|
|
filename?: string; // Video filename
|
|
[key: string]: any;
|
|
};
|
|
}
|
|
|
|
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;
|
|
// Per Stremio protocol - extra properties for filtering
|
|
extra?: CatalogExtra[];
|
|
}
|
|
|
|
// Extra property definition per protocol
|
|
export interface CatalogExtra {
|
|
name: string; // Property name (e.g., 'genre', 'search', 'skip')
|
|
isRequired?: boolean; // If true, must always be provided
|
|
options?: string[]; // Available options (e.g., genre list)
|
|
optionsLimit?: number; // Max selections allowed (default 1)
|
|
}
|
|
|
|
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;
|
|
configurationRequired?: boolean; // Per protocol
|
|
adult?: boolean; // Adult content flag
|
|
p2p?: boolean; // P2P content flag
|
|
};
|
|
config?: ConfigObject[]; // User configuration
|
|
addonCatalogs?: Catalog[]; // Addon catalogs
|
|
background?: string; // Background image URL
|
|
logo?: string; // Logo URL
|
|
contactEmail?: string; // Contact email
|
|
}
|
|
|
|
// Config object for addon configuration per protocol
|
|
interface ConfigObject {
|
|
key: string;
|
|
type: 'text' | 'number' | 'password' | 'checkbox' | 'select';
|
|
default?: string;
|
|
title?: string;
|
|
options?: string[];
|
|
required?: boolean;
|
|
}
|
|
|
|
// Meta Link object per protocol
|
|
export interface MetaLink {
|
|
name: string;
|
|
category: string; // 'actor', 'director', 'writer', etc.
|
|
url: string; // External URL or stremio:/// deep link
|
|
}
|
|
|
|
export interface MetaDetails extends Meta {
|
|
videos?: {
|
|
id: string;
|
|
title: string;
|
|
released: string;
|
|
season?: number;
|
|
episode?: number;
|
|
thumbnail?: string;
|
|
streams?: Stream[]; // Embedded streams (used by PPV-style addons)
|
|
available?: boolean; // Availability flag per protocol
|
|
overview?: string; // Episode summary per protocol
|
|
trailers?: Stream[]; // Trailer streams per protocol
|
|
}[];
|
|
links?: MetaLink[]; // Actor/Director/Genre links per protocol
|
|
}
|
|
|
|
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 = 100; // Protocol standard page size
|
|
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 mmkvStorage.getItem('@user:current')) || 'local';
|
|
// Prefer scoped storage, but fall back to legacy keys to preserve older installs
|
|
let storedAddons = await mmkvStorage.getItem(`@user:${scope}:${this.STORAGE_KEY}`);
|
|
if (!storedAddons) storedAddons = await mmkvStorage.getItem(this.STORAGE_KEY);
|
|
if (!storedAddons) storedAddons = await mmkvStorage.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 mmkvStorage.getItem(`@user:${scope}:${this.ADDON_ORDER_KEY}`);
|
|
if (!storedOrder) storedOrder = await mmkvStorage.getItem(this.ADDON_ORDER_KEY);
|
|
if (!storedOrder) storedOrder = await mmkvStorage.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 mmkvStorage.getItem('@user:current')) || 'local';
|
|
// Write to both scoped and legacy keys for compatibility
|
|
await Promise.all([
|
|
mmkvStorage.setItem(`@user:${scope}:${this.STORAGE_KEY}`, JSON.stringify(addonsArray)),
|
|
mmkvStorage.setItem(this.STORAGE_KEY, JSON.stringify(addonsArray)),
|
|
]);
|
|
} catch (error) {
|
|
// Continue even if save fails
|
|
}
|
|
}
|
|
|
|
private async saveAddonOrder(): Promise<void> {
|
|
try {
|
|
const scope = (await mmkvStorage.getItem('@user:current')) || 'local';
|
|
// Write to both scoped and legacy keys for compatibility
|
|
await Promise.all([
|
|
mmkvStorage.setItem(`@user:${scope}:${this.ADDON_ORDER_KEY}`, JSON.stringify(this.addonOrder)),
|
|
mmkvStorage.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 mmkvStorage.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 mmkvStorage.getItem('user_removed_addons');
|
|
let removedList = removedAddons ? JSON.parse(removedAddons) : [];
|
|
if (!Array.isArray(removedList)) removedList = [];
|
|
|
|
if (!removedList.includes(addonId)) {
|
|
removedList.push(addonId);
|
|
await mmkvStorage.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 mmkvStorage.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 mmkvStorage.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 mmkvStorage.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 mmkvStorage.getItem(key);
|
|
if (storedOrder) {
|
|
const order = JSON.parse(storedOrder);
|
|
if (Array.isArray(order)) {
|
|
const updatedOrder = order.filter(id => id !== addonId);
|
|
await mmkvStorage.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 per Stremio protocol: /{resource}/{type}/{id}/{extraArgs}.json
|
|
// Extra args (search, genre, skip) go in path segment, NOT query params
|
|
const encodedId = encodeURIComponent(id);
|
|
const pageSkip = (page - 1) * this.DEFAULT_PAGE_SIZE;
|
|
|
|
// For all addons
|
|
if (!manifest.url) {
|
|
throw new Error('Addon URL is missing');
|
|
}
|
|
|
|
try {
|
|
if (__DEV__) console.log(`🔍 [getCatalog] Manifest URL for ${manifest.name}: ${manifest.url}`);
|
|
const { baseUrl, queryParams } = this.getAddonBaseURL(manifest.url);
|
|
|
|
// Build extraArgs as combined path segment per protocol
|
|
// Format: /catalog/{type}/{id}/{extraArgs}.json where extraArgs is like "genre=Action&skip=100"
|
|
const extraParts: string[] = [];
|
|
|
|
// Add filters to extra args (genre, search, etc.)
|
|
if (filters && filters.length > 0) {
|
|
filters.filter(f => f && f.value).forEach(f => {
|
|
extraParts.push(`${encodeURIComponent(f.title)}=${encodeURIComponent(f.value)}`);
|
|
});
|
|
}
|
|
|
|
// Add skip for pagination (only if not page 1)
|
|
if (pageSkip > 0) {
|
|
extraParts.push(`skip=${pageSkip}`);
|
|
}
|
|
|
|
// Build the extraArgs path segment
|
|
const extraArgsPath = extraParts.length > 0 ? `/${extraParts.join('&')}` : '';
|
|
|
|
// Construct URLs per protocol
|
|
// Primary: Path-style with extra args in path segment
|
|
const urlPathStyle = `${baseUrl}/catalog/${type}/${encodedId}${extraArgsPath}.json${queryParams ? `?${queryParams}` : ''}`;
|
|
|
|
// Fallback for page 1 without filters: simple URL
|
|
const urlSimple = `${baseUrl}/catalog/${type}/${encodedId}.json${queryParams ? `?${queryParams}` : ''}`;
|
|
|
|
// Legacy fallback: Query-style URL (for older addons)
|
|
const legacyFilterQuery = (filters || [])
|
|
.filter(f => f && f.value)
|
|
.map(f => `&${encodeURIComponent(f.title)}=${encodeURIComponent(f.value!)}`)
|
|
.join('');
|
|
let urlQueryStyle = `${baseUrl}/catalog/${type}/${encodedId}.json?skip=${pageSkip}&limit=${this.DEFAULT_PAGE_SIZE}`;
|
|
if (queryParams) urlQueryStyle += `&${queryParams}`;
|
|
urlQueryStyle += legacyFilterQuery;
|
|
|
|
// Try URLs in order of compatibility
|
|
let response;
|
|
try {
|
|
// For page 1 without filters, try simple URL first (best compatibility)
|
|
if (pageSkip === 0 && extraParts.length === 0) {
|
|
if (__DEV__) console.log(`🔍 [getCatalog] Trying simple URL for ${manifest.name}: ${urlSimple}`);
|
|
response = await this.retryRequest(async () => axios.get(urlSimple));
|
|
// Check if we got valid metas - if empty, try other styles
|
|
if (!response?.data?.metas || response.data.metas.length === 0) {
|
|
throw new Error('Empty response from simple URL');
|
|
}
|
|
} else {
|
|
throw new Error('Has extra args, use path-style');
|
|
}
|
|
} catch (e) {
|
|
try {
|
|
// Try path-style URL (correct per protocol)
|
|
if (__DEV__) console.log(`🔍 [getCatalog] Trying path-style URL for ${manifest.name}: ${urlPathStyle}`);
|
|
response = await this.retryRequest(async () => axios.get(urlPathStyle));
|
|
// Check if we got valid metas - if empty, try query-style
|
|
if (!response?.data?.metas || response.data.metas.length === 0) {
|
|
throw new Error('Empty response from path-style URL');
|
|
}
|
|
} catch (e2) {
|
|
try {
|
|
// Try legacy query-style URL as last resort
|
|
if (__DEV__) console.log(`🔍 [getCatalog] Trying query-style URL for ${manifest.name}: ${urlQueryStyle}`);
|
|
response = await this.retryRequest(async () => axios.get(urlQueryStyle));
|
|
} catch (e3) {
|
|
if (__DEV__) console.log(`❌ [getCatalog] All URL styles failed for ${manifest.name}`);
|
|
throw e3;
|
|
}
|
|
}
|
|
}
|
|
|
|
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();
|
|
|
|
// Check if local scrapers are enabled and execute them first
|
|
try {
|
|
// Load settings from AsyncStorage directly (scoped with fallback)
|
|
const scope = (await mmkvStorage.getItem('@user:current')) || 'local';
|
|
const settingsJson = (await mmkvStorage.getItem(`@user:${scope}:app_settings`))
|
|
|| (await mmkvStorage.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 - no TMDB ID available');
|
|
// Notify UI that local scrapers won't execute by calling their callbacks
|
|
try {
|
|
const installedScrapers = await localScraperService.getInstalledScrapers();
|
|
const enabledScrapers = installedScrapers.filter(s => s.enabled);
|
|
enabledScrapers.forEach(scraper => {
|
|
if (callback) {
|
|
callback([], scraper.id, scraper.name, null);
|
|
}
|
|
});
|
|
} catch (error) {
|
|
logger.warn('🔧 [getStreams] Failed to notify UI about skipped local scrapers:', error);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} 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;
|
|
}
|
|
|
|
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));
|
|
} else {
|
|
// If no idPrefixes specified, assume it supports all prefixes
|
|
supportsIdPrefix = 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;
|
|
// 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));
|
|
} else {
|
|
// If no idPrefixes specified, assume it supports all prefixes
|
|
supportsIdPrefix = true;
|
|
}
|
|
break; // Found the simple stream resource string and type support
|
|
}
|
|
}
|
|
}
|
|
|
|
const canHandleRequest = hasStreamResource && supportsIdPrefix;
|
|
|
|
return canHandleRequest;
|
|
});
|
|
|
|
|
|
|
|
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 {
|
|
if (typeof url !== 'string') return false;
|
|
return url.startsWith('http://') || url.startsWith('https://');
|
|
}
|
|
|
|
private getStreamUrl(stream: any): string {
|
|
// Prefer plain string URLs; guard against objects or unexpected types
|
|
if (typeof stream?.url === 'string') {
|
|
return stream.url;
|
|
}
|
|
// Some addons might nest the URL inside an object; try common shape
|
|
if (stream?.url && typeof stream.url === 'object' && typeof stream.url.url === 'string') {
|
|
return stream.url.url;
|
|
}
|
|
|
|
// Handle YouTube video ID per protocol
|
|
if (stream.ytId) {
|
|
return `https://www.youtube.com/watch?v=${stream.ytId}`;
|
|
}
|
|
|
|
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'
|
|
];
|
|
// Add sources from stream if available per protocol
|
|
const additionalTrackers = (stream.sources || [])
|
|
.filter((s: string) => s.startsWith('tracker:'))
|
|
.map((s: string) => s.replace('tracker:', ''));
|
|
const allTrackers = [...trackers, ...additionalTrackers];
|
|
const trackersString = allTrackers.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 per protocol
|
|
// One of: url, ytId, infoHash, externalUrl, nzbUrl, or archive arrays
|
|
const hasPlayableLink = !!(
|
|
stream.url ||
|
|
stream.infoHash ||
|
|
stream.ytId ||
|
|
stream.externalUrl ||
|
|
stream.nzbUrl ||
|
|
(stream.rarUrls && stream.rarUrls.length > 0) ||
|
|
(stream.zipUrls && stream.zipUrls.length > 0) ||
|
|
(stream['7zipUrls'] && stream['7zipUrls'].length > 0) ||
|
|
(stream.tgzUrls && stream.tgzUrls.length > 0) ||
|
|
(stream.tarUrls && stream.tarUrls.length > 0)
|
|
);
|
|
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:');
|
|
const isExternalUrl = !!stream.externalUrl;
|
|
const isYouTube = !!stream.ytId;
|
|
|
|
// 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;
|
|
|
|
// Preserve complete behaviorHints per protocol
|
|
const behaviorHints: Stream['behaviorHints'] = {
|
|
notWebReady: !isDirectStreamingUrl || isExternalUrl,
|
|
cached: stream.behaviorHints?.cached || undefined,
|
|
bingeGroup: stream.behaviorHints?.bingeGroup || undefined,
|
|
// Per protocol: Country whitelist for geo-restrictions
|
|
countryWhitelist: stream.behaviorHints?.countryWhitelist || undefined,
|
|
// Per protocol: Proxy headers for custom stream headers
|
|
proxyHeaders: stream.behaviorHints?.proxyHeaders || undefined,
|
|
// Per protocol: Video metadata for subtitle matching
|
|
videoHash: stream.behaviorHints?.videoHash || undefined,
|
|
videoSize: stream.behaviorHints?.videoSize || undefined,
|
|
filename: stream.behaviorHints?.filename || undefined,
|
|
// 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 all protocol fields
|
|
const processedStream: Stream = {
|
|
// Primary URL (may be empty for ytId/externalUrl streams)
|
|
url: streamUrl || undefined,
|
|
name: name,
|
|
title: displayTitle,
|
|
addonName: addon.name,
|
|
addonId: addon.id,
|
|
|
|
// Include description as-is to preserve full details
|
|
description: stream.description,
|
|
|
|
// Alternative source types per protocol
|
|
ytId: stream.ytId || undefined,
|
|
externalUrl: stream.externalUrl || undefined,
|
|
nzbUrl: stream.nzbUrl || undefined,
|
|
rarUrls: stream.rarUrls || undefined,
|
|
zipUrls: stream.zipUrls || undefined,
|
|
'7zipUrls': stream['7zipUrls'] || undefined,
|
|
tgzUrls: stream.tgzUrls || undefined,
|
|
tarUrls: stream.tarUrls || undefined,
|
|
servers: stream.servers || undefined,
|
|
|
|
// Torrent/archive file selection
|
|
infoHash: stream.infoHash || undefined,
|
|
fileIdx: stream.fileIdx,
|
|
fileMustInclude: stream.fileMustInclude || undefined,
|
|
|
|
// Stream metadata
|
|
size: sizeInBytes,
|
|
isFree: stream.isFree,
|
|
isDebrid: !!(stream.behaviorHints?.cached),
|
|
|
|
// Embedded subtitles per protocol
|
|
subtitles: stream.subtitles?.map((sub: any, index: number) => ({
|
|
id: sub.id || `${addon.id}-${sub.lang || 'unknown'}-${index}`,
|
|
...sub,
|
|
})) || undefined,
|
|
|
|
// Additional tracker/DHT sources per protocol
|
|
sources: stream.sources || undefined,
|
|
|
|
// Complete behavior hints
|
|
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, index: number) => ({
|
|
// Ensure ID is always present per protocol (required field)
|
|
id: sub.id || `${addon.id}-${sub.lang || 'unknown'}-${index}`,
|
|
...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 (including embedded streams in metadata)
|
|
async hasStreamProviders(type?: string): Promise<boolean> {
|
|
await this.ensureInitialized();
|
|
const addons = Array.from(this.installedAddons.values());
|
|
|
|
for (const addon of addons) {
|
|
if (addon.resources && Array.isArray(addon.resources)) {
|
|
// Check for explicit 'stream' resource
|
|
const hasStreamResource = addon.resources.some(resource =>
|
|
typeof resource === 'string'
|
|
? resource === 'stream'
|
|
: (resource as any).name === 'stream'
|
|
);
|
|
|
|
if (hasStreamResource) {
|
|
// If type specified, also check if addon supports this type
|
|
if (type) {
|
|
const supportsType = addon.types?.includes(type) ||
|
|
addon.resources.some(resource =>
|
|
typeof resource === 'object' &&
|
|
(resource as any).name === 'stream' &&
|
|
(resource as any).types?.includes(type)
|
|
);
|
|
if (supportsType) return true;
|
|
} else {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Also check for addons with meta resource that support the type
|
|
// These addons might provide embedded streams within metadata
|
|
if (type) {
|
|
const hasMetaResource = addon.resources.some(resource =>
|
|
typeof resource === 'string'
|
|
? resource === 'meta'
|
|
: (resource as any).name === 'meta'
|
|
);
|
|
|
|
if (hasMetaResource && addon.types?.includes(type)) {
|
|
// This addon provides meta for the type - might have embedded streams
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Fetch addon catalogs from addons that provide the addon_catalog resource per protocol.
|
|
* Returns a list of other addon manifests that can be installed.
|
|
*/
|
|
async getAddonCatalogs(type: string, id: string): Promise<AddonCatalogItem[]> {
|
|
await this.ensureInitialized();
|
|
|
|
// Find addons that provide addon_catalog resource
|
|
const addons = this.getInstalledAddons().filter(addon => {
|
|
if (!addon.resources) return false;
|
|
return addon.resources.some(r =>
|
|
typeof r === 'string' ? r === 'addon_catalog' : (r as any).name === 'addon_catalog'
|
|
);
|
|
});
|
|
|
|
if (addons.length === 0) {
|
|
logger.log('[getAddonCatalogs] No addons provide addon_catalog resource');
|
|
return [];
|
|
}
|
|
|
|
const results: AddonCatalogItem[] = [];
|
|
|
|
for (const addon of addons) {
|
|
try {
|
|
const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url || '');
|
|
const url = `${baseUrl}/addon_catalog/${type}/${encodeURIComponent(id)}.json${queryParams ? `?${queryParams}` : ''}`;
|
|
|
|
logger.log(`[getAddonCatalogs] Fetching from ${addon.name}: ${url}`);
|
|
const response = await this.retryRequest(() => axios.get(url, { timeout: 10000 }));
|
|
|
|
if (response.data?.addons && Array.isArray(response.data.addons)) {
|
|
results.push(...response.data.addons);
|
|
}
|
|
} catch (error) {
|
|
logger.warn(`[getAddonCatalogs] Failed to fetch from ${addon.name}:`, error);
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
}
|
|
|
|
// Addon catalog item per protocol
|
|
export interface AddonCatalogItem {
|
|
transportName: string; // 'http'
|
|
transportUrl: string; // URL to manifest.json
|
|
manifest: Manifest;
|
|
}
|
|
|
|
export const stremioService = StremioService.getInstance();
|
|
export default stremioService; |