From f8cfdc8ced729ccec4c65a9b06abdfbcea2b6beb Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Tue, 17 Mar 2026 02:27:13 +0530 Subject: [PATCH 01/10] fix lower case standardization for search catalogs --- src/hooks/useMetadata.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/hooks/useMetadata.ts b/src/hooks/useMetadata.ts index 0b5c90c8..c9e3c6ba 100644 --- a/src/hooks/useMetadata.ts +++ b/src/hooks/useMetadata.ts @@ -117,9 +117,15 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat // Normalize anime subtypes to their base types for all internal logic. // anime.series behaves like series; anime.movie behaves like movie. - const normalizedType = type === 'anime.series' ? 'series' - : type === 'anime.movie' ? 'movie' - : type; + // Lowercase first — some addons use capitalized types (e.g. "Movie", "Series", "Other") + // which would break all type comparisons downstream. + const lowercasedType = type ? type.toLowerCase() : type; + + // Normalize anime subtypes to their base types for all internal logic. + // anime.series behaves like series; anime.movie behaves like movie. + const normalizedType = lowercasedType === 'anime.series' ? 'series' + : lowercasedType === 'anime.movie' ? 'movie' + : lowercasedType; const [metadata, setMetadata] = useState(null); const [loading, setLoading] = useState(true); From 1962e66fb488f26652d0874851b68870c4f2b5bd Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Tue, 17 Mar 2026 02:29:33 +0530 Subject: [PATCH 02/10] fix lower case standardization for search catalogs --- src/services/catalogService.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/services/catalogService.ts b/src/services/catalogService.ts index f918947d..448cafa3 100644 --- a/src/services/catalogService.ts +++ b/src/services/catalogService.ts @@ -1731,12 +1731,25 @@ class CatalogService { if (metas.length > 0) { const items = metas.map(meta => { const content = this.convertMetaToStreamingContent(meta); - content.addonId = manifest.id; + // Only set addonId to this addon if it also supports the meta resource. + // Search-only addons (e.g. AI search, people search) don't serve metadata — + // leaving addonId unset lets useMetadata fall through to a proper meta addon. + const addonSupportsMeta = Array.isArray(manifest.resources) && manifest.resources.some((r: any) => + r === 'meta' || (typeof r === 'object' && r?.name === 'meta') + ); + + if (addonSupportsMeta) { + content.addonId = manifest.id; + } // The meta's own type field may be generic (e.g. "series") even when // the catalog it came from is more specific (e.g. "anime.series"). // Stamp the catalog type so grouping in the UI is correct. - if (type && content.type !== type) { - content.type = type; + // Also lowercase — some addons declare types with capital letters (e.g. "Movie", "Other"). + const normalizedCatalogType = type ? type.toLowerCase() : type; + if (normalizedCatalogType && content.type !== normalizedCatalogType) { + content.type = normalizedCatalogType; + } else if (content.type) { + content.type = content.type.toLowerCase(); } return content; }); From 0d62ad1297c75e00dab4db331de3a40b9bd6efba Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 17 Mar 2026 05:26:39 +0530 Subject: [PATCH 03/10] ref: StremioService.ts into managable chunks --- src/services/catalogService.ts | 4 +- src/services/stremio/StremioService.ts | 456 ++++ src/services/stremio/addon-management.ts | 449 ++++ src/services/stremio/addon-order.ts | 112 + src/services/stremio/catalog-operations.ts | 423 ++++ src/services/stremio/context.ts | 39 + src/services/stremio/events.ts | 9 + src/services/stremio/stream-operations.ts | 391 ++++ src/services/stremio/subtitle-operations.ts | 126 ++ src/services/stremio/types.ts | 236 ++ src/services/stremioService.ts | 2181 +------------------ 11 files changed, 2264 insertions(+), 2162 deletions(-) create mode 100644 src/services/stremio/StremioService.ts create mode 100644 src/services/stremio/addon-management.ts create mode 100644 src/services/stremio/addon-order.ts create mode 100644 src/services/stremio/catalog-operations.ts create mode 100644 src/services/stremio/context.ts create mode 100644 src/services/stremio/events.ts create mode 100644 src/services/stremio/stream-operations.ts create mode 100644 src/services/stremio/subtitle-operations.ts create mode 100644 src/services/stremio/types.ts diff --git a/src/services/catalogService.ts b/src/services/catalogService.ts index f918947d..e27b26be 100644 --- a/src/services/catalogService.ts +++ b/src/services/catalogService.ts @@ -1432,7 +1432,7 @@ class CatalogService { logger.log(`Found ${searchableAddons.length} searchable addons:`, searchableAddons.map(a => a.name).join(', ')); // Search each addon and keep results grouped - for (const addon of searchableAddons) { + for (const [addonIndex, addon] of searchableAddons.entries()) { // Get the manifest to ensure we have the correct URL const manifest = manifestMap.get(addon.id); if (!manifest) { @@ -1473,6 +1473,8 @@ class CatalogService { byAddon.push({ addonId: addon.id, addonName: addon.name, + sectionName: addon.name, + catalogIndex: addonIndex, results: uniqueAddonResults, }); } diff --git a/src/services/stremio/StremioService.ts b/src/services/stremio/StremioService.ts new file mode 100644 index 00000000..2cfa043c --- /dev/null +++ b/src/services/stremio/StremioService.ts @@ -0,0 +1,456 @@ +import { mmkvStorage } from '../mmkvStorage'; +import { logger } from '../../utils/logger'; + +import type { StremioServiceContext } from './context'; +import { + getAllSupportedIdPrefixes as getAllSupportedIdPrefixesImpl, + getAllSupportedTypes as getAllSupportedTypesImpl, + getInstalledAddons as getInstalledAddonsImpl, + getInstalledAddonsAsync as getInstalledAddonsAsyncImpl, + getManifest as getManifestImpl, + hasUserRemovedAddon as hasUserRemovedAddonImpl, + initializeAddons, + installAddon as installAddonImpl, + isCollectionContent as isCollectionContentImpl, + isPreInstalledAddon as isPreInstalledAddonImpl, + removeAddon as removeAddonImpl, + unmarkAddonAsRemovedByUser as unmarkAddonAsRemovedByUserImpl, +} from './addon-management'; +import { + applyAddonOrderFromManifestUrls as applyAddonOrderFromManifestUrlsImpl, + moveAddonDown as moveAddonDownImpl, + moveAddonUp as moveAddonUpImpl, +} from './addon-order'; +import { + getAddonCapabilities as getAddonCapabilitiesImpl, + getAddonCatalogs as getAddonCatalogsImpl, + getAllCatalogs as getAllCatalogsImpl, + getCatalog as getCatalogImpl, + getCatalogHasMore as getCatalogHasMoreImpl, + getCatalogPreview as getCatalogPreviewImpl, + getMetaDetails as getMetaDetailsImpl, + getUpcomingEpisodes as getUpcomingEpisodesImpl, + isValidContentId as isValidContentIdImpl, +} from './catalog-operations'; +import { getStreams as getStreamsImpl, hasStreamProviders as hasStreamProvidersImpl } from './stream-operations'; +import { getSubtitles as getSubtitlesImpl } from './subtitle-operations'; +import type { + AddonCapabilities, + AddonCatalogItem, + CatalogExtra, + CatalogFilter, + Manifest, + Meta, + MetaDetails, + MetaLink, + ResourceObject, + SourceObject, + Stream, + StreamCallback, + StreamResponse, + Subtitle, + SubtitleResponse, +} from './types'; + +class StremioService implements StremioServiceContext { + private static instance: StremioService; + + installedAddons: Map = new Map(); + addonOrder: string[] = []; + readonly STORAGE_KEY = 'stremio-addons'; + readonly ADDON_ORDER_KEY = 'stremio-addon-order'; + readonly DEFAULT_PAGE_SIZE = 100; + initialized = false; + initializationPromise: Promise | null = null; + catalogHasMore: Map = new Map(); + + private constructor() { + this.initializationPromise = this.initialize(); + } + + static getInstance(): StremioService { + if (!StremioService.instance) { + StremioService.instance = new StremioService(); + } + + return StremioService.instance; + } + + private async initialize(): Promise { + await initializeAddons(this); + } + + async ensureInitialized(): Promise { + if (!this.initialized && this.initializationPromise) { + await this.initializationPromise; + } + } + + async retryRequest(request: () => Promise, retries = 1, delay = 1000): Promise { + let lastError: any; + + for (let attempt = 0; attempt < retries + 1; attempt += 1) { + try { + return await request(); + } catch (error: any) { + lastError = error; + + if (error.response?.status === 404) { + throw error; + } + + 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; + } + + async saveInstalledAddons(): Promise { + try { + const addonsArray = Array.from(this.installedAddons.values()); + const scope = (await mmkvStorage.getItem('@user:current')) || 'local'; + await Promise.all([ + mmkvStorage.setItem(`@user:${scope}:${this.STORAGE_KEY}`, JSON.stringify(addonsArray)), + mmkvStorage.setItem(this.STORAGE_KEY, JSON.stringify(addonsArray)), + ]); + } catch { + // Storage writes are best-effort. + } + } + + async saveAddonOrder(): Promise { + try { + const scope = (await mmkvStorage.getItem('@user:current')) || 'local'; + 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 { + // Storage writes are best-effort. + } + } + + generateInstallationId(addonId: string): string { + const timestamp = Date.now(); + const random = Math.random().toString(36).substring(2, 9); + return `${addonId}-${timestamp}-${random}`; + } + + addonProvidesStreams(manifest: Manifest): boolean { + return (manifest.resources || []).some(resource => { + if (typeof resource === 'string') { + return resource === 'stream'; + } + + return resource !== null && typeof resource === 'object' && 'name' in resource + ? (resource as ResourceObject).name === 'stream' + : false; + }); + } + + formatId(id: string): string { + return id.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase(); + } + + getAddonBaseURL(url: string): { baseUrl: string; queryParams?: string } { + const [baseUrl, queryString] = url.split('?'); + let cleanBaseUrl = baseUrl.replace(/manifest\.json$/, '').replace(/\/$/, ''); + + if (!cleanBaseUrl.startsWith('http')) { + cleanBaseUrl = `https://${cleanBaseUrl}`; + } + + return { baseUrl: cleanBaseUrl, queryParams: queryString }; + } + + private isDirectStreamingUrl(url?: string): boolean { + return typeof url === 'string' && (url.startsWith('http://') || url.startsWith('https://')); + } + + private getStreamUrl(stream: any): string { + if (typeof stream?.url === 'string') { + return stream.url; + } + + if (stream?.url && typeof stream.url === 'object' && typeof stream.url.url === 'string') { + return stream.url.url; + } + + 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', + ]; + const additionalTrackers = (stream.sources || []) + .filter((source: string) => source.startsWith('tracker:')) + .map((source: string) => source.replace('tracker:', '')); + const trackersString = [...trackers, ...additionalTrackers] + .map(tracker => `&tr=${encodeURIComponent(tracker)}`) + .join(''); + const encodedTitle = encodeURIComponent(stream.title || stream.name || 'Unknown'); + return `magnet:?xt=urn:btih:${stream.infoHash}&dn=${encodedTitle}${trackersString}`; + } + + return ''; + } + + processStreams(streams: any[], addon: Manifest): Stream[] { + return streams + .filter(stream => { + const hasPlayableLink = Boolean( + stream.url || + stream.infoHash || + stream.ytId || + stream.externalUrl || + stream.nzbUrl || + stream.rarUrls?.length || + stream.zipUrls?.length || + stream['7zipUrls']?.length || + stream.tgzUrls?.length || + stream.tarUrls?.length + ); + const hasIdentifier = Boolean(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 = Boolean(stream.externalUrl); + + let displayTitle = stream.title || stream.name || 'Unnamed Stream'; + if ( + stream.description && + stream.description.includes('\n') && + stream.description.length > (stream.title?.length || 0) + ) { + displayTitle = stream.description; + } + + const sizeInBytes = stream.behaviorHints?.videoSize || stream.size || undefined; + const behaviorHints: Stream['behaviorHints'] = { + notWebReady: !isDirectStreamingUrl || isExternalUrl, + cached: stream.behaviorHints?.cached || undefined, + bingeGroup: stream.behaviorHints?.bingeGroup || undefined, + countryWhitelist: stream.behaviorHints?.countryWhitelist || undefined, + proxyHeaders: stream.behaviorHints?.proxyHeaders || undefined, + videoHash: stream.behaviorHints?.videoHash || undefined, + videoSize: stream.behaviorHints?.videoSize || undefined, + filename: stream.behaviorHints?.filename || undefined, + ...(isMagnetStream + ? { + infoHash: stream.infoHash || streamUrl.match(/btih:([a-zA-Z0-9]+)/)?.[1], + fileIdx: stream.fileIdx, + type: 'torrent', + } + : {}), + }; + + return { + url: streamUrl || undefined, + name: stream.name || stream.title || 'Unnamed Stream', + title: displayTitle, + addonName: addon.name, + addonId: addon.id, + description: stream.description, + 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, + infoHash: stream.infoHash || undefined, + fileIdx: stream.fileIdx, + fileMustInclude: stream.fileMustInclude || undefined, + size: sizeInBytes, + isFree: stream.isFree, + isDebrid: Boolean(stream.behaviorHints?.cached), + subtitles: + stream.subtitles?.map((subtitle: any, index: number) => ({ + id: subtitle.id || `${addon.id}-${subtitle.lang || 'unknown'}-${index}`, + ...subtitle, + })) || undefined, + sources: stream.sources || undefined, + behaviorHints, + }; + }); + } + + getAllSupportedTypes(): string[] { + return getAllSupportedTypesImpl(this); + } + + getAllSupportedIdPrefixes(type: string): string[] { + return getAllSupportedIdPrefixesImpl(this, type); + } + + isCollectionContent(id: string): { isCollection: boolean; addon?: Manifest } { + return isCollectionContentImpl(this, id); + } + + async isValidContentId(type: string, id: string | null | undefined): Promise { + return isValidContentIdImpl( + this, + type, + id, + () => this.getAllSupportedTypes(), + value => this.getAllSupportedIdPrefixes(value) + ); + } + + async getManifest(url: string): Promise { + return getManifestImpl(this, url); + } + + async installAddon(url: string): Promise { + await installAddonImpl(this, url); + } + + async removeAddon(installationId: string): Promise { + await removeAddonImpl(this, installationId); + } + + getInstalledAddons(): Manifest[] { + return getInstalledAddonsImpl(this); + } + + async getInstalledAddonsAsync(): Promise { + return getInstalledAddonsAsyncImpl(this); + } + + isPreInstalledAddon(id: string): boolean { + void id; + return isPreInstalledAddonImpl(); + } + + async hasUserRemovedAddon(addonId: string): Promise { + return hasUserRemovedAddonImpl(addonId); + } + + async unmarkAddonAsRemovedByUser(addonId: string): Promise { + await unmarkAddonAsRemovedByUserImpl(addonId); + } + + async getAllCatalogs(): Promise> { + return getAllCatalogsImpl(this); + } + + async getCatalog( + manifest: Manifest, + type: string, + id: string, + page = 1, + filters: CatalogFilter[] = [] + ): Promise { + return getCatalogImpl(this, manifest, type, id, page, filters); + } + + getCatalogHasMore(manifestId: string, type: string, id: string): boolean | undefined { + return getCatalogHasMoreImpl(this, manifestId, type, id); + } + + async getMetaDetails(type: string, id: string, preferredAddonId?: string): Promise { + return getMetaDetailsImpl(this, type, id, preferredAddonId); + } + + async getUpcomingEpisodes( + type: string, + id: string, + options: { + daysBack?: number; + daysAhead?: number; + maxEpisodes?: number; + preferredAddonId?: string; + } = {} + ): Promise<{ seriesName: string; poster: string; episodes: any[] } | null> { + return getUpcomingEpisodesImpl(this, type, id, options); + } + + async getStreams(type: string, id: string, callback?: StreamCallback): Promise { + await getStreamsImpl(this, type, id, callback); + } + + getAddonCapabilities(): AddonCapabilities[] { + return getAddonCapabilitiesImpl(this); + } + + async getCatalogPreview( + addonId: string, + type: string, + id: string, + limit = 5 + ): Promise<{ addon: string; type: string; id: string; items: Meta[] }> { + return getCatalogPreviewImpl(this, addonId, type, id, limit); + } + + async getSubtitles(type: string, id: string, videoId?: string): Promise { + return getSubtitlesImpl(this, type, id, videoId); + } + + moveAddonUp(installationId: string): boolean { + return moveAddonUpImpl(this, installationId); + } + + moveAddonDown(installationId: string): boolean { + return moveAddonDownImpl(this, installationId); + } + + async applyAddonOrderFromManifestUrls(manifestUrls: string[]): Promise { + return applyAddonOrderFromManifestUrlsImpl(this, manifestUrls); + } + + async hasStreamProviders(type?: string): Promise { + return hasStreamProvidersImpl(this, type); + } + + async getAddonCatalogs(type: string, id: string): Promise { + return getAddonCatalogsImpl(this, type, id); + } +} + +export const stremioService = StremioService.getInstance(); + +export type { + AddonCapabilities, + AddonCatalogItem, + CatalogExtra, + Manifest, + Meta, + MetaDetails, + MetaLink, + SourceObject, + Stream, + StreamResponse, + Subtitle, + SubtitleResponse, +}; + +export { StremioService }; +export default stremioService; diff --git a/src/services/stremio/addon-management.ts b/src/services/stremio/addon-management.ts new file mode 100644 index 00000000..77b4674c --- /dev/null +++ b/src/services/stremio/addon-management.ts @@ -0,0 +1,449 @@ +import axios from 'axios'; + +import { mmkvStorage } from '../mmkvStorage'; +import { logger } from '../../utils/logger'; +import { safeAxiosConfig } from '../../utils/axiosConfig'; + +import { ADDON_EVENTS, addonEmitter } from './events'; +import type { StremioServiceContext } from './context'; +import type { Manifest, ResourceObject } from './types'; + +const CINEMETA_ID = 'com.linvo.cinemeta'; +const CINEMETA_URL = 'https://v3-cinemeta.strem.io/manifest.json'; +const OPENSUBTITLES_ID = 'org.stremio.opensubtitlesv3'; +const OPENSUBTITLES_URL = 'https://opensubtitles-v3.strem.io/manifest.json'; + +function createFallbackCinemetaManifest(ctx: StremioServiceContext): Manifest { + return { + id: CINEMETA_ID, + installationId: ctx.generateInstallationId(CINEMETA_ID), + name: 'Cinemeta', + version: '3.0.13', + description: 'Provides metadata for movies and series from TheTVDB, TheMovieDB, etc.', + url: 'https://v3-cinemeta.strem.io', + originalUrl: CINEMETA_URL, + 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, + }, + }; +} + +function createFallbackOpenSubtitlesManifest(ctx: StremioServiceContext): Manifest { + return { + id: OPENSUBTITLES_ID, + installationId: ctx.generateInstallationId(OPENSUBTITLES_ID), + name: 'OpenSubtitles v3', + version: '1.0.0', + description: 'OpenSubtitles v3 Addon for Stremio', + url: 'https://opensubtitles-v3.strem.io', + originalUrl: OPENSUBTITLES_URL, + types: ['movie', 'series'], + catalogs: [], + resources: [ + { + name: 'subtitles', + types: ['movie', 'series'], + idPrefixes: ['tt'], + }, + ], + behaviorHints: { + configurable: false, + }, + }; +} + +async function getCurrentScope(): Promise { + return (await mmkvStorage.getItem('@user:current')) || 'local'; +} + +export async function initializeAddons(ctx: StremioServiceContext): Promise { + if (ctx.initialized) { + return; + } + + try { + const scope = await getCurrentScope(); + let storedAddons = await mmkvStorage.getItem(`@user:${scope}:${ctx.STORAGE_KEY}`); + if (!storedAddons) { + storedAddons = await mmkvStorage.getItem(ctx.STORAGE_KEY); + } + if (!storedAddons) { + storedAddons = await mmkvStorage.getItem(`@user:local:${ctx.STORAGE_KEY}`); + } + + if (storedAddons) { + const parsed = JSON.parse(storedAddons) as Manifest[]; + ctx.installedAddons = new Map(); + + for (const addon of parsed) { + if (!addon?.id) { + continue; + } + + if (!addon.installationId) { + addon.installationId = ctx.generateInstallationId(addon.id); + } + + ctx.installedAddons.set(addon.installationId, addon); + } + } + + const hasUserRemovedCinemeta = await ctx.hasUserRemovedAddon(CINEMETA_ID); + const hasCinemeta = Array.from(ctx.installedAddons.values()).some(addon => addon.id === CINEMETA_ID); + + if (!hasCinemeta && !hasUserRemovedCinemeta) { + try { + const cinemetaManifest = await getManifest(ctx, CINEMETA_URL); + cinemetaManifest.installationId = ctx.generateInstallationId(CINEMETA_ID); + ctx.installedAddons.set(cinemetaManifest.installationId, cinemetaManifest); + } catch { + const fallbackManifest = createFallbackCinemetaManifest(ctx); + ctx.installedAddons.set(fallbackManifest.installationId!, fallbackManifest); + } + } + + const hasUserRemovedOpenSubtitles = await ctx.hasUserRemovedAddon(OPENSUBTITLES_ID); + const hasOpenSubtitles = Array.from(ctx.installedAddons.values()).some( + addon => addon.id === OPENSUBTITLES_ID + ); + + if (!hasOpenSubtitles && !hasUserRemovedOpenSubtitles) { + try { + const openSubsManifest = await getManifest(ctx, OPENSUBTITLES_URL); + openSubsManifest.installationId = ctx.generateInstallationId(OPENSUBTITLES_ID); + ctx.installedAddons.set(openSubsManifest.installationId, openSubsManifest); + } catch { + const fallbackManifest = createFallbackOpenSubtitlesManifest(ctx); + ctx.installedAddons.set(fallbackManifest.installationId!, fallbackManifest); + } + } + + let storedOrder = await mmkvStorage.getItem(`@user:${scope}:${ctx.ADDON_ORDER_KEY}`); + if (!storedOrder) { + storedOrder = await mmkvStorage.getItem(ctx.ADDON_ORDER_KEY); + } + if (!storedOrder) { + storedOrder = await mmkvStorage.getItem(`@user:local:${ctx.ADDON_ORDER_KEY}`); + } + + if (storedOrder) { + ctx.addonOrder = JSON.parse(storedOrder).filter((installationId: string) => + ctx.installedAddons.has(installationId) + ); + } + + const cinemetaInstallation = Array.from(ctx.installedAddons.values()).find( + addon => addon.id === CINEMETA_ID + ); + if ( + cinemetaInstallation?.installationId && + !ctx.addonOrder.includes(cinemetaInstallation.installationId) && + !(await ctx.hasUserRemovedAddon(CINEMETA_ID)) + ) { + ctx.addonOrder.push(cinemetaInstallation.installationId); + } + + const openSubtitlesInstallation = Array.from(ctx.installedAddons.values()).find( + addon => addon.id === OPENSUBTITLES_ID + ); + if ( + openSubtitlesInstallation?.installationId && + !ctx.addonOrder.includes(openSubtitlesInstallation.installationId) && + !(await ctx.hasUserRemovedAddon(OPENSUBTITLES_ID)) + ) { + ctx.addonOrder.push(openSubtitlesInstallation.installationId); + } + + const missingInstallationIds = Array.from(ctx.installedAddons.keys()).filter( + installationId => !ctx.addonOrder.includes(installationId) + ); + ctx.addonOrder = [...ctx.addonOrder, ...missingInstallationIds]; + + await ctx.saveAddonOrder(); + await ctx.saveInstalledAddons(); + ctx.initialized = true; + } catch { + ctx.installedAddons = new Map(); + ctx.addonOrder = []; + ctx.initialized = true; + } +} + +export function getAllSupportedTypes(ctx: StremioServiceContext): string[] { + const types = new Set(); + + for (const addon of ctx.getInstalledAddons()) { + addon.types?.forEach(type => types.add(type)); + + for (const resource of addon.resources || []) { + if (typeof resource === 'object' && resource !== null && 'name' in resource) { + (resource as ResourceObject).types?.forEach(type => types.add(type)); + } + } + + for (const catalog of addon.catalogs || []) { + if (catalog.type) { + types.add(catalog.type); + } + } + } + + return Array.from(types); +} + +export function getAllSupportedIdPrefixes(ctx: StremioServiceContext, type: string): string[] { + const prefixes = new Set(); + + for (const addon of ctx.getInstalledAddons()) { + addon.idPrefixes?.forEach(prefix => prefixes.add(prefix)); + + for (const resource of addon.resources || []) { + if (typeof resource !== 'object' || resource === null || !('name' in resource)) { + continue; + } + + const typedResource = resource as ResourceObject; + if (!typedResource.types?.includes(type)) { + continue; + } + + typedResource.idPrefixes?.forEach(prefix => prefixes.add(prefix)); + } + } + + return Array.from(prefixes); +} + +export function isCollectionContent( + ctx: StremioServiceContext, + id: string +): { isCollection: boolean; addon?: Manifest } { + for (const addon of ctx.getInstalledAddons()) { + const supportsCollections = + addon.types?.includes('collections') || + addon.catalogs?.some(catalog => catalog.type === 'collections'); + + if (!supportsCollections) { + continue; + } + + const addonPrefixes = addon.idPrefixes || []; + const resourcePrefixes = + addon.resources + ?.filter( + resource => + typeof resource === 'object' && + resource !== null && + 'name' in resource && + (((resource as ResourceObject).name === 'meta') || + (resource as ResourceObject).name === 'catalog') + ) + .flatMap(resource => (resource as ResourceObject).idPrefixes || []) || []; + + if ([...addonPrefixes, ...resourcePrefixes].some(prefix => id.startsWith(prefix))) { + return { isCollection: true, addon }; + } + } + + return { isCollection: false }; +} + +export async function getManifest(ctx: StremioServiceContext, url: string): Promise { + try { + const manifestUrl = url.endsWith('manifest.json') ? url : `${url.replace(/\/$/, '')}/manifest.json`; + const response = await ctx.retryRequest(() => axios.get(manifestUrl, safeAxiosConfig)); + const manifest = response.data as Manifest; + + manifest.originalUrl = url; + manifest.url = url.replace(/manifest\.json$/, ''); + + if (!manifest.id) { + manifest.id = ctx.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}`); + } +} + +export async function installAddon(ctx: StremioServiceContext, url: string): Promise { + const manifest = await getManifest(ctx, url); + if (!manifest?.id) { + throw new Error('Invalid addon manifest'); + } + + const existingInstallations = Array.from(ctx.installedAddons.values()).filter( + addon => addon.id === manifest.id + ); + if (existingInstallations.length > 0 && !ctx.addonProvidesStreams(manifest)) { + throw new Error( + 'This addon is already installed. Multiple installations are only allowed for stream providers.' + ); + } + + manifest.installationId = ctx.generateInstallationId(manifest.id); + ctx.installedAddons.set(manifest.installationId, manifest); + + await ctx.unmarkAddonAsRemovedByUser(manifest.id); + await cleanupRemovedAddonFromStorage(ctx, manifest.id); + + if (!ctx.addonOrder.includes(manifest.installationId)) { + ctx.addonOrder.push(manifest.installationId); + } + + await ctx.saveInstalledAddons(); + await ctx.saveAddonOrder(); + addonEmitter.emit(ADDON_EVENTS.ADDON_ADDED, { + installationId: manifest.installationId, + addonId: manifest.id, + }); +} + +export async function removeAddon(ctx: StremioServiceContext, installationId: string): Promise { + if (!ctx.installedAddons.has(installationId)) { + return; + } + + const addon = ctx.installedAddons.get(installationId); + ctx.installedAddons.delete(installationId); + ctx.addonOrder = ctx.addonOrder.filter(id => id !== installationId); + + if (addon) { + const remainingInstallations = Array.from(ctx.installedAddons.values()).filter( + entry => entry.id === addon.id + ); + if (remainingInstallations.length === 0) { + await markAddonAsRemovedByUser(addon.id); + await cleanupRemovedAddonFromStorage(ctx, addon.id); + } + } + + await ctx.saveInstalledAddons(); + await ctx.saveAddonOrder(); + addonEmitter.emit(ADDON_EVENTS.ADDON_REMOVED, installationId); +} + +export function getInstalledAddons(ctx: StremioServiceContext): Manifest[] { + return ctx.addonOrder + .filter(installationId => ctx.installedAddons.has(installationId)) + .map(installationId => ctx.installedAddons.get(installationId) as Manifest); +} + +export async function getInstalledAddonsAsync(ctx: StremioServiceContext): Promise { + await ctx.ensureInitialized(); + return getInstalledAddons(ctx); +} + +export function isPreInstalledAddon(): boolean { + return false; +} + +export async function hasUserRemovedAddon(addonId: string): Promise { + 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 { + return false; + } +} + +async function markAddonAsRemovedByUser(addonId: string): Promise { + 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 { + // Best-effort cleanup only. + } +} + +export async function unmarkAddonAsRemovedByUser(addonId: string): Promise { + try { + const removedAddons = await mmkvStorage.getItem('user_removed_addons'); + if (!removedAddons) { + return; + } + + const 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 { + // Best-effort cleanup only. + } +} + +async function cleanupRemovedAddonFromStorage( + ctx: StremioServiceContext, + addonId: string +): Promise { + try { + const scope = await getCurrentScope(); + const keys = [ + `@user:${scope}:${ctx.ADDON_ORDER_KEY}`, + ctx.ADDON_ORDER_KEY, + `@user:local:${ctx.ADDON_ORDER_KEY}`, + ]; + + for (const key of keys) { + const storedOrder = await mmkvStorage.getItem(key); + if (!storedOrder) { + continue; + } + + const order = JSON.parse(storedOrder); + if (!Array.isArray(order)) { + continue; + } + + const updatedOrder = order.filter(id => id !== addonId); + await mmkvStorage.setItem(key, JSON.stringify(updatedOrder)); + } + } catch { + // Best-effort cleanup only. + } +} diff --git a/src/services/stremio/addon-order.ts b/src/services/stremio/addon-order.ts new file mode 100644 index 00000000..fac3eca5 --- /dev/null +++ b/src/services/stremio/addon-order.ts @@ -0,0 +1,112 @@ +import { ADDON_EVENTS, addonEmitter } from './events'; +import type { StremioServiceContext } from './context'; + +export function moveAddonUp(ctx: StremioServiceContext, installationId: string): boolean { + const index = ctx.addonOrder.indexOf(installationId); + if (index <= 0) { + return false; + } + + [ctx.addonOrder[index - 1], ctx.addonOrder[index]] = [ + ctx.addonOrder[index], + ctx.addonOrder[index - 1], + ]; + void ctx.saveAddonOrder(); + addonEmitter.emit(ADDON_EVENTS.ORDER_CHANGED); + return true; +} + +export function moveAddonDown(ctx: StremioServiceContext, installationId: string): boolean { + const index = ctx.addonOrder.indexOf(installationId); + if (index < 0 || index >= ctx.addonOrder.length - 1) { + return false; + } + + [ctx.addonOrder[index], ctx.addonOrder[index + 1]] = [ + ctx.addonOrder[index + 1], + ctx.addonOrder[index], + ]; + void ctx.saveAddonOrder(); + addonEmitter.emit(ADDON_EVENTS.ORDER_CHANGED); + return true; +} + +export async function applyAddonOrderFromManifestUrls( + ctx: StremioServiceContext, + manifestUrls: string[] +): Promise { + await ctx.ensureInitialized(); + if (!Array.isArray(manifestUrls) || manifestUrls.length === 0) { + return false; + } + + const normalizeManifestUrl = (raw: string): string => { + const value = (raw || '').trim(); + if (!value) { + return ''; + } + + const withManifest = value.includes('manifest.json') + ? value + : `${value.replace(/\/$/, '')}/manifest.json`; + return withManifest.toLowerCase(); + }; + + const localByNormalizedUrl = new Map(); + for (const installationId of ctx.addonOrder) { + const addon = ctx.installedAddons.get(installationId); + if (!addon) { + continue; + } + + const normalized = normalizeManifestUrl(addon.originalUrl || addon.url || ''); + if (!normalized) { + continue; + } + + const matches = localByNormalizedUrl.get(normalized) || []; + matches.push(installationId); + localByNormalizedUrl.set(normalized, matches); + } + + const nextOrder: string[] = []; + const seenInstallations = new Set(); + + for (const remoteUrl of manifestUrls) { + const normalizedRemote = normalizeManifestUrl(remoteUrl); + const candidates = localByNormalizedUrl.get(normalizedRemote); + if (!normalizedRemote || !candidates?.length) { + continue; + } + + const installationId = candidates.shift(); + if (!installationId || seenInstallations.has(installationId)) { + continue; + } + + nextOrder.push(installationId); + seenInstallations.add(installationId); + } + + for (const installationId of ctx.addonOrder) { + if (!ctx.installedAddons.has(installationId) || seenInstallations.has(installationId)) { + continue; + } + + nextOrder.push(installationId); + seenInstallations.add(installationId); + } + + const changed = + nextOrder.length !== ctx.addonOrder.length || + nextOrder.some((id, index) => id !== ctx.addonOrder[index]); + + if (!changed) { + return false; + } + + ctx.addonOrder = nextOrder; + await ctx.saveAddonOrder(); + addonEmitter.emit(ADDON_EVENTS.ORDER_CHANGED); + return true; +} diff --git a/src/services/stremio/catalog-operations.ts b/src/services/stremio/catalog-operations.ts new file mode 100644 index 00000000..8dba5f7e --- /dev/null +++ b/src/services/stremio/catalog-operations.ts @@ -0,0 +1,423 @@ +import axios from 'axios'; + +import { logger } from '../../utils/logger'; +import { createSafeAxiosConfig, safeAxiosConfig } from '../../utils/axiosConfig'; + +import type { StremioServiceContext } from './context'; +import type { + AddonCapabilities, + AddonCatalogItem, + CatalogFilter, + Manifest, + Meta, + MetaDetails, + ResourceObject, +} from './types'; + +export async function isValidContentId( + ctx: StremioServiceContext, + type: string, + id: string | null | undefined, + getAllSupportedTypes: () => string[], + getAllSupportedIdPrefixes: (type: string) => string[] +): Promise { + await ctx.ensureInitialized(); + + const supportedTypes = getAllSupportedTypes(); + const isValidType = supportedTypes.includes(type); + const lowerId = (id || '').toLowerCase(); + const isNullishId = !id || lowerId === 'null' || lowerId === 'undefined'; + const providerLikeIds = new Set(['moviebox', 'torbox']); + const isProviderSlug = providerLikeIds.has(lowerId); + + if (!isValidType || isNullishId || isProviderSlug) { + return false; + } + + const supportedPrefixes = getAllSupportedIdPrefixes(type); + if (supportedPrefixes.length === 0) { + return true; + } + + return supportedPrefixes.some(prefix => { + const lowerPrefix = prefix.toLowerCase(); + if (!lowerId.startsWith(lowerPrefix)) { + return false; + } + + if (lowerPrefix.endsWith(':') || lowerPrefix.endsWith('_')) { + return true; + } + + return lowerId.length > lowerPrefix.length; + }); +} + +export async function getAllCatalogs( + ctx: StremioServiceContext +): Promise> { + const result: Record = {}; + const promises = ctx.getInstalledAddons().map(async addon => { + const catalog = addon.catalogs?.[0]; + if (!catalog) { + return; + } + + try { + const items = await getCatalog(ctx, 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; +} + +export async function getCatalog( + ctx: StremioServiceContext, + manifest: Manifest, + type: string, + id: string, + page = 1, + filters: CatalogFilter[] = [] +): Promise { + const encodedId = encodeURIComponent(id); + const pageSkip = (page - 1) * ctx.DEFAULT_PAGE_SIZE; + + if (!manifest.url) { + throw new Error('Addon URL is missing'); + } + + try { + const { baseUrl, queryParams } = ctx.getAddonBaseURL(manifest.url); + const extraParts: string[] = []; + + if (filters.length > 0) { + filters + .filter(filter => filter && filter.value) + .forEach(filter => { + extraParts.push( + `${encodeURIComponent(filter.title)}=${encodeURIComponent(filter.value)}` + ); + }); + } + + if (pageSkip > 0) { + extraParts.push(`skip=${pageSkip}`); + } + + const extraArgsPath = extraParts.length > 0 ? `/${extraParts.join('&')}` : ''; + const urlPathStyle = + `${baseUrl}/catalog/${type}/${encodedId}${extraArgsPath}.json` + + `${queryParams ? `?${queryParams}` : ''}`; + const urlSimple = `${baseUrl}/catalog/${type}/${encodedId}.json${queryParams ? `?${queryParams}` : ''}`; + + const legacyFilterQuery = filters + .filter(filter => filter && filter.value) + .map(filter => `&${encodeURIComponent(filter.title)}=${encodeURIComponent(filter.value)}`) + .join(''); + + let urlQueryStyle = + `${baseUrl}/catalog/${type}/${encodedId}.json` + + `?skip=${pageSkip}&limit=${ctx.DEFAULT_PAGE_SIZE}`; + if (queryParams) { + urlQueryStyle += `&${queryParams}`; + } + urlQueryStyle += legacyFilterQuery; + + let response; + + try { + if (pageSkip === 0 && extraParts.length === 0) { + response = await ctx.retryRequest(() => axios.get(urlSimple, safeAxiosConfig)); + if (!response?.data?.metas?.length) { + throw new Error('Empty response from simple URL'); + } + } else { + throw new Error('Has extra args, use path-style'); + } + } catch { + try { + response = await ctx.retryRequest(() => axios.get(urlPathStyle, safeAxiosConfig)); + if (!response?.data?.metas?.length) { + throw new Error('Empty response from path-style URL'); + } + } catch { + response = await ctx.retryRequest(() => axios.get(urlQueryStyle, safeAxiosConfig)); + } + } + + if (!response?.data) { + return []; + } + + const hasMore = typeof response.data.hasMore === 'boolean' ? response.data.hasMore : undefined; + const key = `${manifest.id}|${type}|${id}`; + if (typeof hasMore === 'boolean') { + ctx.catalogHasMore.set(key, hasMore); + } + + return Array.isArray(response.data.metas) ? response.data.metas : []; + } catch (error) { + logger.error(`Failed to fetch catalog from ${manifest.name}:`, error); + throw error; + } +} + +export function getCatalogHasMore( + ctx: StremioServiceContext, + manifestId: string, + type: string, + id: string +): boolean | undefined { + return ctx.catalogHasMore.get(`${manifestId}|${type}|${id}`); +} + +function addonSupportsMetaResource(addon: Manifest, type: string, id: string): boolean { + let hasMetaSupport = false; + let supportsIdPrefix = false; + + for (const resource of addon.resources || []) { + if (typeof resource === 'object' && resource !== null && 'name' in resource) { + const typedResource = resource as ResourceObject; + if (typedResource.name === 'meta' && typedResource.types?.includes(type)) { + hasMetaSupport = true; + supportsIdPrefix = + !typedResource.idPrefixes?.length || + typedResource.idPrefixes.some(prefix => id.startsWith(prefix)); + break; + } + } else if (resource === 'meta' && addon.types?.includes(type)) { + hasMetaSupport = true; + supportsIdPrefix = + !addon.idPrefixes?.length || addon.idPrefixes.some(prefix => id.startsWith(prefix)); + break; + } + } + + const requiresIdPrefix = !!addon.idPrefixes?.length; + return hasMetaSupport && (!requiresIdPrefix || supportsIdPrefix); +} + +async function fetchMetaFromAddon( + ctx: StremioServiceContext, + addon: Manifest, + type: string, + id: string +): Promise { + const { baseUrl, queryParams } = ctx.getAddonBaseURL(addon.url || ''); + const encodedId = encodeURIComponent(id); + const url = queryParams + ? `${baseUrl}/meta/${type}/${encodedId}.json?${queryParams}` + : `${baseUrl}/meta/${type}/${encodedId}.json`; + + const response = await ctx.retryRequest(() => axios.get(url, createSafeAxiosConfig(10000))); + return response.data?.meta?.id ? response.data.meta : null; +} + +export async function getMetaDetails( + ctx: StremioServiceContext, + type: string, + id: string, + preferredAddonId?: string +): Promise { + try { + if (!(await ctx.isValidContentId(type, id))) { + return null; + } + + const addons = ctx.getInstalledAddons(); + + if (preferredAddonId) { + const preferredAddon = addons.find(addon => addon.id === preferredAddonId); + if (preferredAddon?.resources && addonSupportsMetaResource(preferredAddon, type, id)) { + try { + const meta = await fetchMetaFromAddon(ctx, preferredAddon, type, id); + if (meta) { + return meta; + } + } catch { + // Fall through to other addons. + } + } + } + + for (const baseUrl of ['https://v3-cinemeta.strem.io', 'http://v3-cinemeta.strem.io']) { + try { + const encodedId = encodeURIComponent(id); + const url = `${baseUrl}/meta/${type}/${encodedId}.json`; + const response = await ctx.retryRequest(() => axios.get(url, createSafeAxiosConfig(10000))); + if (response.data?.meta?.id) { + return response.data.meta; + } + } catch { + // Try next Cinemeta URL. + } + } + + for (const addon of addons) { + if (!addon.resources || addon.id === 'com.linvo.cinemeta' || addon.id === preferredAddonId) { + continue; + } + + if (!addonSupportsMetaResource(addon, type, id)) { + continue; + } + + try { + const meta = await fetchMetaFromAddon(ctx, addon, type, id); + if (meta) { + return meta; + } + } catch { + // Try next addon. + } + } + + return null; + } catch (error) { + logger.error('Error in getMetaDetails:', error); + return null; + } +} + +export async function getUpcomingEpisodes( + ctx: StremioServiceContext, + 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 { + const metadata = await ctx.getMetaDetails(type, id, preferredAddonId); + if (!metadata) { + return null; + } + + if (!metadata.videos?.length) { + 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); + + const episodes = 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); + return releaseDate >= startDate && releaseDate <= endDate; + }) + .sort((left, right) => new Date(left.released).getTime() - new Date(right.released).getTime()) + .slice(0, maxEpisodes); + + return { + seriesName: metadata.name, + poster: metadata.poster || '', + episodes, + }; + } catch (error) { + logger.error(`[StremioService] Error fetching upcoming episodes for ${id}:`, error); + return null; + } +} + +export function getAddonCapabilities(ctx: StremioServiceContext): AddonCapabilities[] { + return ctx.getInstalledAddons().map(addon => ({ + name: addon.name, + id: addon.id, + version: addon.version, + catalogs: addon.catalogs || [], + resources: (addon.resources || []).filter( + (resource): resource is ResourceObject => typeof resource === 'object' && resource !== null + ), + types: addon.types || [], + })); +} + +export async function getCatalogPreview( + ctx: StremioServiceContext, + addonId: string, + type: string, + id: string, + limit = 5 +): Promise<{ + addon: string; + type: string; + id: string; + items: Meta[]; +}> { + const addon = ctx.getInstalledAddons().find(entry => entry.id === addonId); + if (!addon) { + throw new Error(`Addon ${addonId} not found`); + } + + const items = await ctx.getCatalog(addon, type, id); + return { + addon: addonId, + type, + id, + items: items.slice(0, limit), + }; +} + +export async function getAddonCatalogs( + ctx: StremioServiceContext, + type: string, + id: string +): Promise { + await ctx.ensureInitialized(); + + const addons = ctx.getInstalledAddons().filter(addon => + addon.resources?.some(resource => + typeof resource === 'string' + ? resource === 'addon_catalog' + : (resource as ResourceObject).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 } = ctx.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 ctx.retryRequest(() => axios.get(url, createSafeAxiosConfig(10000))); + + if (Array.isArray(response.data?.addons)) { + results.push(...response.data.addons); + } + } catch (error) { + logger.warn(`[getAddonCatalogs] Failed to fetch from ${addon.name}:`, error); + } + } + + return results; +} diff --git a/src/services/stremio/context.ts b/src/services/stremio/context.ts new file mode 100644 index 00000000..2279376d --- /dev/null +++ b/src/services/stremio/context.ts @@ -0,0 +1,39 @@ +import type { + CatalogFilter, + Manifest, + Meta, + MetaDetails, + Stream, +} from './types'; + +export interface StremioServiceContext { + installedAddons: Map; + addonOrder: string[]; + STORAGE_KEY: string; + ADDON_ORDER_KEY: string; + DEFAULT_PAGE_SIZE: number; + initialized: boolean; + initializationPromise: Promise | null; + catalogHasMore: Map; + ensureInitialized(): Promise; + retryRequest(request: () => Promise, retries?: number, delay?: number): Promise; + saveInstalledAddons(): Promise; + saveAddonOrder(): Promise; + generateInstallationId(addonId: string): string; + addonProvidesStreams(manifest: Manifest): boolean; + formatId(id: string): string; + getInstalledAddons(): Manifest[]; + getAddonBaseURL(url: string): { baseUrl: string; queryParams?: string }; + processStreams(streams: any[], addon: Manifest): Stream[]; + isValidContentId(type: string, id: string | null | undefined): Promise; + getCatalog( + manifest: Manifest, + type: string, + id: string, + page?: number, + filters?: CatalogFilter[] + ): Promise; + getMetaDetails(type: string, id: string, preferredAddonId?: string): Promise; + hasUserRemovedAddon(addonId: string): Promise; + unmarkAddonAsRemovedByUser(addonId: string): Promise; +} diff --git a/src/services/stremio/events.ts b/src/services/stremio/events.ts new file mode 100644 index 00000000..eacc6c1c --- /dev/null +++ b/src/services/stremio/events.ts @@ -0,0 +1,9 @@ +import EventEmitter from 'eventemitter3'; + +export const addonEmitter = new EventEmitter(); + +export const ADDON_EVENTS = { + ORDER_CHANGED: 'order_changed', + ADDON_ADDED: 'addon_added', + ADDON_REMOVED: 'addon_removed', +} as const; diff --git a/src/services/stremio/stream-operations.ts b/src/services/stremio/stream-operations.ts new file mode 100644 index 00000000..6163fb6d --- /dev/null +++ b/src/services/stremio/stream-operations.ts @@ -0,0 +1,391 @@ +import axios from 'axios'; + +import { mmkvStorage } from '../mmkvStorage'; +import { localScraperService } from '../pluginService'; +import { DEFAULT_SETTINGS, type AppSettings } from '../../hooks/useSettings'; +import { TMDBService } from '../tmdbService'; +import { logger } from '../../utils/logger'; +import { safeAxiosConfig } from '../../utils/axiosConfig'; + +import type { StremioServiceContext } from './context'; +import type { Manifest, ResourceObject, StreamCallback } from './types'; + +function pickStreamAddons(ctx: StremioServiceContext, requestType: string, id: string): Manifest[] { + return ctx.getInstalledAddons().filter(addon => { + if (!Array.isArray(addon.resources)) { + logger.log(`⚠️ [getStreams] Addon ${addon.id} has no valid resources array`); + return false; + } + + let hasStreamResource = false; + let supportsIdPrefix = false; + + for (const resource of addon.resources) { + if (typeof resource === 'object' && resource !== null && 'name' in resource) { + const typedResource = resource as ResourceObject; + if (typedResource.name === 'stream' && typedResource.types?.includes(requestType)) { + hasStreamResource = true; + supportsIdPrefix = + !typedResource.idPrefixes?.length || + typedResource.idPrefixes.some(prefix => id.startsWith(prefix)); + break; + } + } else if (resource === 'stream' && addon.types?.includes(requestType)) { + hasStreamResource = true; + supportsIdPrefix = + !addon.idPrefixes?.length || addon.idPrefixes.some(prefix => id.startsWith(prefix)); + break; + } + } + + return hasStreamResource && supportsIdPrefix; + }); +} + +async function runLocalScrapers( + type: string, + id: string, + callback?: StreamCallback +): Promise { + try { + 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 || !(await localScraperService.hasScrapers())) { + return; + } + + logger.log('🔧 [getStreams] Executing local scrapers for', type, id); + + const scraperType = type === 'series' ? 'tv' : type; + let tmdbId: string | null = null; + let season: number | undefined; + let episode: number | undefined; + let idType: 'imdb' | 'kitsu' | 'tmdb' = 'imdb'; + + try { + const idParts = id.split(':'); + let baseId: string; + + if (idParts[0] === 'series') { + baseId = idParts[1]; + if (scraperType === 'tv' && idParts.length >= 4) { + season = parseInt(idParts[2], 10); + episode = parseInt(idParts[3], 10); + } + + if (idParts[1] === 'kitsu') { + idType = 'kitsu'; + baseId = idParts[2]; + if (scraperType === 'tv' && idParts.length >= 5) { + season = parseInt(idParts[3], 10); + episode = parseInt(idParts[4], 10); + } + } + } else if (idParts[0].startsWith('tt')) { + baseId = idParts[0]; + if (scraperType === 'tv' && idParts.length >= 3) { + season = parseInt(idParts[1], 10); + episode = parseInt(idParts[2], 10); + } + } else if (idParts[0] === 'kitsu') { + idType = 'kitsu'; + baseId = idParts[1]; + if (scraperType === 'tv' && idParts.length >= 4) { + season = parseInt(idParts[2], 10); + episode = parseInt(idParts[3], 10); + } + } else if (idParts[0] === 'tmdb') { + idType = 'tmdb'; + baseId = idParts[1]; + if (scraperType === 'tv' && idParts.length >= 4) { + season = parseInt(idParts[2], 10); + episode = parseInt(idParts[3], 10); + } + } else { + baseId = idParts[0]; + if (scraperType === 'tv' && idParts.length >= 3) { + season = parseInt(idParts[1], 10); + episode = parseInt(idParts[2], 10); + } + } + + if (idType === 'imdb') { + const tmdbIdNumber = await TMDBService.getInstance().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') { + tmdbId = baseId; + logger.log('🔧 [getStreams] Using TMDB ID directly for local scrapers:', tmdbId); + } else if (idType === 'kitsu') { + logger.log('🔧 [getStreams] Skipping local scrapers for kitsu ID:', baseId); + } else { + 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); + } + + if (!tmdbId) { + logger.log('🔧 [getStreams] Local scrapers not executed - no TMDB ID available'); + try { + const installedScrapers = await localScraperService.getInstalledScrapers(); + installedScrapers + .filter(scraper => scraper.enabled) + .forEach(scraper => callback?.([], scraper.id, scraper.name, null)); + } catch (error) { + logger.warn('🔧 [getStreams] Failed to notify UI about skipped local scrapers:', error); + } + return; + } + + localScraperService.getStreams(scraperType, tmdbId, season, episode, (streams, scraperId, scraperName, error) => { + if (!callback) { + return; + } + + if (error) { + callback(null, scraperId, scraperName, error); + return; + } + + callback(streams || [], scraperId, scraperName, null); + }); + } catch { + // Local scrapers are best-effort. + } +} + +function logUnmatchedStreamAddons( + ctx: StremioServiceContext, + addons: Manifest[], + effectiveType: string, + requestedType: string, + id: string +): void { + logger.warn('⚠️ [getStreams] No addons found that can provide streams'); + + const encodedId = encodeURIComponent(id); + logger.log(`🚫 [getStreams] No stream addons matched. Would have requested: /stream/${effectiveType}/${encodedId}.json`); + logger.log( + `🚫 [getStreams] Details: requestedType='${requestedType}' effectiveType='${effectiveType}' id='${id}'` + ); + + const streamCapableAddons = addons.filter(addon => + addon.resources?.some(resource => + typeof resource === 'object' && resource !== null && 'name' in resource + ? (resource as ResourceObject).name === 'stream' + : resource === 'stream' + ) + ); + + if (streamCapableAddons.length === 0) { + logger.log('🚫 [getStreams] No stream-capable addons installed'); + return; + } + + logger.log(`🚫 [getStreams] Found ${streamCapableAddons.length} stream-capable addon(s) that didn't match:`); + + for (const addon of streamCapableAddons) { + const streamResources = addon.resources?.filter(resource => + typeof resource === 'object' && resource !== null && 'name' in resource + ? (resource as ResourceObject).name === 'stream' + : resource === 'stream' + ); + + for (const resource of streamResources || []) { + if (typeof resource === 'object' && resource !== null) { + const typedResource = resource as ResourceObject; + const types = typedResource.types || []; + const prefixes = typedResource.idPrefixes || []; + const typeMatch = types.includes(effectiveType); + const prefixMatch = prefixes.length === 0 || prefixes.some(prefix => id.startsWith(prefix)); + + if (addon.url) { + const { baseUrl, queryParams } = ctx.getAddonBaseURL(addon.url); + const wouldBeUrl = queryParams + ? `${baseUrl}/stream/${effectiveType}/${encodedId}.json?${queryParams}` + : `${baseUrl}/stream/${effectiveType}/${encodedId}.json`; + + console.log( + ` ❌ ${addon.name} (${addon.id}):\n` + + ` types=[${types.join(',')}] typeMatch=${typeMatch}\n` + + ` prefixes=[${prefixes.join(',')}] prefixMatch=${prefixMatch}\n` + + ` url=${wouldBeUrl}` + ); + } + } else if (resource === 'stream' && addon.url) { + const addonTypes = addon.types || []; + const addonPrefixes = addon.idPrefixes || []; + const typeMatch = addonTypes.includes(effectiveType); + const prefixMatch = + addonPrefixes.length === 0 || addonPrefixes.some(prefix => id.startsWith(prefix)); + const { baseUrl, queryParams } = ctx.getAddonBaseURL(addon.url); + const wouldBeUrl = queryParams + ? `${baseUrl}/stream/${effectiveType}/${encodedId}.json?${queryParams}` + : `${baseUrl}/stream/${effectiveType}/${encodedId}.json`; + + console.log( + ` ❌ ${addon.name} (${addon.id}) [addon-level]:\n` + + ` types=[${addonTypes.join(',')}] typeMatch=${typeMatch}\n` + + ` prefixes=[${addonPrefixes.join(',')}] prefixMatch=${prefixMatch}\n` + + ` url=${wouldBeUrl}` + ); + } + } + } +} + +export async function getStreams( + ctx: StremioServiceContext, + type: string, + id: string, + callback?: StreamCallback +): Promise { + await ctx.ensureInitialized(); + + const addons = ctx.getInstalledAddons(); + await runLocalScrapers(type, id, callback); + + let effectiveType = type; + let streamAddons = pickStreamAddons(ctx, type, id); + + logger.log( + `🧭 [getStreams] Resolving stream addons for type='${type}' id='${id}' (matched=${streamAddons.length})` + ); + + if (streamAddons.length === 0) { + const fallbackTypes = ['series', 'movie', 'tv', 'channel'].filter(candidate => candidate !== type); + for (const fallbackType of fallbackTypes) { + const fallbackAddons = pickStreamAddons(ctx, fallbackType, id); + if (fallbackAddons.length === 0) { + continue; + } + + effectiveType = fallbackType; + streamAddons = fallbackAddons; + logger.log( + `🔁 [getStreams] No stream addons for type '${type}', falling back to '${effectiveType}' for id '${id}'` + ); + break; + } + } + + if (effectiveType !== type) { + logger.log( + `🧭 [getStreams] Using effectiveType='${effectiveType}' (requested='${type}') for id='${id}'` + ); + } + + if (streamAddons.length === 0) { + logUnmatchedStreamAddons(ctx, addons, effectiveType, type, id); + return; + } + + streamAddons.forEach(addon => { + void (async () => { + try { + if (!addon.url) { + logger.warn(`⚠️ [getStreams] Addon ${addon.id} has no URL`); + callback?.(null, addon.id, addon.name, new Error('Addon has no URL'), addon.installationId); + return; + } + + const { baseUrl, queryParams } = ctx.getAddonBaseURL(addon.url); + const encodedId = encodeURIComponent(id); + const url = queryParams + ? `${baseUrl}/stream/${effectiveType}/${encodedId}.json?${queryParams}` + : `${baseUrl}/stream/${effectiveType}/${encodedId}.json`; + + logger.log( + `🔗 [getStreams] GET ${url} (addon='${addon.name}' id='${addon.id}' install='${addon.installationId}' requestedType='${type}' effectiveType='${effectiveType}' rawId='${id}')` + ); + + const response = await ctx.retryRequest(() => axios.get(url, safeAxiosConfig)); + const processedStreams = Array.isArray(response.data?.streams) + ? ctx.processStreams(response.data.streams, addon) + : []; + + if (Array.isArray(response.data?.streams)) { + logger.log( + `✅ [getStreams] Processed ${processedStreams.length} valid streams from ${addon.name} (${addon.id}) [${addon.installationId}]` + ); + } else { + logger.log( + `⚠️ [getStreams] No streams found in response from ${addon.name} (${addon.id}) [${addon.installationId}]` + ); + } + + callback?.(processedStreams, addon.id, addon.name, null, addon.installationId); + } catch (error) { + callback?.(null, addon.id, addon.name, error as Error, addon.installationId); + } + })(); + }); +} + +export async function hasStreamProviders( + ctx: StremioServiceContext, + type?: string +): Promise { + await ctx.ensureInitialized(); + + for (const addon of Array.from(ctx.installedAddons.values())) { + if (!Array.isArray(addon.resources)) { + continue; + } + + const hasStreamResource = addon.resources.some(resource => + typeof resource === 'string' + ? resource === 'stream' + : (resource as ResourceObject).name === 'stream' + ); + + if (hasStreamResource) { + if (!type) { + return true; + } + + const supportsType = + addon.types?.includes(type) || + addon.resources.some( + resource => + typeof resource === 'object' && + resource !== null && + (resource as ResourceObject).name === 'stream' && + (resource as ResourceObject).types?.includes(type) + ); + + if (supportsType) { + return true; + } + } + + if (!type) { + continue; + } + + const hasMetaResource = addon.resources.some(resource => + typeof resource === 'string' + ? resource === 'meta' + : (resource as ResourceObject).name === 'meta' + ); + + if (hasMetaResource && addon.types?.includes(type)) { + return true; + } + } + + return false; +} diff --git a/src/services/stremio/subtitle-operations.ts b/src/services/stremio/subtitle-operations.ts new file mode 100644 index 00000000..fad18a95 --- /dev/null +++ b/src/services/stremio/subtitle-operations.ts @@ -0,0 +1,126 @@ +import axios from 'axios'; + +import { logger } from '../../utils/logger'; +import { createSafeAxiosConfig } from '../../utils/axiosConfig'; + +import type { StremioServiceContext } from './context'; +import type { ResourceObject, Subtitle } from './types'; + +export async function getSubtitles( + ctx: StremioServiceContext, + type: string, + id: string, + videoId?: string +): Promise { + await ctx.ensureInitialized(); + + const idForChecking = type === 'series' && videoId ? videoId.replace('series:', '') : id; + const subtitleAddons = ctx.getInstalledAddons().filter(addon => { + if (!addon.resources) { + return false; + } + + const subtitlesResource = addon.resources.find(resource => + typeof resource === 'string' + ? resource === 'subtitles' + : (resource as ResourceObject).name === 'subtitles' + ); + + if (!subtitlesResource) { + return false; + } + + let supportsType = true; + if (typeof subtitlesResource === 'object' && subtitlesResource.types) { + supportsType = subtitlesResource.types.includes(type); + } else if (addon.types) { + supportsType = addon.types.includes(type); + } + + if (!supportsType) { + logger.log(`[getSubtitles] Addon ${addon.name} does not support type ${type}`); + return false; + } + + let idPrefixes: string[] | undefined; + if (typeof subtitlesResource === 'object' && subtitlesResource.idPrefixes) { + idPrefixes = subtitlesResource.idPrefixes; + } else if (addon.idPrefixes) { + idPrefixes = addon.idPrefixes; + } + + const supportsIdPrefix = + !idPrefixes?.length || idPrefixes.some(prefix => idForChecking.startsWith(prefix)); + + if (!supportsIdPrefix) { + logger.log( + `[getSubtitles] Addon ${addon.name} does not support ID prefix for ${idForChecking} (requires: ${idPrefixes?.join(', ')})` + ); + } + + return supportsIdPrefix; + }); + + if (subtitleAddons.length === 0) { + logger.warn('No subtitle-capable addons installed that support the requested type/id'); + return []; + } + + logger.log( + `[getSubtitles] Found ${subtitleAddons.length} subtitle addons for ${type}/${id}: ${subtitleAddons.map(addon => addon.name).join(', ')}` + ); + + const requests = subtitleAddons.map(async addon => { + if (!addon.url) { + return [] as Subtitle[]; + } + + try { + const { baseUrl, queryParams } = ctx.getAddonBaseURL(addon.url); + const targetId = + type === 'series' && videoId + ? encodeURIComponent(videoId.replace('series:', '')) + : encodeURIComponent(id); + const targetType = type === 'series' && videoId ? 'series' : type; + const url = queryParams + ? `${baseUrl}/subtitles/${targetType}/${targetId}.json?${queryParams}` + : `${baseUrl}/subtitles/${targetType}/${targetId}.json`; + + logger.log(`[getSubtitles] Fetching subtitles from ${addon.name}: ${url}`); + const response = await ctx.retryRequest(() => + axios.get(url, createSafeAxiosConfig(10000)) + ); + + if (!Array.isArray(response.data?.subtitles)) { + logger.log(`[getSubtitles] No subtitles array in response from ${addon.name}`); + return [] as Subtitle[]; + } + + logger.log(`[getSubtitles] Got ${response.data.subtitles.length} subtitles from ${addon.name}`); + return response.data.subtitles.map((subtitle: any, index: number) => ({ + id: subtitle.id || `${addon.id}-${subtitle.lang || 'unknown'}-${index}`, + ...subtitle, + addon: addon.id, + addonName: addon.name, + })) as Subtitle[]; + } catch (error: any) { + logger.error(`[getSubtitles] Failed to fetch subtitles from ${addon.name}:`, error?.message || error); + return [] as Subtitle[]; + } + }); + + const merged = ([] as Subtitle[]).concat(...(await Promise.all(requests))); + const seen = new Set(); + + const deduped = merged.filter(subtitle => { + if (!subtitle.url || seen.has(subtitle.url)) { + return false; + } + + seen.add(subtitle.url); + return true; + }); + + logger.log(`[getSubtitles] Total: ${deduped.length} unique subtitles from all addons`); + return deduped; +} diff --git a/src/services/stremio/types.ts b/src/services/stremio/types.ts new file mode 100644 index 00000000..4789f166 --- /dev/null +++ b/src/services/stremio/types.ts @@ -0,0 +1,236 @@ +export interface Meta { + id: string; + type: string; + name: string; + poster?: string; + posterShape?: 'poster' | 'square' | 'landscape'; + 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; + 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; + format?: 'srt' | 'vtt' | 'ass' | 'ssa'; +} + +export interface SourceObject { + url: string; + bytes?: number; +} + +export interface Stream { + url?: string; + ytId?: string; + infoHash?: string; + externalUrl?: string; + nzbUrl?: string; + rarUrls?: SourceObject[]; + zipUrls?: SourceObject[]; + '7zipUrls'?: SourceObject[]; + tgzUrls?: SourceObject[]; + tarUrls?: SourceObject[]; + fileIdx?: number; + fileMustInclude?: string; + servers?: string[]; + name?: string; + title?: string; + description?: string; + addon?: string; + addonId?: string; + addonName?: string; + size?: number; + isFree?: boolean; + isDebrid?: boolean; + quality?: string; + headers?: Record; + subtitles?: Subtitle[]; + sources?: string[]; + behaviorHints?: { + bingeGroup?: string; + notWebReady?: boolean; + countryWhitelist?: string[]; + cached?: boolean; + proxyHeaders?: { + request?: Record; + response?: Record; + }; + videoHash?: string; + videoSize?: number; + filename?: string; + [key: string]: any; + }; +} + +export interface StreamResponse { + streams: Stream[]; + addon: string; + addonName: string; +} + +export interface SubtitleResponse { + subtitles: Subtitle[]; + addon: string; + addonName: string; +} + +export interface StreamCallback { + ( + streams: Stream[] | null, + addonId: string | null, + addonName: string | null, + error: Error | null, + installationId?: string | null + ): void; +} + +export interface CatalogFilter { + title: string; + value: any; +} + +interface Catalog { + type: string; + id: string; + name: string; + extraSupported?: string[]; + extraRequired?: string[]; + itemCount?: number; + extra?: CatalogExtra[]; +} + +export interface CatalogExtra { + name: string; + isRequired?: boolean; + options?: string[]; + optionsLimit?: number; +} + +interface ResourceObject { + name: string; + types: string[]; + idPrefixes?: string[]; + idPrefix?: string[]; +} + +export interface Manifest { + id: string; + installationId?: string; + name: string; + version: string; + description: string; + url?: string; + originalUrl?: string; + catalogs?: Catalog[]; + resources?: any[]; + types?: string[]; + idPrefixes?: string[]; + manifestVersion?: string; + queryParams?: string; + behaviorHints?: { + configurable?: boolean; + configurationRequired?: boolean; + adult?: boolean; + p2p?: boolean; + }; + config?: ConfigObject[]; + addonCatalogs?: Catalog[]; + background?: string; + logo?: string; + contactEmail?: string; +} + +interface ConfigObject { + key: string; + type: 'text' | 'number' | 'password' | 'checkbox' | 'select'; + default?: string; + title?: string; + options?: string[]; + required?: boolean; +} + +export interface MetaLink { + name: string; + category: string; + url: string; +} + +export interface MetaDetails extends Meta { + videos?: { + id: string; + title: string; + released: string; + season?: number; + episode?: number; + thumbnail?: string; + streams?: Stream[]; + available?: boolean; + overview?: string; + trailers?: Stream[]; + }[]; + links?: MetaLink[]; +} + +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[]; +} + +export interface AddonCatalogItem { + transportName: string; + transportUrl: string; + manifest: Manifest; +} + +export type { Catalog, ConfigObject, ResourceObject }; diff --git a/src/services/stremioService.ts b/src/services/stremioService.ts index f30b0af2..d0c7d89e 100644 --- a/src/services/stremioService.ts +++ b/src/services/stremioService.ts @@ -1,2161 +1,20 @@ -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'; -import { MalSync } from './mal/MalSync'; -import { safeAxiosConfig, createSafeAxiosConfig } from '../utils/axiosConfig'; - -// 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; - posterShape?: 'poster' | 'square' | 'landscape'; // For variable aspect ratios - 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; - - // 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; - response?: Record; - }; - 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 and installation ID -interface StreamCallback { - (streams: Stream[] | null, addonId: string | null, addonName: string | null, error: Error | null, installationId?: string | 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; - installationId?: string; // Unique ID for this installation (allows multiple installs of same addon) - 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 = new Map(); // Key is installationId - private addonOrder: string[] = []; // Array of installationIds - 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 | null = null; - private catalogHasMore: Map = new Map(); - - private constructor() { - // Start initialization but don't wait for it - this.initializationPromise = this.initialize(); - } - - // Generate a unique installation ID for an addon - private generateInstallationId(addonId: string): string { - const timestamp = Date.now(); - const random = Math.random().toString(36).substring(2, 9); - return `${addonId}-${timestamp}-${random}`; - } - - - private addonProvidesStreams(manifest: Manifest): boolean { - if (!manifest.resources || !Array.isArray(manifest.resources)) { - return false; - } - - return manifest.resources.some(resource => { - if (typeof resource === 'string') { - return resource === 'stream'; - } else if (typeof resource === 'object' && resource !== null && 'name' in resource) { - return (resource as ResourceObject).name === 'stream'; - } - return false; - }); - } - - // Dynamic validator for content IDs based on installed addon capabilities - public async isValidContentId(type: string, id: string | null | undefined): Promise { - // 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(['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. - // For prefixes without a trailing separator (e.g. "mal", "kitsu"), the ID must be - // longer than the prefix itself so that bare prefix strings like "mal" are rejected. - const result = supportedPrefixes.some(prefix => { - const lowerPrefix = prefix.toLowerCase(); - if (!lowerId.startsWith(lowerPrefix)) return false; - if (lowerPrefix.endsWith(':') || lowerPrefix.endsWith('_')) return true; - return lowerId.length > lowerPrefix.length; - }); - return result; - } - - // Get all content types supported by installed addons - public getAllSupportedTypes(): string[] { - const addons = this.getInstalledAddons(); - const types = new Set(); - - 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(); - - 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 { - 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 using installationId as key - this.installedAddons = new Map(); - for (const addon of parsed) { - if (addon && addon.id) { - // Generate installationId for existing addons that don't have one (migration) - if (!addon.installationId) { - addon.installationId = this.generateInstallationId(addon.id); - } - this.installedAddons.set(addon.installationId, addon); - } - } - } - - // Install Cinemeta for new users, but allow existing users to uninstall it - const cinemetaId = 'com.linvo.cinemeta'; - const hasUserRemovedCinemeta = await this.hasUserRemovedAddon(cinemetaId); - - // Check if Cinemeta is already installed (by checking addon.id, not installationId) - const hasCinemeta = Array.from(this.installedAddons.values()).some(addon => addon.id === cinemetaId); - - if (!hasCinemeta && !hasUserRemovedCinemeta) { - try { - const cinemetaManifest = await this.getManifest('https://v3-cinemeta.strem.io/manifest.json'); - cinemetaManifest.installationId = this.generateInstallationId(cinemetaId); - this.installedAddons.set(cinemetaManifest.installationId, cinemetaManifest); - } catch (error) { - // Fallback to minimal manifest if fetch fails - const fallbackManifest: Manifest = { - id: cinemetaId, - installationId: this.generateInstallationId(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(fallbackManifest.installationId!, fallbackManifest); - } - } - - // Install OpenSubtitles v3 by default unless user has explicitly removed it - const opensubsId = 'org.stremio.opensubtitlesv3'; - const hasUserRemovedOpenSubtitles = await this.hasUserRemovedAddon(opensubsId); - - // Check if OpenSubtitles is already installed (by checking addon.id, not installationId) - const hasOpenSubs = Array.from(this.installedAddons.values()).some(addon => addon.id === opensubsId); - - if (!hasOpenSubs && !hasUserRemovedOpenSubtitles) { - try { - const opensubsManifest = await this.getManifest('https://opensubtitles-v3.strem.io/manifest.json'); - opensubsManifest.installationId = this.generateInstallationId(opensubsId); - this.installedAddons.set(opensubsManifest.installationId, opensubsManifest); - } catch (error) { - const fallbackManifest: Manifest = { - id: opensubsId, - installationId: this.generateInstallationId(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(fallbackManifest.installationId!, 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 installationIds that aren't in installedAddons - this.addonOrder = this.addonOrder.filter(installationId => this.installedAddons.has(installationId)); - } - - // Add Cinemeta to order only if user hasn't removed it - const hasUserRemovedCinemetaOrder = await this.hasUserRemovedAddon(cinemetaId); - const cinemetaInstallation = Array.from(this.installedAddons.values()).find(addon => addon.id === cinemetaId); - if (cinemetaInstallation && cinemetaInstallation.installationId && - !this.addonOrder.includes(cinemetaInstallation.installationId) && !hasUserRemovedCinemetaOrder) { - this.addonOrder.push(cinemetaInstallation.installationId); - } - - // Only add OpenSubtitles to order if user hasn't removed it - const hasUserRemovedOpenSubtitlesOrder = await this.hasUserRemovedAddon(opensubsId); - const opensubsInstallation = Array.from(this.installedAddons.values()).find(addon => addon.id === opensubsId); - if (opensubsInstallation && opensubsInstallation.installationId && - !this.addonOrder.includes(opensubsInstallation.installationId) && !hasUserRemovedOpenSubtitlesOrder) { - this.addonOrder.push(opensubsInstallation.installationId); - } - - // Add any missing addons to the order (use installationIds) - const installedInstallationIds = Array.from(this.installedAddons.keys()); - const missingInstallationIds = installedInstallationIds.filter(installationId => !this.addonOrder.includes(installationId)); - this.addonOrder = [...this.addonOrder, ...missingInstallationIds]; - - // 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 { - if (!this.initialized && this.initializationPromise) { - await this.initializationPromise; - } - } - - private async retryRequest(request: () => Promise, retries = 1, delay = 1000): Promise { - 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 { - 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 { - 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 { - 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, safeAxiosConfig); - }); - - 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 { - const manifest = await this.getManifest(url); - if (manifest && manifest.id) { - // Check if this addon is already installed - const existingInstallations = Array.from(this.installedAddons.values()).filter(a => a.id === manifest.id); - const isAlreadyInstalled = existingInstallations.length > 0; - - // Only allow multiple installations for stream-providing addons - if (isAlreadyInstalled && !this.addonProvidesStreams(manifest)) { - throw new Error('This addon is already installed. Multiple installations are only allowed for stream providers.'); - } - - // Generate a unique installation ID for this installation - manifest.installationId = this.generateInstallationId(manifest.id); - - // Store using installationId as key (allows multiple installations of same addon) - this.installedAddons.set(manifest.installationId, 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 installationId to order (new addons go to the end) - if (!this.addonOrder.includes(manifest.installationId)) { - this.addonOrder.push(manifest.installationId); - } - - await this.saveInstalledAddons(); - await this.saveAddonOrder(); - // Emit an event that an addon was added (include both ids for compatibility) - addonEmitter.emit(ADDON_EVENTS.ADDON_ADDED, { installationId: manifest.installationId, addonId: manifest.id }); - } else { - throw new Error('Invalid addon manifest'); - } - } - - async removeAddon(installationId: string): Promise { - // Allow removal of any addon installation, including pre-installed ones like Cinemeta - if (this.installedAddons.has(installationId)) { - const addon = this.installedAddons.get(installationId); - this.installedAddons.delete(installationId); - // Remove from order using installationId - this.addonOrder = this.addonOrder.filter(id => id !== installationId); - - // Track user explicit removal only if this is the last installation of this addon - if (addon) { - const remainingInstallations = Array.from(this.installedAddons.values()).filter(a => a.id === addon.id); - if (remainingInstallations.length === 0) { - // This was the last installation, mark addon as removed by user - await this.markAddonAsRemovedByUser(addon.id); - // Proactively clean up any persisted orders/legacy keys for this addon - await this.cleanupRemovedAddonFromStorage(addon.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, installationId); - } - } - - getInstalledAddons(): Manifest[] { - // Return addons in the specified order (using installationIds) - const result = this.addonOrder - .filter(installationId => this.installedAddons.has(installationId)) - .map(installationId => this.installedAddons.get(installationId)!); - return result; - } - - async getInstalledAddonsAsync(): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - // 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, safeAxiosConfig)); - // 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, safeAxiosConfig)); - // 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, safeAxiosConfig)); - } 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 { - 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, createSafeAxiosConfig(10000)); - }); - - - if (response.data && response.data.meta && response.data.meta.id) { - return response.data.meta; - } else { - if (__DEV__) console.warn(`⚠️ [getMetaDetails] Preferred addon ${preferredAddon.name} returned empty/invalid meta`); - } - } 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, createSafeAxiosConfig(10000)); - }); - - - if (response.data && response.data.meta && response.data.meta.id) { - return response.data.meta; - } else { - if (__DEV__) console.log(`[getMetaDetails] Cinemeta URL ${baseUrl} returned empty/invalid meta`); - } - } 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, createSafeAxiosConfig(10000)); - }); - - - if (response.data && response.data.meta && response.data.meta.id) { - return response.data.meta; - } else { - if (__DEV__) console.log(`[getMetaDetails] Addon ${addon.name} returned empty/invalid meta`); - } - } 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 { - await this.ensureInitialized(); - - let activeId = id; - let resolvedTmdbId: string | null = null; - - const addons = this.getInstalledAddons(); - - // Some addons use non-standard meta types (e.g. "anime") but expect streams under the "series" endpoint. - // We'll try the requested type first, then (if no addons match) fall back to "series". - const pickStreamAddons = (requestType: string) => - 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; - - for (const resource of addon.resources) { - 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(requestType)) { - hasStreamResource = true; - - if (Array.isArray(typedResource.idPrefixes) && typedResource.idPrefixes.length > 0) { - supportsIdPrefix = typedResource.idPrefixes.some(p => id.startsWith(p)); - } else { - supportsIdPrefix = true; - } - break; - } - } else if (typeof resource === 'string' && resource === 'stream' && addon.types) { - if (Array.isArray(addon.types) && addon.types.includes(requestType)) { - hasStreamResource = true; - if (addon.idPrefixes && Array.isArray(addon.idPrefixes) && addon.idPrefixes.length > 0) { - supportsIdPrefix = addon.idPrefixes.some(p => id.startsWith(p)); - } else { - supportsIdPrefix = true; - } - break; - } - } - } - - return hasStreamResource && supportsIdPrefix; - }); - - // 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 = resolvedTmdbId; - 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 - } - - let effectiveType = type; - let streamAddons = pickStreamAddons(type); - - logger.log(`🧭 [getStreams] Resolving stream addons for type='${type}' id='${id}' (matched=${streamAddons.length})`); - - if (streamAddons.length === 0) { - const fallbackTypes = ['series', 'movie', 'tv', 'channel'].filter(t => t !== type); - for (const fallbackType of fallbackTypes) { - const fallbackAddons = pickStreamAddons(fallbackType); - if (fallbackAddons.length > 0) { - effectiveType = fallbackType; - streamAddons = fallbackAddons; - logger.log(`🔁 [getStreams] No stream addons for type '${type}', falling back to '${effectiveType}' for id '${id}'`); - break; - } - } - } - - if (effectiveType !== type) { - logger.log(`🧭 [getStreams] Using effectiveType='${effectiveType}' (requested='${type}') for id='${id}'`); - } - - if (streamAddons.length === 0) { - logger.warn('⚠️ [getStreams] No addons found that can provide streams'); - - // Log what the URL would have been for debugging - const encodedId = encodeURIComponent(id); - const exampleUrl = `/stream/${effectiveType}/${encodedId}.json`; - logger.log(`🚫 [getStreams] No stream addons matched. Would have requested: ${exampleUrl}`); - logger.log(`🚫 [getStreams] Details: requestedType='${type}' effectiveType='${effectiveType}' id='${id}'`); - - // Show which addons have stream capability but didn't match - const streamCapableAddons = addons.filter(addon => { - if (!addon.resources || !Array.isArray(addon.resources)) return false; - return addon.resources.some(resource => { - if (typeof resource === 'object' && resource !== null && 'name' in resource) { - return (resource as ResourceObject).name === 'stream'; - } - return typeof resource === 'string' && resource === 'stream'; - }); - }); - - if (streamCapableAddons.length > 0) { - logger.log(`🚫 [getStreams] Found ${streamCapableAddons.length} stream-capable addon(s) that didn't match:`); - - for (const addon of streamCapableAddons) { - const streamResources = addon.resources!.filter(resource => { - if (typeof resource === 'object' && resource !== null && 'name' in resource) { - return (resource as ResourceObject).name === 'stream'; - } - return typeof resource === 'string' && resource === 'stream'; - }); - - for (const resource of streamResources) { - if (typeof resource === 'object' && resource !== null) { - const typedResource = resource as ResourceObject; - const types = typedResource.types || []; - const prefixes = typedResource.idPrefixes || []; - const typeMatch = types.includes(effectiveType); - const prefixMatch = prefixes.length === 0 || prefixes.some(p => id.startsWith(p)); - - if (addon.url) { - const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url); - const wouldBeUrl = queryParams - ? `${baseUrl}/stream/${effectiveType}/${encodedId}.json?${queryParams}` - : `${baseUrl}/stream/${effectiveType}/${encodedId}.json`; - - console.log( - ` ❌ ${addon.name} (${addon.id}):\n` + - ` types=[${types.join(',')}] typeMatch=${typeMatch}\n` + - ` prefixes=[${prefixes.join(',')}] prefixMatch=${prefixMatch}\n` + - ` url=${wouldBeUrl}` - ); - } else { - console.log(` ❌ ${addon.name} (${addon.id}): no URL configured`); - } - } else if (typeof resource === 'string' && resource === 'stream') { - // String resource - check addon-level types and prefixes - const addonTypes = addon.types || []; - const addonPrefixes = addon.idPrefixes || []; - const typeMatch = addonTypes.includes(effectiveType); - const prefixMatch = addonPrefixes.length === 0 || addonPrefixes.some(p => id.startsWith(p)); - - if (addon.url) { - const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url); - const wouldBeUrl = queryParams - ? `${baseUrl}/stream/${effectiveType}/${encodedId}.json?${queryParams}` - : `${baseUrl}/stream/${effectiveType}/${encodedId}.json`; - - console.log( - ` ❌ ${addon.name} (${addon.id}) [addon-level]:\n` + - ` types=[${addonTypes.join(',')}] typeMatch=${typeMatch}\n` + - ` prefixes=[${addonPrefixes.join(',')}] prefixMatch=${prefixMatch}\n` + - ` url=${wouldBeUrl}` - ); - } - } - } - } - } else { - logger.log(`🚫 [getStreams] No stream-capable addons installed`); - } - - 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'), addon.installationId); - return; - } - - const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url); - const encodedId = encodeURIComponent(activeId); - const url = queryParams ? `${baseUrl}/stream/${effectiveType}/${encodedId}.json?${queryParams}` : `${baseUrl}/stream/${effectiveType}/${encodedId}.json`; - - logger.log( - `🔗 [getStreams] GET ${url} (addon='${addon.name}' id='${addon.id}' install='${addon.installationId}' requestedType='${type}' effectiveType='${effectiveType}' rawId='${id}')` - ); - - const response = await this.retryRequest(async () => { - return await axios.get(url, safeAxiosConfig); - }); - - let processedStreams: Stream[] = []; - if (response.data && response.data.streams) { - logger.log(`✅ [getStreams] Got ${response.data.streams.length} streams from ${addon.name} (${addon.id}) [${addon.installationId}]`); - processedStreams = this.processStreams(response.data.streams, addon); - logger.log(`✅ [getStreams] Processed ${processedStreams.length} valid streams from ${addon.name} (${addon.id}) [${addon.installationId}]`); - } else { - logger.log(`⚠️ [getStreams] No streams found in response from ${addon.name} (${addon.id}) [${addon.installationId}]`); - } - - if (callback) { - // Call callback with processed streams (can be empty array), include installationId - callback(processedStreams, addon.id, addon.name, null, addon.installationId); - } - } catch (error) { - if (callback) { - // Call callback with error, include installationId - callback(null, addon.id, addon.name, error as Error, addon.installationId); - } - } - })(); // 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 { - 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( - `🔗 [fetchStreamsFromAddon] GET ${url} (addon='${addon.name}' id='${addon.id}' install='${addon.installationId}' type='${type}' rawId='${id}')` - ); - - try { - // Increase timeout for debrid services - const timeout = addon.id.toLowerCase().includes('torrentio') ? 60000 : 10000; - - const response = await this.retryRequest(async () => { - logger.log(`🌐 [fetchStreamsFromAddon] Requesting ${url} (timeout=${timeout}ms)`); - return await axios.get(url, createSafeAxiosConfig(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 { - await this.ensureInitialized(); - // Collect from all installed addons that expose a subtitles resource - const addons = this.getInstalledAddons(); - - // The ID to check for prefix matching - use videoId for series (e.g., tt1234567:1:1), otherwise use id - const idForChecking = type === 'series' && videoId - ? videoId.replace('series:', '') - : id; - - const subtitleAddons = addons.filter(addon => { - if (!addon.resources) return false; - - // Check if addon has subtitles resource - const subtitlesResource = addon.resources.find((resource: any) => { - if (typeof resource === 'string') return resource === 'subtitles'; - return resource && resource.name === 'subtitles'; - }); - - if (!subtitlesResource) return false; - - // Check type support - either from the resource object or addon-level types - let supportsType = true; - if (typeof subtitlesResource === 'object' && subtitlesResource.types) { - supportsType = subtitlesResource.types.includes(type); - } else if (addon.types) { - supportsType = addon.types.includes(type); - } - - if (!supportsType) { - logger.log(`[getSubtitles] Addon ${addon.name} does not support type ${type}`); - return false; - } - - // Check idPrefixes - either from the resource object or addon-level - let supportsIdPrefix = true; - let idPrefixes: string[] | undefined; - - if (typeof subtitlesResource === 'object' && subtitlesResource.idPrefixes) { - idPrefixes = subtitlesResource.idPrefixes; - } else if (addon.idPrefixes) { - idPrefixes = addon.idPrefixes; - } - - if (idPrefixes && idPrefixes.length > 0) { - supportsIdPrefix = idPrefixes.some(prefix => idForChecking.startsWith(prefix)); - } - - if (!supportsIdPrefix) { - logger.log(`[getSubtitles] Addon ${addon.name} does not support ID prefix for ${idForChecking} (requires: ${idPrefixes?.join(', ')})`); - return false; - } - - return true; - }); - - if (subtitleAddons.length === 0) { - logger.warn('No subtitle-capable addons installed that support the requested type/id'); - return []; - } - - logger.log(`[getSubtitles] Found ${subtitleAddons.length} subtitle addons for ${type}/${id}: ${subtitleAddons.map(a => a.name).join(', ')}`); - - const requests = subtitleAddons.map(async (addon) => { - if (!addon.url) return [] as Subtitle[]; - try { - const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url || ''); - let url = ''; - if (type === 'series' && videoId) { - const episodeInfo = encodeURIComponent(videoId.replace('series:', '')); - url = queryParams - ? `${baseUrl}/subtitles/series/${episodeInfo}.json?${queryParams}` - : `${baseUrl}/subtitles/series/${episodeInfo}.json`; - } else { - const encodedId = encodeURIComponent(id); - url = queryParams - ? `${baseUrl}/subtitles/${type}/${encodedId}.json?${queryParams}` - : `${baseUrl}/subtitles/${type}/${encodedId}.json`; - } - logger.log(`[getSubtitles] Fetching subtitles from ${addon.name}: ${url}`); - const response = await this.retryRequest(async () => axios.get(url, createSafeAxiosConfig(10000))); - if (response.data && Array.isArray(response.data.subtitles)) { - logger.log(`[getSubtitles] Got ${response.data.subtitles.length} subtitles from ${addon.name}`); - 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[]; - } else { - logger.log(`[getSubtitles] No subtitles array in response from ${addon.name}`); - } - } catch (error: any) { - logger.error(`[getSubtitles] Failed to fetch subtitles from ${addon.name}:`, error?.message || 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(); - const deduped = merged.filter(s => { - const key = s.url; - if (!key) return false; - if (seen.has(key)) return false; - seen.add(key); - return true; - }); - logger.log(`[getSubtitles] Total: ${deduped.length} unique subtitles from all addons`); - return deduped; - } - - // Add methods to move addons in the order (using installationIds) - moveAddonUp(installationId: string): boolean { - const index = this.addonOrder.indexOf(installationId); - 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(installationId: string): boolean { - const index = this.addonOrder.indexOf(installationId); - 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; - } - - // Reconcile local addon order to match a remote ordered list of addon manifest URLs. - // Any local addons not present in the remote list are appended in their current order. - async applyAddonOrderFromManifestUrls(manifestUrls: string[]): Promise { - await this.ensureInitialized(); - if (!Array.isArray(manifestUrls) || manifestUrls.length === 0) return false; - - const normalizeManifestUrl = (raw: string): string => { - const value = (raw || '').trim(); - if (!value) return ''; - const withManifest = value.includes('manifest.json') - ? value - : `${value.replace(/\/$/, '')}/manifest.json`; - return withManifest.toLowerCase(); - }; - - const localByNormalizedUrl = new Map(); - for (const installationId of this.addonOrder) { - const addon = this.installedAddons.get(installationId); - if (!addon) continue; - const normalized = normalizeManifestUrl(addon.originalUrl || addon.url || ''); - if (!normalized) continue; - const list = localByNormalizedUrl.get(normalized) || []; - list.push(installationId); - localByNormalizedUrl.set(normalized, list); - } - - const nextOrder: string[] = []; - const seenInstallations = new Set(); - - for (const remoteUrl of manifestUrls) { - const normalizedRemote = normalizeManifestUrl(remoteUrl); - if (!normalizedRemote) continue; - const candidates = localByNormalizedUrl.get(normalizedRemote); - if (!candidates || candidates.length === 0) continue; - const installationId = candidates.shift(); - if (!installationId || seenInstallations.has(installationId)) continue; - nextOrder.push(installationId); - seenInstallations.add(installationId); - } - - for (const installationId of this.addonOrder) { - if (!this.installedAddons.has(installationId)) continue; - if (seenInstallations.has(installationId)) continue; - nextOrder.push(installationId); - seenInstallations.add(installationId); - } - - const changed = - nextOrder.length !== this.addonOrder.length || - nextOrder.some((id, index) => id !== this.addonOrder[index]); - - if (!changed) return false; - - this.addonOrder = nextOrder; - await this.saveAddonOrder(); - addonEmitter.emit(ADDON_EVENTS.ORDER_CHANGED); - return true; - } - - // Check if any installed addons can provide streams (including embedded streams in metadata) - async hasStreamProviders(type?: string): Promise { - 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 { - 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, createSafeAxiosConfig(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; +export { ADDON_EVENTS, addonEmitter } from './stremio/events'; +export { + StremioService, + default, + stremioService, +} from './stremio/StremioService'; +export type { + AddonCapabilities, + AddonCatalogItem, + CatalogExtra, + Manifest, + Meta, + MetaDetails, + MetaLink, + SourceObject, + Stream, + StreamResponse, + Subtitle, + SubtitleResponse, +} from './stremio/StremioService'; From b15b01d1f5f3bf759f9e54bbd7f4e8e9a78ab9e0 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 17 Mar 2026 05:46:49 +0530 Subject: [PATCH 04/10] ref: catalog service into managable chunks --- src/services/catalog/catalog-utils.ts | 84 + src/services/catalog/content-details.ts | 282 ++++ src/services/catalog/content-mappers.ts | 157 ++ src/services/catalog/discovery.ts | 398 +++++ src/services/catalog/library.ts | 329 ++++ src/services/catalog/search.ts | 401 +++++ src/services/catalog/types.ts | 154 ++ src/services/catalogService.ts | 1868 ++--------------------- 8 files changed, 1922 insertions(+), 1751 deletions(-) create mode 100644 src/services/catalog/catalog-utils.ts create mode 100644 src/services/catalog/content-details.ts create mode 100644 src/services/catalog/content-mappers.ts create mode 100644 src/services/catalog/discovery.ts create mode 100644 src/services/catalog/library.ts create mode 100644 src/services/catalog/search.ts create mode 100644 src/services/catalog/types.ts diff --git a/src/services/catalog/catalog-utils.ts b/src/services/catalog/catalog-utils.ts new file mode 100644 index 00000000..de273e57 --- /dev/null +++ b/src/services/catalog/catalog-utils.ts @@ -0,0 +1,84 @@ +import type { Manifest } from '../stremioService'; + +import type { StreamingAddon, StreamingCatalog } from './types'; + +export function convertManifestToStreamingAddon(manifest: Manifest): StreamingAddon { + return { + id: manifest.id, + name: manifest.name, + version: manifest.version, + description: manifest.description, + types: manifest.types || [], + catalogs: (manifest.catalogs || []).map(catalog => ({ + ...catalog, + extraSupported: catalog.extraSupported || [], + extra: (catalog.extra || []).map(extra => ({ + name: extra.name, + isRequired: extra.isRequired, + options: extra.options, + optionsLimit: extra.optionsLimit, + })), + })), + resources: manifest.resources || [], + url: (manifest.url || manifest.originalUrl) as any, + originalUrl: (manifest.originalUrl || manifest.url) as any, + transportUrl: manifest.url, + transportName: manifest.name, + }; +} + +export async function getAllAddons(getInstalledAddons: () => Promise): Promise { + const addons = await getInstalledAddons(); + return addons.map(convertManifestToStreamingAddon); +} + +export function catalogSupportsExtra(catalog: StreamingCatalog, extraName: string): boolean { + return (catalog.extraSupported || []).includes(extraName) || + (catalog.extra || []).some(extra => extra.name === extraName); +} + +export function getRequiredCatalogExtras(catalog: StreamingCatalog): string[] { + return (catalog.extra || []).filter(extra => extra.isRequired).map(extra => extra.name); +} + +export function canBrowseCatalog(catalog: StreamingCatalog): boolean { + if ( + (catalog.id && catalog.id.startsWith('search.')) || + (catalog.type && catalog.type.startsWith('search')) + ) { + return false; + } + + const requiredExtras = getRequiredCatalogExtras(catalog); + return requiredExtras.every(extraName => extraName === 'genre'); +} + +export function isVisibleOnHome(catalog: StreamingCatalog, addonCatalogs: StreamingCatalog[]): boolean { + if ( + (catalog.id && catalog.id.startsWith('search.')) || + (catalog.type && catalog.type.startsWith('search')) + ) { + return false; + } + + const requiredExtras = getRequiredCatalogExtras(catalog); + if (requiredExtras.length > 0) { + return false; + } + + const addonUsesShowInHome = addonCatalogs.some(addonCatalog => addonCatalog.showInHome === true); + if (addonUsesShowInHome) { + return catalog.showInHome === true; + } + + return true; +} + +export function canSearchCatalog(catalog: StreamingCatalog): boolean { + if (!catalogSupportsExtra(catalog, 'search')) { + return false; + } + + const requiredExtras = getRequiredCatalogExtras(catalog); + return requiredExtras.every(extraName => extraName === 'search'); +} diff --git a/src/services/catalog/content-details.ts b/src/services/catalog/content-details.ts new file mode 100644 index 00000000..bfec521a --- /dev/null +++ b/src/services/catalog/content-details.ts @@ -0,0 +1,282 @@ +import { stremioService } from '../stremioService'; +import { mmkvStorage } from '../mmkvStorage'; +import { TMDBService } from '../tmdbService'; +import { logger } from '../../utils/logger'; + +import { convertMetaToStreamingContent, convertMetaToStreamingContentEnhanced } from './content-mappers'; +import { addToRecentContent, createLibraryKey, type CatalogLibraryState } from './library'; +import { DATA_SOURCE_KEY, DataSource, type StreamingContent } from './types'; + +export async function getDataSourcePreference(): Promise { + try { + const dataSource = await mmkvStorage.getItem(DATA_SOURCE_KEY); + return (dataSource as DataSource) || DataSource.STREMIO_ADDONS; + } catch (error) { + logger.error('Failed to get data source preference:', error); + return DataSource.STREMIO_ADDONS; + } +} + +export async function setDataSourcePreference(dataSource: DataSource): Promise { + try { + await mmkvStorage.setItem(DATA_SOURCE_KEY, dataSource); + } catch (error) { + logger.error('Failed to set data source preference:', error); + } +} + +export async function getContentDetails( + state: CatalogLibraryState, + type: string, + id: string, + preferredAddonId?: string +): Promise { + console.log('🔍 [CatalogService] getContentDetails called:', { type, id, preferredAddonId }); + + try { + let meta = null; + let lastError = null; + + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + console.log(`🔍 [CatalogService] Attempt ${attempt + 1}/2 for getContentDetails:`, { type, id, preferredAddonId }); + + const isValidId = await stremioService.isValidContentId(type, id); + console.log('🔍 [CatalogService] Content ID validation:', { type, id, isValidId }); + + if (!isValidId) { + console.log('🔍 [CatalogService] Invalid content ID, breaking retry loop'); + break; + } + + console.log('🔍 [CatalogService] Calling stremioService.getMetaDetails:', { type, id, preferredAddonId }); + meta = await stremioService.getMetaDetails(type, id, preferredAddonId); + console.log('🔍 [CatalogService] stremioService.getMetaDetails result:', { + hasMeta: !!meta, + metaId: meta?.id, + metaName: meta?.name, + metaType: meta?.type, + }); + + if (meta) { + break; + } + + await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, attempt))); + } catch (error) { + lastError = error; + console.log(`🔍 [CatalogService] Attempt ${attempt + 1} failed:`, { + errorMessage: error instanceof Error ? error.message : String(error), + isAxiosError: (error as any)?.isAxiosError, + responseStatus: (error as any)?.response?.status, + responseData: (error as any)?.response?.data, + }); + logger.error(`Attempt ${attempt + 1} failed to get content details for ${type}:${id}:`, error); + await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, attempt))); + } + } + + if (meta) { + console.log('🔍 [CatalogService] Meta found, converting to StreamingContent:', { + metaId: meta.id, + metaName: meta.name, + metaType: meta.type, + }); + + const content = convertMetaToStreamingContentEnhanced(meta, state.library); + addToRecentContent(state, content); + content.inLibrary = state.library[createLibraryKey(type, id)] !== undefined; + + console.log('🔍 [CatalogService] Successfully converted meta to StreamingContent:', { + contentId: content.id, + contentName: content.name, + contentType: content.type, + inLibrary: content.inLibrary, + }); + + return content; + } + + console.log('🔍 [CatalogService] No meta found, checking lastError:', { + hasLastError: !!lastError, + lastErrorMessage: lastError instanceof Error ? lastError.message : String(lastError), + }); + + if (lastError) { + console.log('🔍 [CatalogService] Throwing lastError:', { + errorMessage: lastError instanceof Error ? lastError.message : String(lastError), + isAxiosError: (lastError as any)?.isAxiosError, + responseStatus: (lastError as any)?.response?.status, + }); + throw lastError; + } + + console.log('🔍 [CatalogService] No meta and no error, returning null'); + return null; + } catch (error) { + console.log('🔍 [CatalogService] getContentDetails caught error:', { + errorMessage: error instanceof Error ? error.message : String(error), + isAxiosError: (error as any)?.isAxiosError, + responseStatus: (error as any)?.response?.status, + responseData: (error as any)?.response?.data, + }); + logger.error(`Failed to get content details for ${type}:${id}:`, error); + return null; + } +} + +export async function getEnhancedContentDetails( + state: CatalogLibraryState, + type: string, + id: string, + preferredAddonId?: string +): Promise { + console.log('🔍 [CatalogService] getEnhancedContentDetails called:', { type, id, preferredAddonId }); + logger.log(`🔍 [MetadataScreen] Fetching enhanced metadata for ${type}:${id} ${preferredAddonId ? `from addon ${preferredAddonId}` : ''}`); + + try { + const result = await getContentDetails(state, type, id, preferredAddonId); + console.log('🔍 [CatalogService] getEnhancedContentDetails result:', { + hasResult: !!result, + resultId: result?.id, + resultName: result?.name, + resultType: result?.type, + }); + return result; + } catch (error) { + console.log('🔍 [CatalogService] getEnhancedContentDetails error:', { + errorMessage: error instanceof Error ? error.message : String(error), + isAxiosError: (error as any)?.isAxiosError, + responseStatus: (error as any)?.response?.status, + responseData: (error as any)?.response?.data, + }); + throw error; + } +} + +export async function getBasicContentDetails( + state: CatalogLibraryState, + type: string, + id: string, + preferredAddonId?: string +): Promise { + try { + let meta = null; + let lastError = null; + + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + if (!(await stremioService.isValidContentId(type, id))) { + break; + } + + meta = await stremioService.getMetaDetails(type, id, preferredAddonId); + if (meta) { + break; + } + + await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, attempt))); + } catch (error) { + lastError = error; + logger.error(`Attempt ${attempt + 1} failed to get basic content details for ${type}:${id}:`, error); + await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, attempt))); + } + } + + if (meta) { + const content = convertMetaToStreamingContent(meta, state.library); + content.inLibrary = state.library[createLibraryKey(type, id)] !== undefined; + return content; + } + + if (lastError) { + throw lastError; + } + + return null; + } catch (error) { + logger.error(`Failed to get basic content details for ${type}:${id}:`, error); + return null; + } +} + +export async function getStremioId(type: string, tmdbId: string): Promise { + if (__DEV__) { + console.log('=== CatalogService.getStremioId ==='); + console.log('Input type:', type); + console.log('Input tmdbId:', tmdbId); + } + + try { + if (type === 'movie') { + if (__DEV__) { + console.log('Processing movie - fetching TMDB details...'); + } + + const movieDetails = await TMDBService.getInstance().getMovieDetails(tmdbId); + + if (__DEV__) { + console.log('Movie details result:', { + id: movieDetails?.id, + title: movieDetails?.title, + imdb_id: movieDetails?.imdb_id, + hasImdbId: !!movieDetails?.imdb_id, + }); + } + + if (movieDetails?.imdb_id) { + if (__DEV__) { + console.log('Successfully found IMDb ID:', movieDetails.imdb_id); + } + return movieDetails.imdb_id; + } + + console.warn('No IMDb ID found for movie:', tmdbId); + return null; + } + + if (type === 'tv' || type === 'series') { + if (__DEV__) { + console.log('Processing TV show - fetching TMDB details for IMDb ID...'); + } + + const externalIds = await TMDBService.getInstance().getShowExternalIds(parseInt(tmdbId, 10)); + + if (__DEV__) { + console.log('TV show external IDs result:', { + tmdbId, + imdb_id: externalIds?.imdb_id, + hasImdbId: !!externalIds?.imdb_id, + }); + } + + if (externalIds?.imdb_id) { + if (__DEV__) { + console.log('Successfully found IMDb ID for TV show:', externalIds.imdb_id); + } + return externalIds.imdb_id; + } + + console.warn('No IMDb ID found for TV show, falling back to kitsu format:', tmdbId); + const fallbackId = `kitsu:${tmdbId}`; + if (__DEV__) { + console.log('Generated fallback Stremio ID for TV:', fallbackId); + } + return fallbackId; + } + + console.warn('Unknown type provided:', type); + return null; + } catch (error: any) { + if (__DEV__) { + console.error('=== Error in getStremioId ==='); + console.error('Type:', type); + console.error('TMDB ID:', tmdbId); + console.error('Error details:', error); + console.error('Error message:', error.message); + } + + logger.error('Error getting Stremio ID:', error); + return null; + } +} diff --git a/src/services/catalog/content-mappers.ts b/src/services/catalog/content-mappers.ts new file mode 100644 index 00000000..e61dc8d9 --- /dev/null +++ b/src/services/catalog/content-mappers.ts @@ -0,0 +1,157 @@ +import { TMDBService } from '../tmdbService'; +import { logger } from '../../utils/logger'; + +import type { Meta } from '../stremioService'; + +import { createLibraryKey } from './library'; +import type { StreamingContent } from './types'; + +const FALLBACK_POSTER_URL = 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image'; + +export function convertMetaToStreamingContent( + meta: Meta, + library: Record +): StreamingContent { + let posterUrl = meta.poster; + if (!posterUrl || posterUrl.trim() === '' || posterUrl === 'null' || posterUrl === 'undefined') { + posterUrl = FALLBACK_POSTER_URL; + } + + let logoUrl = (meta as any).logo; + if (!logoUrl || logoUrl.trim() === '' || logoUrl === 'null' || logoUrl === 'undefined') { + logoUrl = undefined; + } + + return { + id: meta.id, + type: meta.type, + name: meta.name, + poster: posterUrl, + posterShape: meta.posterShape || 'poster', + banner: meta.background, + logo: logoUrl, + imdbRating: meta.imdbRating, + year: meta.year, + genres: meta.genres, + description: meta.description, + runtime: meta.runtime, + inLibrary: library[createLibraryKey(meta.type, meta.id)] !== undefined, + certification: meta.certification, + releaseInfo: meta.releaseInfo, + }; +} + +export function convertMetaToStreamingContentEnhanced( + meta: Meta, + library: Record +): StreamingContent { + const converted: StreamingContent = { + id: meta.id, + type: meta.type, + name: meta.name, + poster: meta.poster || FALLBACK_POSTER_URL, + posterShape: meta.posterShape || 'poster', + banner: meta.background, + logo: (meta as any).logo || undefined, + imdbRating: meta.imdbRating, + year: meta.year, + genres: meta.genres, + description: meta.description, + runtime: meta.runtime, + inLibrary: library[createLibraryKey(meta.type, meta.id)] !== undefined, + certification: meta.certification, + directors: (meta as any).director + ? (Array.isArray((meta as any).director) ? (meta as any).director : [(meta as any).director]) + : undefined, + writer: (meta as any).writer || undefined, + country: (meta as any).country || undefined, + imdb_id: (meta as any).imdb_id || undefined, + slug: (meta as any).slug || undefined, + releaseInfo: meta.releaseInfo || (meta as any).releaseInfo || undefined, + trailerStreams: (meta as any).trailerStreams || undefined, + links: (meta as any).links || undefined, + behaviorHints: (meta as any).behaviorHints || undefined, + }; + + if ((meta as any).app_extras?.cast && Array.isArray((meta as any).app_extras.cast)) { + converted.addonCast = (meta as any).app_extras.cast.map((castMember: any, index: number) => ({ + id: index + 1, + name: castMember.name || 'Unknown', + character: castMember.character || '', + profile_path: castMember.photo || null, + })); + } else if (meta.cast && Array.isArray(meta.cast)) { + converted.addonCast = meta.cast.map((castName: string, index: number) => ({ + id: index + 1, + name: castName || 'Unknown', + character: '', + profile_path: null, + })); + } + + if ((meta as any).trailerStreams?.length > 0) { + logger.log(`🎬 Enhanced metadata: Found ${(meta as any).trailerStreams.length} trailers for ${meta.name}`); + } + + if ((meta as any).links?.length > 0) { + logger.log(`🔗 Enhanced metadata: Found ${(meta as any).links.length} links for ${meta.name}`); + } + + if (converted.addonCast && converted.addonCast.length > 0) { + logger.log(`🎭 Enhanced metadata: Found ${converted.addonCast.length} cast members from addon for ${meta.name}`); + } + + if ((meta as any).videos) { + converted.videos = (meta as any).videos; + } + + return converted; +} + +export async function convertTMDBToStreamingContent( + item: any, + type: 'movie' | 'tv', + library: Record +): Promise { + const id = item.external_ids?.imdb_id || `tmdb:${item.id}`; + const name = type === 'movie' ? item.title : item.name; + const posterPath = item.poster_path; + + let genres: string[] = []; + if (item.genre_ids && item.genre_ids.length > 0) { + try { + const tmdbService = TMDBService.getInstance(); + const genreLists = type === 'movie' + ? await tmdbService.getMovieGenres() + : await tmdbService.getTvGenres(); + + genres = item.genre_ids + .map((genreId: number) => { + const genre = genreLists.find(currentGenre => currentGenre.id === genreId); + return genre ? genre.name : null; + }) + .filter(Boolean) as string[]; + } catch (error) { + logger.error('Failed to get genres for TMDB content:', error); + } + } + + const contentType = type === 'movie' ? 'movie' : 'series'; + + return { + id, + type: contentType, + name: name || 'Unknown', + poster: posterPath + ? `https://image.tmdb.org/t/p/w500${posterPath}` + : FALLBACK_POSTER_URL, + posterShape: 'poster', + banner: item.backdrop_path ? `https://image.tmdb.org/t/p/original${item.backdrop_path}` : undefined, + year: type === 'movie' + ? (item.release_date ? new Date(item.release_date).getFullYear() : undefined) + : (item.first_air_date ? new Date(item.first_air_date).getFullYear() : undefined), + description: item.overview, + genres, + inLibrary: library[createLibraryKey(contentType, id)] !== undefined, + }; +} diff --git a/src/services/catalog/discovery.ts b/src/services/catalog/discovery.ts new file mode 100644 index 00000000..8189449f --- /dev/null +++ b/src/services/catalog/discovery.ts @@ -0,0 +1,398 @@ +import { stremioService } from '../stremioService'; +import { TMDBService } from '../tmdbService'; +import { logger } from '../../utils/logger'; +import { getCatalogDisplayName } from '../../utils/catalogNameUtils'; + +import { + canBrowseCatalog, + convertManifestToStreamingAddon, + getAllAddons, + isVisibleOnHome, +} from './catalog-utils'; +import { convertMetaToStreamingContent, convertTMDBToStreamingContent } from './content-mappers'; +import type { CatalogContent, DataSource, StreamingAddon, StreamingCatalog, StreamingContent } from './types'; + +export async function getAllStreamingAddons(): Promise { + return getAllAddons(() => stremioService.getInstalledAddonsAsync()); +} + +export async function resolveHomeCatalogsToFetch( + limitIds?: string[] +): Promise> { + const addons = await getAllStreamingAddons(); + const potentialCatalogs: Array<{ addon: StreamingAddon; catalog: StreamingCatalog }> = []; + + for (const addon of addons) { + for (const catalog of addon.catalogs || []) { + if (isVisibleOnHome(catalog, addon.catalogs)) { + potentialCatalogs.push({ addon, catalog }); + } + } + } + + if (limitIds && limitIds.length > 0) { + return potentialCatalogs.filter(item => { + const catalogId = `${item.addon.id}:${item.catalog.type}:${item.catalog.id}`; + return limitIds.includes(catalogId); + }); + } + + return potentialCatalogs.sort(() => 0.5 - Math.random()).slice(0, 5); +} + +export async function fetchHomeCatalog( + library: Record, + addon: StreamingAddon, + catalog: StreamingCatalog +): Promise { + try { + const addonManifests = await stremioService.getInstalledAddonsAsync(); + const manifest = addonManifests.find(currentAddon => currentAddon.id === addon.id); + if (!manifest) { + return null; + } + + const metas = await stremioService.getCatalog(manifest, catalog.type, catalog.id, 1); + if (!metas || metas.length === 0) { + return null; + } + + const items = metas.slice(0, 12).map(meta => convertMetaToStreamingContent(meta, library)); + const originalName = catalog.name || catalog.id; + let displayName = await getCatalogDisplayName(addon.id, catalog.type, catalog.id, originalName); + const isCustom = displayName !== originalName; + + if (!isCustom) { + const uniqueWords: string[] = []; + const seenWords = new Set(); + + for (const word of displayName.split(' ')) { + const normalizedWord = word.toLowerCase(); + if (!seenWords.has(normalizedWord)) { + uniqueWords.push(word); + seenWords.add(normalizedWord); + } + } + + displayName = uniqueWords.join(' '); + + const contentType = catalog.type === 'movie' ? 'Movies' : 'TV Shows'; + if (!displayName.toLowerCase().includes(contentType.toLowerCase())) { + displayName = `${displayName} ${contentType}`; + } + } + + return { + addon: addon.id, + type: catalog.type, + id: catalog.id, + name: displayName, + items, + }; + } catch (error) { + logger.error(`Failed to load ${catalog.name} from ${addon.name}:`, error); + return null; + } +} + +export async function getHomeCatalogs( + library: Record, + limitIds?: string[] +): Promise { + const catalogsToFetch = await resolveHomeCatalogsToFetch(limitIds); + const catalogResults = await Promise.all( + catalogsToFetch.map(({ addon, catalog }) => fetchHomeCatalog(library, addon, catalog)) + ); + + return catalogResults.filter((catalog): catalog is CatalogContent => catalog !== null); +} + +export async function getCatalogByType( + library: Record, + dataSourcePreference: DataSource, + type: string, + genreFilter?: string +): Promise { + if (dataSourcePreference === 'tmdb') { + return getCatalogByTypeFromTMDB(library, type, genreFilter); + } + + const addons = await getAllStreamingAddons(); + const typeAddons = addons.filter(addon => addon.catalogs.some(catalog => catalog.type === type)); + const manifests = await stremioService.getInstalledAddonsAsync(); + const manifestMap = new Map(manifests.map(manifest => [manifest.id, manifest])); + const catalogPromises: Array> = []; + + for (const addon of typeAddons) { + const typeCatalogs = addon.catalogs.filter( + catalog => catalog.type === type && isVisibleOnHome(catalog, addon.catalogs) + ); + + for (const catalog of typeCatalogs) { + catalogPromises.push( + (async () => { + try { + const manifest = manifestMap.get(addon.id); + if (!manifest) { + return null; + } + + const filters = genreFilter ? [{ title: 'genre', value: genreFilter }] : []; + const metas = await stremioService.getCatalog(manifest, type, catalog.id, 1, filters); + + if (!metas || metas.length === 0) { + return null; + } + + return { + addon: addon.id, + type, + id: catalog.id, + name: await getCatalogDisplayName(addon.id, catalog.type, catalog.id, catalog.name), + genre: genreFilter, + items: metas.map(meta => convertMetaToStreamingContent(meta, library)), + }; + } catch (error) { + logger.error(`Failed to get catalog ${catalog.id} for addon ${addon.id}:`, error); + return null; + } + })() + ); + } + } + + const catalogResults = await Promise.all(catalogPromises); + return catalogResults.filter((catalog): catalog is CatalogContent => catalog !== null); +} + +async function getCatalogByTypeFromTMDB( + library: Record, + type: string, + genreFilter?: string +): Promise { + const tmdbService = TMDBService.getInstance(); + const tmdbType = type === 'movie' ? 'movie' : 'tv'; + + try { + if (!genreFilter || genreFilter === 'All') { + return Promise.all([ + (async () => ({ + addon: 'tmdb', + type, + id: 'trending', + name: `Trending ${type === 'movie' ? 'Movies' : 'TV Shows'}`, + items: await Promise.all( + (await tmdbService.getTrending(tmdbType, 'week')).map(item => + convertTMDBToStreamingContent(item, tmdbType, library) + ) + ), + }))(), + (async () => ({ + addon: 'tmdb', + type, + id: 'popular', + name: `Popular ${type === 'movie' ? 'Movies' : 'TV Shows'}`, + items: await Promise.all( + (await tmdbService.getPopular(tmdbType, 1)).map(item => + convertTMDBToStreamingContent(item, tmdbType, library) + ) + ), + }))(), + (async () => ({ + addon: 'tmdb', + type, + id: 'upcoming', + name: type === 'movie' ? 'Upcoming Movies' : 'On Air TV Shows', + items: await Promise.all( + (await tmdbService.getUpcoming(tmdbType, 1)).map(item => + convertTMDBToStreamingContent(item, tmdbType, library) + ) + ), + }))(), + ]); + } + + return [{ + addon: 'tmdb', + type, + id: 'discover', + name: `${genreFilter} ${type === 'movie' ? 'Movies' : 'TV Shows'}`, + genre: genreFilter, + items: await Promise.all( + (await tmdbService.discoverByGenre(tmdbType, genreFilter)).map(item => + convertTMDBToStreamingContent(item, tmdbType, library) + ) + ), + }]; + } catch (error) { + logger.error(`Failed to get catalog from TMDB for type ${type}, genre ${genreFilter}:`, error); + return []; + } +} + +export async function getDiscoverFilters(): Promise<{ + genres: string[]; + types: string[]; + catalogsByType: Record< + string, + Array<{ addonId: string; addonName: string; catalogId: string; catalogName: string; genres: string[] }> + >; +}> { + const addons = await getAllStreamingAddons(); + const allGenres = new Set(); + const allTypes = new Set(); + const catalogsByType: Record< + string, + Array<{ addonId: string; addonName: string; catalogId: string; catalogName: string; genres: string[] }> + > = {}; + + for (const addon of addons) { + for (const catalog of addon.catalogs || []) { + if (!canBrowseCatalog(catalog)) { + continue; + } + + if (catalog.type) { + allTypes.add(catalog.type); + } + + const catalogGenres: string[] = []; + for (const extra of catalog.extra || []) { + if (extra.name === 'genre' && Array.isArray(extra.options)) { + for (const genre of extra.options) { + allGenres.add(genre); + catalogGenres.push(genre); + } + } + } + + if (catalog.type) { + catalogsByType[catalog.type] ||= []; + catalogsByType[catalog.type].push({ + addonId: addon.id, + addonName: addon.name, + catalogId: catalog.id, + catalogName: catalog.name || catalog.id, + genres: catalogGenres, + }); + } + } + } + + return { + genres: Array.from(allGenres).sort((left, right) => left.localeCompare(right)), + types: Array.from(allTypes), + catalogsByType, + }; +} + +export async function discoverContent( + library: Record, + type: string, + genre?: string, + limit = 20 +): Promise> { + const addons = await getAllStreamingAddons(); + const manifests = await stremioService.getInstalledAddonsAsync(); + const manifestMap = new Map(manifests.map(manifest => [manifest.id, manifest])); + const catalogPromises: Array> = []; + + for (const addon of addons) { + const matchingCatalogs = addon.catalogs.filter( + catalog => catalog.type === type && canBrowseCatalog(catalog) + ); + + for (const catalog of matchingCatalogs) { + const supportsGenre = catalog.extra?.some(extra => extra.name === 'genre') || + catalog.extraSupported?.includes('genre'); + + if (genre && !supportsGenre) { + continue; + } + + const manifest = manifestMap.get(addon.id); + if (!manifest) { + continue; + } + + catalogPromises.push( + (async () => { + try { + const filters = genre ? [{ title: 'genre', value: genre }] : []; + const metas = await stremioService.getCatalog(manifest, type, catalog.id, 1, filters); + + if (!metas || metas.length === 0) { + return null; + } + + return { + addonName: addon.name, + items: metas.slice(0, limit).map(meta => ({ + ...convertMetaToStreamingContent(meta, library), + addonId: addon.id, + })), + }; + } catch (error) { + logger.error(`Discover failed for ${catalog.id} in addon ${addon.id}:`, error); + return null; + } + })() + ); + } + } + + const addonMap = new Map(); + for (const result of await Promise.all(catalogPromises)) { + if (!result || result.items.length === 0) { + continue; + } + + const existingItems = addonMap.get(result.addonName) || []; + const existingIds = new Set(existingItems.map(item => `${item.type}:${item.id}`)); + const newItems = result.items.filter(item => !existingIds.has(`${item.type}:${item.id}`)); + addonMap.set(result.addonName, [...existingItems, ...newItems]); + } + + return Array.from(addonMap.entries()).map(([addonName, items]) => ({ + addonName, + items: items.slice(0, limit), + })); +} + +export async function discoverContentFromCatalog( + library: Record, + addonId: string, + catalogId: string, + type: string, + genre?: string, + page = 1 +): Promise { + try { + const manifests = await stremioService.getInstalledAddonsAsync(); + const manifest = manifests.find(currentManifest => currentManifest.id === addonId); + + if (!manifest) { + logger.error(`Addon ${addonId} not found`); + return []; + } + + const catalog = (manifest.catalogs || []).find(item => item.type === type && item.id === catalogId); + if (!catalog || !canBrowseCatalog(convertManifestToStreamingAddon(manifest).catalogs.find( + item => item.type === type && item.id === catalogId + ) || { ...catalog, extraSupported: catalog.extraSupported || [], extra: catalog.extra || [] })) { + logger.warn(`Catalog ${catalogId} in addon ${addonId} is not browseable`); + return []; + } + + const filters = genre ? [{ title: 'genre', value: genre }] : []; + const metas = await stremioService.getCatalog(manifest, type, catalogId, page, filters); + + return (metas || []).map(meta => ({ + ...convertMetaToStreamingContent(meta, library), + addonId, + })); + } catch (error) { + logger.error(`Discover from catalog failed for ${addonId}/${catalogId}:`, error); + return []; + } +} diff --git a/src/services/catalog/library.ts b/src/services/catalog/library.ts new file mode 100644 index 00000000..2751b396 --- /dev/null +++ b/src/services/catalog/library.ts @@ -0,0 +1,329 @@ +import { notificationService } from '../notificationService'; +import { mmkvStorage } from '../mmkvStorage'; +import { logger } from '../../utils/logger'; + +import type { StreamingContent } from './types'; + +export interface CatalogLibraryState { + LEGACY_LIBRARY_KEY: string; + RECENT_CONTENT_KEY: string; + MAX_RECENT_ITEMS: number; + library: Record; + recentContent: StreamingContent[]; + librarySubscribers: Array<(items: StreamingContent[]) => void>; + libraryAddListeners: Array<(item: StreamingContent) => void>; + libraryRemoveListeners: Array<(type: string, id: string) => void>; + initPromise: Promise; + isInitialized: boolean; +} + +export function createLibraryKey(type: string, id: string): string { + return `${type}:${id}`; +} + +export async function initializeCatalogState(state: CatalogLibraryState): Promise { + logger.log('[CatalogService] Starting initialization...'); + + try { + logger.log('[CatalogService] Step 1: Initializing scope...'); + await initializeScope(); + + logger.log('[CatalogService] Step 2: Loading library...'); + await loadLibrary(state); + + logger.log('[CatalogService] Step 3: Loading recent content...'); + await loadRecentContent(state); + + state.isInitialized = true; + logger.log( + `[CatalogService] Initialization completed successfully. Library contains ${Object.keys(state.library).length} items.` + ); + } catch (error) { + logger.error('[CatalogService] Initialization failed:', error); + state.isInitialized = true; + } +} + +export async function ensureCatalogInitialized(state: CatalogLibraryState): Promise { + logger.log(`[CatalogService] ensureInitialized() called. isInitialized: ${state.isInitialized}`); + + try { + await state.initPromise; + logger.log( + `[CatalogService] ensureInitialized() completed. Library ready with ${Object.keys(state.library).length} items.` + ); + } catch (error) { + logger.error('[CatalogService] Error waiting for initialization:', error); + } +} + +async function initializeScope(): Promise { + try { + const currentScope = await mmkvStorage.getItem('@user:current'); + + if (!currentScope) { + await mmkvStorage.setItem('@user:current', 'local'); + logger.log('[CatalogService] Initialized @user:current scope to "local"'); + return; + } + + logger.log(`[CatalogService] Using existing scope: "${currentScope}"`); + } catch (error) { + logger.error('[CatalogService] Failed to initialize scope:', error); + } +} + +async function loadLibrary(state: CatalogLibraryState): Promise { + try { + const scope = (await mmkvStorage.getItem('@user:current')) || 'local'; + const scopedKey = `@user:${scope}:stremio-library`; + let storedLibrary = await mmkvStorage.getItem(scopedKey); + + if (!storedLibrary) { + storedLibrary = await mmkvStorage.getItem(state.LEGACY_LIBRARY_KEY); + if (storedLibrary) { + await mmkvStorage.setItem(scopedKey, storedLibrary); + } + } + + if (storedLibrary) { + const parsedLibrary = JSON.parse(storedLibrary); + logger.log( + `[CatalogService] Raw library data type: ${Array.isArray(parsedLibrary) ? 'ARRAY' : 'OBJECT'}, keys: ${JSON.stringify(Object.keys(parsedLibrary).slice(0, 5))}` + ); + + if (Array.isArray(parsedLibrary)) { + logger.log('[CatalogService] WARNING: Library is stored as ARRAY format. Converting to OBJECT format.'); + const libraryObject: Record = {}; + + for (const item of parsedLibrary) { + libraryObject[createLibraryKey(item.type, item.id)] = item; + } + + state.library = libraryObject; + logger.log(`[CatalogService] Converted ${parsedLibrary.length} items from array to object format`); + + const normalizedLibrary = JSON.stringify(state.library); + await mmkvStorage.setItem(scopedKey, normalizedLibrary); + await mmkvStorage.setItem(state.LEGACY_LIBRARY_KEY, normalizedLibrary); + logger.log('[CatalogService] Re-saved library in correct format'); + } else { + state.library = parsedLibrary; + } + + logger.log( + `[CatalogService] Library loaded successfully with ${Object.keys(state.library).length} items from scope: ${scope}` + ); + } else { + logger.log(`[CatalogService] No library data found for scope: ${scope}`); + state.library = {}; + } + + await mmkvStorage.setItem('@user:current', scope); + } catch (error: any) { + logger.error('Failed to load library:', error); + state.library = {}; + } +} + +async function saveLibrary(state: CatalogLibraryState): Promise { + if (state.isInitialized) { + await ensureCatalogInitialized(state); + } + + try { + const itemCount = Object.keys(state.library).length; + const scope = (await mmkvStorage.getItem('@user:current')) || 'local'; + const scopedKey = `@user:${scope}:stremio-library`; + const libraryData = JSON.stringify(state.library); + + logger.log(`[CatalogService] Saving library with ${itemCount} items to scope: "${scope}" (key: ${scopedKey})`); + + await mmkvStorage.setItem(scopedKey, libraryData); + await mmkvStorage.setItem(state.LEGACY_LIBRARY_KEY, libraryData); + + logger.log(`[CatalogService] Library saved successfully with ${itemCount} items`); + } catch (error: any) { + logger.error('Failed to save library:', error); + logger.error( + `[CatalogService] Library save failed details - scope: ${(await mmkvStorage.getItem('@user:current')) || 'unknown'}, itemCount: ${Object.keys(state.library).length}` + ); + } +} + +async function loadRecentContent(state: CatalogLibraryState): Promise { + try { + const storedRecentContent = await mmkvStorage.getItem(state.RECENT_CONTENT_KEY); + if (storedRecentContent) { + state.recentContent = JSON.parse(storedRecentContent); + } + } catch (error: any) { + logger.error('Failed to load recent content:', error); + } +} + +async function saveRecentContent(state: CatalogLibraryState): Promise { + try { + await mmkvStorage.setItem(state.RECENT_CONTENT_KEY, JSON.stringify(state.recentContent)); + } catch (error: any) { + logger.error('Failed to save recent content:', error); + } +} + +function notifyLibrarySubscribers(state: CatalogLibraryState): void { + const items = Object.values(state.library); + state.librarySubscribers.forEach(callback => callback(items)); +} + +export async function getLibraryItems(state: CatalogLibraryState): Promise { + if (!state.isInitialized) { + await ensureCatalogInitialized(state); + } + + return Object.values(state.library); +} + +export function subscribeToLibraryUpdates( + state: CatalogLibraryState, + callback: (items: StreamingContent[]) => void +): () => void { + state.librarySubscribers.push(callback); + + Promise.resolve().then(() => { + getLibraryItems(state).then(items => { + if (state.librarySubscribers.includes(callback)) { + callback(items); + } + }); + }); + + return () => { + const index = state.librarySubscribers.indexOf(callback); + if (index > -1) { + state.librarySubscribers.splice(index, 1); + } + }; +} + +export function onLibraryAdd( + state: CatalogLibraryState, + listener: (item: StreamingContent) => void +): () => void { + state.libraryAddListeners.push(listener); + + return () => { + state.libraryAddListeners = state.libraryAddListeners.filter(currentListener => currentListener !== listener); + }; +} + +export function onLibraryRemove( + state: CatalogLibraryState, + listener: (type: string, id: string) => void +): () => void { + state.libraryRemoveListeners.push(listener); + + return () => { + state.libraryRemoveListeners = state.libraryRemoveListeners.filter( + currentListener => currentListener !== listener + ); + }; +} + +export async function addToLibrary(state: CatalogLibraryState, content: StreamingContent): Promise { + logger.log(`[CatalogService] addToLibrary() called for: ${content.type}:${content.id} (${content.name})`); + + await ensureCatalogInitialized(state); + + const key = createLibraryKey(content.type, content.id); + const itemCountBefore = Object.keys(state.library).length; + logger.log(`[CatalogService] Adding to library with key: "${key}". Current library keys: [${Object.keys(state.library).length}] items`); + + state.library[key] = { + ...content, + addedToLibraryAt: Date.now(), + }; + + const itemCountAfter = Object.keys(state.library).length; + logger.log( + `[CatalogService] Library updated: ${itemCountBefore} -> ${itemCountAfter} items. New library keys: [${Object.keys(state.library).slice(0, 5).join(', ')}${Object.keys(state.library).length > 5 ? '...' : ''}]` + ); + + await saveLibrary(state); + logger.log(`[CatalogService] addToLibrary() completed for: ${content.type}:${content.id}`); + + notifyLibrarySubscribers(state); + + try { + state.libraryAddListeners.forEach(listener => listener(content)); + } catch {} + + if (content.type === 'series') { + try { + await notificationService.updateNotificationsForSeries(content.id); + console.log(`[CatalogService] Auto-setup notifications for series: ${content.name}`); + } catch (error) { + console.error(`[CatalogService] Failed to setup notifications for ${content.name}:`, error); + } + } +} + +export async function removeFromLibrary( + state: CatalogLibraryState, + type: string, + id: string +): Promise { + logger.log(`[CatalogService] removeFromLibrary() called for: ${type}:${id}`); + + await ensureCatalogInitialized(state); + + const key = createLibraryKey(type, id); + const itemCountBefore = Object.keys(state.library).length; + const itemExisted = key in state.library; + logger.log( + `[CatalogService] Removing key: "${key}". Currently library has ${itemCountBefore} items with keys: [${Object.keys(state.library).slice(0, 5).join(', ')}${Object.keys(state.library).length > 5 ? '...' : ''}]` + ); + + delete state.library[key]; + + const itemCountAfter = Object.keys(state.library).length; + logger.log(`[CatalogService] Library updated: ${itemCountBefore} -> ${itemCountAfter} items (existed: ${itemExisted})`); + + await saveLibrary(state); + logger.log(`[CatalogService] removeFromLibrary() completed for: ${type}:${id}`); + + notifyLibrarySubscribers(state); + + try { + state.libraryRemoveListeners.forEach(listener => listener(type, id)); + } catch {} + + if (type === 'series') { + try { + const scheduledNotifications = notificationService.getScheduledNotifications(); + const seriesToCancel = scheduledNotifications.filter(notification => notification.seriesId === id); + + for (const notification of seriesToCancel) { + await notificationService.cancelNotification(notification.id); + } + + console.log(`[CatalogService] Cancelled ${seriesToCancel.length} notifications for removed series: ${id}`); + } catch (error) { + console.error(`[CatalogService] Failed to cancel notifications for removed series ${id}:`, error); + } + } +} + +export function addToRecentContent(state: CatalogLibraryState, content: StreamingContent): void { + state.recentContent = state.recentContent.filter(item => !(item.id === content.id && item.type === content.type)); + state.recentContent.unshift(content); + + if (state.recentContent.length > state.MAX_RECENT_ITEMS) { + state.recentContent = state.recentContent.slice(0, state.MAX_RECENT_ITEMS); + } + + void saveRecentContent(state); +} + +export function getRecentContent(state: CatalogLibraryState): StreamingContent[] { + return state.recentContent; +} diff --git a/src/services/catalog/search.ts b/src/services/catalog/search.ts new file mode 100644 index 00000000..b20a3af0 --- /dev/null +++ b/src/services/catalog/search.ts @@ -0,0 +1,401 @@ +import axios from 'axios'; + +import { stremioService, type Manifest } from '../stremioService'; +import { logger } from '../../utils/logger'; +import { createSafeAxiosConfig } from '../../utils/axiosConfig'; + +import { canSearchCatalog, getAllAddons } from './catalog-utils'; +import { convertMetaToStreamingContent } from './content-mappers'; +import type { AddonSearchResults, GroupedSearchResults, StreamingContent } from './types'; + +type PendingSection = { + addonId: string; + addonName: string; + sectionName: string; + catalogIndex: number; + results: StreamingContent[]; +}; + +export async function searchContent( + library: Record, + query: string +): Promise { + if (!query || query.trim().length < 2) { + return []; + } + + const addons = await getAllAddons(() => stremioService.getInstalledAddonsAsync()); + const manifests = await stremioService.getInstalledAddonsAsync(); + const manifestMap = new Map(manifests.map(manifest => [manifest.id, manifest])); + const results: StreamingContent[] = []; + + await Promise.all( + addons.flatMap(addon => + (addon.catalogs || []) + .filter(catalog => canSearchCatalog(catalog)) + .map(async catalog => { + const manifest = manifestMap.get(addon.id); + if (!manifest) { + return; + } + + try { + const metas = await stremioService.getCatalog( + manifest, + catalog.type, + catalog.id, + 1, + [{ title: 'search', value: query }] + ); + + if (metas?.length) { + results.push( + ...metas.map(meta => ({ + ...convertMetaToStreamingContent(meta, library), + addonId: addon.id, + })) + ); + } + } catch (error) { + logger.error(`Search failed for ${catalog.id} in addon ${addon.id}:`, error); + } + }) + ) + ); + + return Array.from(new Map(results.map(item => [`${item.type}:${item.id}`, item])).values()); +} + +export async function searchContentCinemeta( + library: Record, + query: string +): Promise { + if (!query) { + return { byAddon: [], allResults: [] }; + } + + const trimmedQuery = query.trim().toLowerCase(); + logger.log('Searching across all addons for:', trimmedQuery); + + const addons = await getAllAddons(() => stremioService.getInstalledAddonsAsync()); + const manifests = await stremioService.getInstalledAddonsAsync(); + const manifestMap = new Map(manifests.map(manifest => [manifest.id, manifest])); + const searchableAddons = addons.filter(addon => addon.catalogs.some(catalog => canSearchCatalog(catalog))); + const byAddon: AddonSearchResults[] = []; + + logger.log(`Found ${searchableAddons.length} searchable addons:`, searchableAddons.map(addon => addon.name).join(', ')); + + for (const [addonIndex, addon] of searchableAddons.entries()) { + const manifest = manifestMap.get(addon.id); + if (!manifest) { + logger.warn(`Manifest not found for addon ${addon.name} (${addon.id})`); + continue; + } + + const catalogResults = await Promise.allSettled( + addon.catalogs + .filter(catalog => canSearchCatalog(catalog)) + .map(catalog => searchAddonCatalog(library, manifest, catalog.type, catalog.id, trimmedQuery)) + ); + + const addonResults: StreamingContent[] = []; + for (const result of catalogResults) { + if (result.status === 'fulfilled' && result.value) { + addonResults.push(...result.value); + } else if (result.status === 'rejected') { + logger.error(`Search failed for ${addon.name}:`, result.reason); + } + } + + if (addonResults.length > 0) { + const seen = new Set(); + byAddon.push({ + addonId: addon.id, + addonName: addon.name, + sectionName: addon.name, + catalogIndex: addonIndex, + results: addonResults.filter(item => { + const key = `${item.type}:${item.id}`; + if (seen.has(key)) { + return false; + } + + seen.add(key); + return true; + }), + }); + } + } + + const allResults: StreamingContent[] = []; + const globalSeen = new Set(); + + for (const addonGroup of byAddon) { + for (const item of addonGroup.results) { + const key = `${item.type}:${item.id}`; + if (!globalSeen.has(key)) { + globalSeen.add(key); + allResults.push(item); + } + } + } + + logger.log(`Search complete: ${byAddon.length} addons returned results, ${allResults.length} unique items total`); + return { byAddon, allResults }; +} + +export function startLiveSearch( + library: Record, + query: string, + onAddonResults: (section: AddonSearchResults) => void +): { cancel: () => void; done: Promise } { + const controller = { cancelled: false }; + + const done = (async () => { + if (!query || !query.trim()) { + return; + } + + const trimmedQuery = query.trim().toLowerCase(); + logger.log('Live search across addons for:', trimmedQuery); + + const addons = await getAllAddons(() => stremioService.getInstalledAddonsAsync()); + logger.log(`Total addons available: ${addons.length}`); + + const manifests = await stremioService.getInstalledAddonsAsync(); + const manifestMap = new Map(manifests.map(manifest => [manifest.id, manifest])); + const searchableAddons = addons.filter(addon => + (addon.catalogs || []).some(catalog => canSearchCatalog(catalog)) + ); + + logger.log( + `Found ${searchableAddons.length} searchable addons:`, + searchableAddons.map(addon => `${addon.name} (${addon.id})`).join(', ') + ); + + if (searchableAddons.length === 0) { + logger.warn('No searchable addons found. Make sure you have addons installed that support search functionality.'); + return; + } + + const addonOrderRef: Record = {}; + searchableAddons.forEach((addon, index) => { + addonOrderRef[addon.id] = index; + }); + + const catalogTypeLabels: Record = { + movie: 'Movies', + series: 'TV Shows', + 'anime.series': 'Anime Series', + 'anime.movie': 'Anime Movies', + other: 'Other', + tv: 'TV', + channel: 'Channels', + }; + const genericCatalogNames = new Set(['search', 'Search']); + const allPendingSections: PendingSection[] = []; + + await Promise.all( + searchableAddons.map(async addon => { + if (controller.cancelled) { + return; + } + + try { + const manifest = manifestMap.get(addon.id); + if (!manifest) { + logger.warn(`Manifest not found for addon ${addon.name} (${addon.id})`); + return; + } + + const searchableCatalogs = (addon.catalogs || []).filter(catalog => canSearchCatalog(catalog)); + logger.log(`Searching ${addon.name} (${addon.id}) with ${searchableCatalogs.length} searchable catalogs`); + + const settled = await Promise.allSettled( + searchableCatalogs.map(catalog => + searchAddonCatalog(library, manifest, catalog.type, catalog.id, trimmedQuery) + ) + ); + + if (controller.cancelled) { + return; + } + + const addonRank = addonOrderRef[addon.id] ?? Number.MAX_SAFE_INTEGER; + if (searchableCatalogs.length > 1) { + searchableCatalogs.forEach((catalog, index) => { + const result = settled[index]; + if (result.status === 'rejected' || !result.value?.length) { + if (result.status === 'rejected') { + logger.warn(`Search failed for ${catalog.id} in ${addon.name}:`, result.reason); + } + return; + } + + const sectionName = buildSectionName( + addon.name, + catalog.name, + catalog.type, + genericCatalogNames, + catalogTypeLabels + ); + + allPendingSections.push({ + addonId: `${addon.id}||${catalog.type}||${catalog.id}`, + addonName: addon.name, + sectionName, + catalogIndex: addonRank * 1000 + index, + results: dedupeAndStampResults(result.value, catalog.type), + }); + }); + return; + } + + const result = settled[0]; + const catalog = searchableCatalogs[0]; + if (!result || result.status === 'rejected' || !result.value?.length) { + if (result?.status === 'rejected') { + logger.warn(`Search failed for ${addon.name}:`, result.reason); + } + return; + } + + allPendingSections.push({ + addonId: addon.id, + addonName: addon.name, + sectionName: addon.name, + catalogIndex: addonRank * 1000, + results: dedupeAndStampResults(result.value, catalog.type), + }); + } catch (error) { + logger.error(`Error searching addon ${addon.name} (${addon.id}):`, error); + } + }) + ); + + if (controller.cancelled) { + return; + } + + allPendingSections.sort((left, right) => left.catalogIndex - right.catalogIndex); + for (const section of allPendingSections) { + if (controller.cancelled) { + return; + } + + if (section.results.length > 0) { + logger.log(`Emitting ${section.results.length} results from ${section.sectionName}`); + onAddonResults(section); + } + } + })(); + + return { + cancel: () => { + controller.cancelled = true; + }, + done, + }; +} + +async function searchAddonCatalog( + library: Record, + manifest: Manifest, + type: string, + catalogId: string, + query: string +): Promise { + try { + const url = buildSearchUrl(manifest, type, catalogId, query); + if (!url) { + return []; + } + + logger.log(`Searching ${manifest.name} (${type}/${catalogId}):`, url); + const response = await axios.get<{ metas: any[] }>(url, createSafeAxiosConfig(10000)); + const metas = response.data?.metas || []; + + if (metas.length === 0) { + return []; + } + + const items = metas.map(meta => { + const content = convertMetaToStreamingContent(meta, library); + content.addonId = manifest.id; + if (type && content.type !== type) { + content.type = type; + } + return content; + }); + + logger.log(`Found ${items.length} results from ${manifest.name}`); + return items; + } catch (error: any) { + const errorMessage = error?.response?.status + ? `HTTP ${error.response.status}` + : error?.message || 'Unknown error'; + const errorUrl = error?.config?.url || 'unknown URL'; + logger.error(`Search failed for ${manifest.name} (${type}/${catalogId}) at ${errorUrl}: ${errorMessage}`); + if (error?.response?.data) { + logger.error('Response data:', error.response.data); + } + return []; + } +} + +function buildSearchUrl(manifest: Manifest, type: string, catalogId: string, query: string): string | null { + if (manifest.id === 'com.linvo.cinemeta') { + return `https://v3-cinemeta.strem.io/catalog/${type}/${encodeURIComponent(catalogId)}/search=${encodeURIComponent(query)}.json`; + } + + const chosenUrl = manifest.url || manifest.originalUrl; + if (!chosenUrl) { + logger.warn(`Addon ${manifest.name} (${manifest.id}) has no URL, skipping search`); + return null; + } + + const [baseUrlPart, queryParams] = chosenUrl.split('?'); + let cleanBaseUrl = baseUrlPart.replace(/manifest\.json$/, '').replace(/\/$/, ''); + if (!cleanBaseUrl.startsWith('http')) { + cleanBaseUrl = `https://${cleanBaseUrl}`; + } + + let url = `${cleanBaseUrl}/catalog/${type}/${encodeURIComponent(catalogId)}/search=${encodeURIComponent(query)}.json`; + if (queryParams) { + url += `?${queryParams}`; + } + + return url; +} + +function dedupeAndStampResults(results: StreamingContent[], catalogType: string): StreamingContent[] { + const bestById = new Map(); + + for (const item of results) { + const existing = bestById.get(item.id); + if (!existing || (!existing.type.includes('.') && item.type.includes('.'))) { + bestById.set(item.id, item); + } + } + + return Array.from(bestById.values()).map(item => + catalogType && item.type !== catalogType ? { ...item, type: catalogType } : item + ); +} + +function buildSectionName( + addonName: string, + catalogName: string | undefined, + catalogType: string, + genericCatalogNames: Set, + catalogTypeLabels: Record +): string { + const typeLabel = catalogTypeLabels[catalogType] || + catalogType.replace(/[._]/g, ' ').replace(/\b\w/g, char => char.toUpperCase()); + + const catalogLabel = (!catalogName || genericCatalogNames.has(catalogName) || catalogName === addonName) + ? typeLabel + : catalogName; + + return `${addonName} - ${catalogLabel}`; +} diff --git a/src/services/catalog/types.ts b/src/services/catalog/types.ts new file mode 100644 index 00000000..42a2b21a --- /dev/null +++ b/src/services/catalog/types.ts @@ -0,0 +1,154 @@ +export const DATA_SOURCE_KEY = 'discover_data_source'; + +export enum DataSource { + STREMIO_ADDONS = 'stremio_addons', + TMDB = 'tmdb', +} + +export interface StreamingCatalogExtra { + name: string; + isRequired?: boolean; + options?: string[]; + optionsLimit?: number; +} + +export interface StreamingCatalog { + type: string; + id: string; + name: string; + extraSupported?: string[]; + extra?: StreamingCatalogExtra[]; + showInHome?: boolean; +} + +export interface StreamingAddon { + id: string; + name: string; + version: string; + description: string; + types: string[]; + catalogs: StreamingCatalog[]; + resources: { + name: string; + types: string[]; + idPrefixes?: string[]; + }[]; + url?: string; + originalUrl?: string; + transportUrl?: string; + transportName?: string; +} + +export interface StreamingContent { + id: string; + type: string; + name: string; + tmdbId?: number; + poster: string; + posterShape?: 'poster' | 'square' | 'landscape'; + banner?: string; + logo?: string; + imdbRating?: string; + year?: number; + genres?: string[]; + description?: string; + runtime?: string; + released?: string; + trailerStreams?: any[]; + videos?: any[]; + inLibrary?: boolean; + directors?: string[]; + creators?: string[]; + certification?: string; + country?: string; + writer?: string[]; + links?: Array<{ + name: string; + category: string; + url: string; + }>; + behaviorHints?: { + defaultVideoId?: string; + hasScheduledVideos?: boolean; + [key: string]: any; + }; + imdb_id?: string; + mal_id?: number; + external_ids?: { + mal_id?: number; + imdb_id?: string; + tmdb_id?: number; + tvdb_id?: number; + }; + slug?: string; + releaseInfo?: string; + traktSource?: 'watchlist' | 'continue-watching' | 'watched'; + addonCast?: Array<{ + id: number; + name: string; + character: string; + profile_path: string | null; + }>; + networks?: Array<{ + id: number | string; + name: string; + logo?: string; + }>; + tvDetails?: { + status?: string; + firstAirDate?: string; + lastAirDate?: string; + numberOfSeasons?: number; + numberOfEpisodes?: number; + episodeRunTime?: number[]; + type?: string; + originCountry?: string[]; + originalLanguage?: string; + createdBy?: Array<{ + id: number; + name: string; + profile_path?: string; + }>; + }; + movieDetails?: { + status?: string; + releaseDate?: string; + runtime?: number; + budget?: number; + revenue?: number; + originalLanguage?: string; + originCountry?: string[]; + tagline?: string; + }; + collection?: { + id: number; + name: string; + poster_path?: string; + backdrop_path?: string; + }; + addedToLibraryAt?: number; + addonId?: string; +} + +export interface AddonSearchResults { + addonId: string; + addonName: string; + sectionName: string; + catalogIndex: number; + results: StreamingContent[]; +} + +export interface GroupedSearchResults { + byAddon: AddonSearchResults[]; + allResults: StreamingContent[]; +} + +export interface CatalogContent { + addon: string; + type: string; + id: string; + name: string; + originalName?: string; + genre?: string; + items: StreamingContent[]; +} diff --git a/src/services/catalogService.ts b/src/services/catalogService.ts index e27b26be..2cfce314 100644 --- a/src/services/catalogService.ts +++ b/src/services/catalogService.ts @@ -1,1836 +1,202 @@ -import { stremioService, Meta, Manifest } from './stremioService'; -import { notificationService } from './notificationService'; -import { mmkvStorage } from './mmkvStorage'; -import axios from 'axios'; -import { TMDBService } from './tmdbService'; -import { logger } from '../utils/logger'; -import { getCatalogDisplayName } from '../utils/catalogNameUtils'; -import { createSafeAxiosConfig } from '../utils/axiosConfig'; +import { + getBasicContentDetails, + getContentDetails, + getDataSourcePreference, + getEnhancedContentDetails, + getStremioId, + setDataSourcePreference, +} from './catalog/content-details'; +import { + discoverContent, + discoverContentFromCatalog, + fetchHomeCatalog, + getAllStreamingAddons, + getCatalogByType, + getDiscoverFilters, + getHomeCatalogs, + resolveHomeCatalogsToFetch, +} from './catalog/discovery'; +import { + addToLibrary, + ensureCatalogInitialized, + getLibraryItems, + getRecentContent, + initializeCatalogState, + onLibraryAdd, + onLibraryRemove, + removeFromLibrary, + subscribeToLibraryUpdates, +} from './catalog/library'; +import { + searchContent, + searchContentCinemeta, + startLiveSearch, +} from './catalog/search'; +import type { CatalogLibraryState } from './catalog/library'; +import type { + AddonSearchResults, + CatalogContent, + DataSource, + GroupedSearchResults, + StreamingAddon, + StreamingCatalog, + StreamingContent, +} from './catalog/types'; -// Add a constant for storing the data source preference -const DATA_SOURCE_KEY = 'discover_data_source'; +export { DataSource } from './catalog/types'; +export type { + AddonSearchResults, + CatalogContent, + GroupedSearchResults, + StreamingAddon, + StreamingContent, +} from './catalog/types'; -// Define data source types -export enum DataSource { - STREMIO_ADDONS = 'stremio_addons', - TMDB = 'tmdb', -} - -interface StreamingCatalogExtra { - name: string; - isRequired?: boolean; - options?: string[]; - optionsLimit?: number; -} - -interface StreamingCatalog { - type: string; - id: string; - name: string; - extraSupported?: string[]; - extra?: StreamingCatalogExtra[]; -} - -export interface StreamingAddon { - id: string; - name: string; - version: string; - description: string; - types: string[]; - catalogs: StreamingCatalog[]; - resources: { - name: string; - types: string[]; - idPrefixes?: string[]; - }[]; - url?: string; // preferred base URL (manifest or original) - originalUrl?: string; // original addon URL if provided - transportUrl?: string; - transportName?: string; -} - -export interface AddonSearchResults { - addonId: string; - addonName: string; - sectionName: string; // Display name — catalog name for named catalogs, addon name otherwise - catalogIndex: number; // Position in addon manifest — used for deterministic sort within same addon - results: StreamingContent[]; -} - -export interface GroupedSearchResults { - byAddon: AddonSearchResults[]; - allResults: StreamingContent[]; // Deduplicated flat list for backwards compatibility -} - -export interface StreamingContent { - id: string; - type: string; - name: string; - tmdbId?: number; - poster: string; - posterShape?: 'poster' | 'square' | 'landscape'; - banner?: string; - logo?: string; - imdbRating?: string; - year?: number; - genres?: string[]; - description?: string; - runtime?: string; - released?: string; - trailerStreams?: any[]; - videos?: any[]; - inLibrary?: boolean; - directors?: string[]; - creators?: string[]; - certification?: string; - // Enhanced metadata from addons - country?: string; - writer?: string[]; - links?: Array<{ - name: string; - category: string; - url: string; - }>; - behaviorHints?: { - defaultVideoId?: string; - hasScheduledVideos?: boolean; - [key: string]: any; - }; - imdb_id?: string; - mal_id?: number; - external_ids?: { - mal_id?: number; - imdb_id?: string; - tmdb_id?: number; - tvdb_id?: number; - }; - slug?: string; - releaseInfo?: string; - traktSource?: 'watchlist' | 'continue-watching' | 'watched'; - addonCast?: Array<{ - id: number; - name: string; - character: string; - profile_path: string | null; - }>; - networks?: Array<{ - id: number | string; - name: string; - logo?: string; - }>; - tvDetails?: { - status?: string; - firstAirDate?: string; - lastAirDate?: string; - numberOfSeasons?: number; - numberOfEpisodes?: number; - episodeRunTime?: number[]; - type?: string; - originCountry?: string[]; - originalLanguage?: string; - createdBy?: Array<{ - id: number; - name: string; - profile_path?: string; - }>; - }; - movieDetails?: { - status?: string; - releaseDate?: string; - runtime?: number; - budget?: number; - revenue?: number; - originalLanguage?: string; - originCountry?: string[]; - tagline?: string; - }; - collection?: { - id: number; - name: string; - poster_path?: string; - backdrop_path?: string; - }; - addedToLibraryAt?: number; // Timestamp when added to library - addonId?: string; // ID of the addon that provided this content -} - -export interface CatalogContent { - addon: string; - type: string; - id: string; - name: string; - originalName?: string; - genre?: string; - items: StreamingContent[]; -} - -const CATALOG_SETTINGS_KEY = 'catalog_settings'; - -class CatalogService { +class CatalogService implements CatalogLibraryState { private static instance: CatalogService; - private readonly LEGACY_LIBRARY_KEY = 'stremio-library'; - private readonly RECENT_CONTENT_KEY = 'stremio-recent-content'; - private library: Record = {}; - private recentContent: StreamingContent[] = []; - private readonly MAX_RECENT_ITEMS = 20; - private librarySubscribers: ((items: StreamingContent[]) => void)[] = []; - private libraryAddListeners: ((item: StreamingContent) => void)[] = []; - private libraryRemoveListeners: ((type: string, id: string) => void)[] = []; - private initPromise: Promise; - private isInitialized: boolean = false; + + readonly LEGACY_LIBRARY_KEY = 'stremio-library'; + readonly RECENT_CONTENT_KEY = 'stremio-recent-content'; + readonly MAX_RECENT_ITEMS = 20; + + library: Record = {}; + recentContent: StreamingContent[] = []; + librarySubscribers: Array<(items: StreamingContent[]) => void> = []; + libraryAddListeners: Array<(item: StreamingContent) => void> = []; + libraryRemoveListeners: Array<(type: string, id: string) => void> = []; + initPromise: Promise; + isInitialized = false; private constructor() { - this.initPromise = this.initialize(); - } - - private async initialize(): Promise { - logger.log('[CatalogService] Starting initialization...'); - try { - logger.log('[CatalogService] Step 1: Initializing scope...'); - await this.initializeScope(); - logger.log('[CatalogService] Step 2: Loading library...'); - await this.loadLibrary(); - logger.log('[CatalogService] Step 3: Loading recent content...'); - await this.loadRecentContent(); - this.isInitialized = true; - logger.log(`[CatalogService] Initialization completed successfully. Library contains ${Object.keys(this.library).length} items.`); - } catch (error) { - logger.error('[CatalogService] Initialization failed:', error); - // Still mark as initialized to prevent blocking forever - this.isInitialized = true; - } - } - - private async ensureInitialized(): Promise { - logger.log(`[CatalogService] ensureInitialized() called. isInitialized: ${this.isInitialized}`); - try { - await this.initPromise; - logger.log(`[CatalogService] ensureInitialized() completed. Library ready with ${Object.keys(this.library).length} items.`); - } catch (error) { - logger.error('[CatalogService] Error waiting for initialization:', error); - } - } - - private async initializeScope(): Promise { - try { - const currentScope = await mmkvStorage.getItem('@user:current'); - if (!currentScope) { - await mmkvStorage.setItem('@user:current', 'local'); - logger.log('[CatalogService] Initialized @user:current scope to "local"'); - } else { - logger.log(`[CatalogService] Using existing scope: "${currentScope}"`); - } - } catch (error) { - logger.error('[CatalogService] Failed to initialize scope:', error); - } + this.initPromise = initializeCatalogState(this); } static getInstance(): CatalogService { if (!CatalogService.instance) { CatalogService.instance = new CatalogService(); } + return CatalogService.instance; } - private async loadLibrary(): Promise { - try { - const scope = (await mmkvStorage.getItem('@user:current')) || 'local'; - const scopedKey = `@user:${scope}:stremio-library`; - let storedLibrary = (await mmkvStorage.getItem(scopedKey)); - if (!storedLibrary) { - // Fallback: read legacy and migrate into scoped - storedLibrary = await mmkvStorage.getItem(this.LEGACY_LIBRARY_KEY); - if (storedLibrary) { - await mmkvStorage.setItem(scopedKey, storedLibrary); - } - } - if (storedLibrary) { - const parsedLibrary = JSON.parse(storedLibrary); - logger.log(`[CatalogService] Raw library data type: ${Array.isArray(parsedLibrary) ? 'ARRAY' : 'OBJECT'}, keys: ${JSON.stringify(Object.keys(parsedLibrary).slice(0, 5))}`); - - // Convert array format to object format if needed - if (Array.isArray(parsedLibrary)) { - logger.log(`[CatalogService] WARNING: Library is stored as ARRAY format. Converting to OBJECT format.`); - const libraryObject: Record = {}; - for (const item of parsedLibrary) { - const key = `${item.type}:${item.id}`; - libraryObject[key] = item; - } - this.library = libraryObject; - logger.log(`[CatalogService] Converted ${parsedLibrary.length} items from array to object format`); - // Re-save in correct format (don't call ensureInitialized here since we're still initializing) - const scope = (await mmkvStorage.getItem('@user:current')) || 'local'; - const scopedKey = `@user:${scope}:stremio-library`; - const libraryData = JSON.stringify(this.library); - await mmkvStorage.setItem(scopedKey, libraryData); - await mmkvStorage.setItem(this.LEGACY_LIBRARY_KEY, libraryData); - logger.log(`[CatalogService] Re-saved library in correct format`); - } else { - this.library = parsedLibrary; - } - logger.log(`[CatalogService] Library loaded successfully with ${Object.keys(this.library).length} items from scope: ${scope}`); - } else { - logger.log(`[CatalogService] No library data found for scope: ${scope}`); - this.library = {}; - } - // Ensure @user:current is set to prevent future scope issues - await mmkvStorage.setItem('@user:current', scope); - } catch (error: any) { - logger.error('Failed to load library:', error); - this.library = {}; - } - } - - private async saveLibrary(): Promise { - // Only wait for initialization if we're not already initializing (avoid circular dependency) - if (this.isInitialized) { - await this.ensureInitialized(); - } - try { - const itemCount = Object.keys(this.library).length; - const scope = (await mmkvStorage.getItem('@user:current')) || 'local'; - const scopedKey = `@user:${scope}:stremio-library`; - const libraryData = JSON.stringify(this.library); - - logger.log(`[CatalogService] Saving library with ${itemCount} items to scope: "${scope}" (key: ${scopedKey})`); - - await mmkvStorage.setItem(scopedKey, libraryData); - await mmkvStorage.setItem(this.LEGACY_LIBRARY_KEY, libraryData); - - logger.log(`[CatalogService] Library saved successfully with ${itemCount} items`); - } catch (error: any) { - logger.error('Failed to save library:', error); - logger.error(`[CatalogService] Library save failed details - scope: ${(await mmkvStorage.getItem('@user:current')) || 'unknown'}, itemCount: ${Object.keys(this.library).length}`); - } - } - - private async loadRecentContent(): Promise { - try { - const storedRecentContent = await mmkvStorage.getItem(this.RECENT_CONTENT_KEY); - if (storedRecentContent) { - this.recentContent = JSON.parse(storedRecentContent); - } - } catch (error: any) { - logger.error('Failed to load recent content:', error); - } - } - - private async saveRecentContent(): Promise { - try { - await mmkvStorage.setItem(this.RECENT_CONTENT_KEY, JSON.stringify(this.recentContent)); - } catch (error: any) { - logger.error('Failed to save recent content:', error); - } + private async ensureInitialized(): Promise { + await ensureCatalogInitialized(this); } async getAllAddons(): Promise { - const addons = await stremioService.getInstalledAddonsAsync(); - return addons.map(addon => this.convertManifestToStreamingAddon(addon)); + return getAllStreamingAddons(); } - private convertManifestToStreamingAddon(manifest: Manifest): StreamingAddon { - return { - id: manifest.id, - name: manifest.name, - version: manifest.version, - description: manifest.description, - types: manifest.types || [], - catalogs: (manifest.catalogs || []).map(catalog => ({ - ...catalog, - extraSupported: catalog.extraSupported || [], - extra: (catalog.extra || []).map(extra => ({ - name: extra.name, - isRequired: extra.isRequired, - options: extra.options, - optionsLimit: extra.optionsLimit, - })), - })), - resources: manifest.resources || [], - url: (manifest.url || manifest.originalUrl) as any, - originalUrl: (manifest.originalUrl || manifest.url) as any, - transportUrl: manifest.url, - transportName: manifest.name - }; + async resolveHomeCatalogsToFetch(limitIds?: string[]) { + return resolveHomeCatalogsToFetch(limitIds); } - private catalogSupportsExtra(catalog: StreamingCatalog, extraName: string): boolean { - return (catalog.extraSupported || []).includes(extraName) || - (catalog.extra || []).some(extra => extra.name === extraName); - } - - private getRequiredCatalogExtras(catalog: StreamingCatalog): string[] { - return (catalog.extra || []) - .filter(extra => extra.isRequired) - .map(extra => extra.name); - } - - private canBrowseCatalog(catalog: StreamingCatalog): boolean { - // Exclude search-only catalogs from discover browsing - if ( - (catalog.id && catalog.id.startsWith('search.')) || - (catalog.type && catalog.type.startsWith('search')) - ) { - return false; - } - const requiredExtras = this.getRequiredCatalogExtras(catalog); - return requiredExtras.every(extraName => extraName === 'genre'); - } - - /** - * Whether a catalog should appear on the home screen, based purely on the - * addon manifest — no user settings / mmkv involved. - * - * Rules (in order): - * 1. Search catalogs (id/type starts with "search") → never on home - * 2. Catalogs with any required extra (including required genre) → never on home - * 3. Addon uses showInHome flag on at least one catalog: - * → only catalogs with showInHome:true appear on home - * 4. No showInHome flag on any catalog → all browseable catalogs appear on home - */ - private isVisibleOnHome(catalog: StreamingCatalog, addonCatalogs: StreamingCatalog[]): boolean { - // Rule 1: never show search catalogs - if ( - (catalog.id && catalog.id.startsWith('search.')) || - (catalog.type && catalog.type.startsWith('search')) - ) { - return false; - } - - // Rule 2: never show catalogs with any required extra (e.g. required genre, calendarVideosIds) - const requiredExtras = this.getRequiredCatalogExtras(catalog); - if (requiredExtras.length > 0) { - return false; - } - - // Rule 3: respect showInHome if the addon uses it on any catalog - const addonUsesShowInHome = addonCatalogs.some((c: any) => c.showInHome === true); - if (addonUsesShowInHome) { - return (catalog as any).showInHome === true; - } - - // Rule 4: no showInHome flag used — show all browseable catalogs - return true; - } - - private canSearchCatalog(catalog: StreamingCatalog): boolean { - if (!this.catalogSupportsExtra(catalog, 'search')) { - return false; - } - - const requiredExtras = this.getRequiredCatalogExtras(catalog); - return requiredExtras.every(extraName => extraName === 'search'); - } - - async resolveHomeCatalogsToFetch(limitIds?: string[]): Promise<{ addon: StreamingAddon; catalog: any }[]> { - const addons = await this.getAllAddons(); - - // Collect catalogs visible on home using manifest-only rules (no mmkv/user settings) - const potentialCatalogs: { addon: StreamingAddon; catalog: any }[] = []; - - for (const addon of addons) { - if (addon.catalogs) { - for (const catalog of addon.catalogs) { - if (this.isVisibleOnHome(catalog, addon.catalogs)) { - potentialCatalogs.push({ addon, catalog }); - } - } - } - } - - // Determine which catalogs to actually fetch - let catalogsToFetch: { addon: StreamingAddon; catalog: any }[] = []; - - if (limitIds && limitIds.length > 0) { - // User selected specific catalogs - strict filtering - catalogsToFetch = potentialCatalogs.filter(item => { - const catalogId = `${item.addon.id}:${item.catalog.type}:${item.catalog.id}`; - return limitIds.includes(catalogId); - }); - } else { - // "All" mode - Smart Sample: Pick 5 random catalogs to avoid waterfall - catalogsToFetch = potentialCatalogs.sort(() => 0.5 - Math.random()).slice(0, 5); - } - - return catalogsToFetch; - } - - async fetchHomeCatalog(addon: StreamingAddon, catalog: any): Promise { - try { - // Hoist manifest list retrieval and find once - const addonManifests = await stremioService.getInstalledAddonsAsync(); - const manifest = addonManifests.find(a => a.id === addon.id); - if (!manifest) return null; - - const metas = await stremioService.getCatalog(manifest, catalog.type, catalog.id, 1); - if (metas && metas.length > 0) { - // Cap items per catalog to reduce memory and rendering load - const limited = metas.slice(0, 12); - const items = limited.map(meta => this.convertMetaToStreamingContent(meta)); - - // Get potentially custom display name; if customized, respect it as-is - const originalName = catalog.name || catalog.id; - let displayName = await getCatalogDisplayName(addon.id, catalog.type, catalog.id, originalName); - const isCustom = displayName !== originalName; - - if (!isCustom) { - // Remove duplicate words and clean up the name (case-insensitive) - const words = displayName.split(' '); - const uniqueWords: string[] = []; - const seenWords = new Set(); - for (const word of words) { - const lowerWord = word.toLowerCase(); - if (!seenWords.has(lowerWord)) { - uniqueWords.push(word); - seenWords.add(lowerWord); - } - } - displayName = uniqueWords.join(' '); - - // Add content type if not present - const contentType = catalog.type === 'movie' ? 'Movies' : 'TV Shows'; - if (!displayName.toLowerCase().includes(contentType.toLowerCase())) { - displayName = `${displayName} ${contentType}`; - } - } - - return { - addon: addon.id, - type: catalog.type, - id: catalog.id, - name: displayName, - items - }; - } - return null; - } catch (error) { - logger.error(`Failed to load ${catalog.name} from ${addon.name}:`, error); - return null; - } + async fetchHomeCatalog(addon: StreamingAddon, catalog: StreamingCatalog): Promise { + return fetchHomeCatalog(this.library, addon, catalog); } async getHomeCatalogs(limitIds?: string[]): Promise { - // Determine which catalogs to actually fetch - const catalogsToFetch = await this.resolveHomeCatalogsToFetch(limitIds); - - // Create promises for the selected catalogs - const catalogPromises = catalogsToFetch.map(async ({ addon, catalog }) => { - return this.fetchHomeCatalog(addon, catalog); - }); - - // Wait for all selected catalog fetch promises to resolve in parallel - const catalogResults = await Promise.all(catalogPromises); - - // Filter out null results - return catalogResults.filter(catalog => catalog !== null) as CatalogContent[]; + return getHomeCatalogs(this.library, limitIds); } async getCatalogByType(type: string, genreFilter?: string): Promise { - // Get the data source preference (default to Stremio addons) - const dataSourcePreference = await this.getDataSourcePreference(); - - // If TMDB is selected as the data source, use TMDB API - if (dataSourcePreference === DataSource.TMDB) { - return this.getCatalogByTypeFromTMDB(type, genreFilter); - } - - // Otherwise use the original Stremio addons method - const addons = await this.getAllAddons(); - - const typeAddons = addons.filter(addon => - addon.catalogs && addon.catalogs.some(catalog => catalog.type === type) - ); - - // Create an array of promises for all catalog fetches - const catalogPromises: Promise[] = []; - - for (const addon of typeAddons) { - const typeCatalogs = addon.catalogs.filter((catalog: StreamingCatalog) => - catalog.type === type && this.isVisibleOnHome(catalog, addon.catalogs) - ); - - for (const catalog of typeCatalogs) { - const catalogPromise = (async () => { - try { - const addonManifest = await stremioService.getInstalledAddonsAsync(); - const manifest = addonManifest.find(a => a.id === addon.id); - if (!manifest) return null; - - const filters = genreFilter ? [{ title: 'genre', value: genreFilter }] : []; - const metas = await stremioService.getCatalog(manifest, type, catalog.id, 1, filters); - - if (metas && metas.length > 0) { - const items = metas.map(meta => this.convertMetaToStreamingContent(meta)); - - // Get potentially custom display name - const displayName = await getCatalogDisplayName(addon.id, catalog.type, catalog.id, catalog.name); - - return { - addon: addon.id, - type, - id: catalog.id, - name: displayName, - genre: genreFilter, - items - }; - } - return null; - } catch (error) { - logger.error(`Failed to get catalog ${catalog.id} for addon ${addon.id}:`, error); - return null; - } - })(); - - catalogPromises.push(catalogPromise); - } - } - - // Wait for all catalog fetch promises to resolve in parallel - const catalogResults = await Promise.all(catalogPromises); - - // Filter out null results - return catalogResults.filter(catalog => catalog !== null) as CatalogContent[]; + const dataSourcePreference = await getDataSourcePreference(); + return getCatalogByType(this.library, dataSourcePreference, type, genreFilter); } - /** - * Get catalog content from TMDB by type and genre - */ - private async getCatalogByTypeFromTMDB(type: string, genreFilter?: string): Promise { - const tmdbService = TMDBService.getInstance(); - const catalogs: CatalogContent[] = []; - - try { - // Map Stremio content type to TMDB content type - const tmdbType = type === 'movie' ? 'movie' : 'tv'; - - // If no genre filter or All is selected, get multiple catalogs - if (!genreFilter || genreFilter === 'All') { - // Create an array of promises for all catalog fetches - const catalogFetchPromises = [ - // Trending catalog - (async () => { - const trendingItems = await tmdbService.getTrending(tmdbType, 'week'); - const trendingItemsPromises = trendingItems.map(item => this.convertTMDBToStreamingContent(item, tmdbType)); - const trendingStreamingItems = await Promise.all(trendingItemsPromises); - - return { - addon: 'tmdb', - type, - id: 'trending', - name: `Trending ${type === 'movie' ? 'Movies' : 'TV Shows'}`, - items: trendingStreamingItems - }; - })(), - - // Popular catalog - (async () => { - const popularItems = await tmdbService.getPopular(tmdbType, 1); - const popularItemsPromises = popularItems.map(item => this.convertTMDBToStreamingContent(item, tmdbType)); - const popularStreamingItems = await Promise.all(popularItemsPromises); - - return { - addon: 'tmdb', - type, - id: 'popular', - name: `Popular ${type === 'movie' ? 'Movies' : 'TV Shows'}`, - items: popularStreamingItems - }; - })(), - - // Upcoming/on air catalog - (async () => { - const upcomingItems = await tmdbService.getUpcoming(tmdbType, 1); - const upcomingItemsPromises = upcomingItems.map(item => this.convertTMDBToStreamingContent(item, tmdbType)); - const upcomingStreamingItems = await Promise.all(upcomingItemsPromises); - - return { - addon: 'tmdb', - type, - id: 'upcoming', - name: type === 'movie' ? 'Upcoming Movies' : 'On Air TV Shows', - items: upcomingStreamingItems - }; - })() - ]; - - // Wait for all catalog fetches to complete in parallel - return await Promise.all(catalogFetchPromises); - } else { - // Get content by genre - const genreItems = await tmdbService.discoverByGenre(tmdbType, genreFilter); - const streamingItemsPromises = genreItems.map(item => this.convertTMDBToStreamingContent(item, tmdbType)); - const streamingItems = await Promise.all(streamingItemsPromises); - - return [{ - addon: 'tmdb', - type, - id: 'discover', - name: `${genreFilter} ${type === 'movie' ? 'Movies' : 'TV Shows'}`, - genre: genreFilter, - items: streamingItems - }]; - } - } catch (error) { - logger.error(`Failed to get catalog from TMDB for type ${type}, genre ${genreFilter}:`, error); - return []; - } + async getDataSourcePreference() { + return getDataSourcePreference(); } - /** - * Convert TMDB trending/discover result to StreamingContent format - */ - private async convertTMDBToStreamingContent(item: any, type: 'movie' | 'tv'): Promise { - const id = item.external_ids?.imdb_id || `tmdb:${item.id}`; - const name = type === 'movie' ? item.title : item.name; - const posterPath = item.poster_path; - - // Get genres from genre_ids - let genres: string[] = []; - if (item.genre_ids && item.genre_ids.length > 0) { - try { - const tmdbService = TMDBService.getInstance(); - const genreLists = type === 'movie' - ? await tmdbService.getMovieGenres() - : await tmdbService.getTvGenres(); - - const genreIds: number[] = item.genre_ids; - genres = genreIds - .map(genreId => { - const genre = genreLists.find(g => g.id === genreId); - return genre ? genre.name : null; - }) - .filter(Boolean) as string[]; - } catch (error) { - logger.error('Failed to get genres for TMDB content:', error); - } - } - - return { - id, - type: type === 'movie' ? 'movie' : 'series', - name: name || 'Unknown', - poster: posterPath ? `https://image.tmdb.org/t/p/w500${posterPath}` : 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image', - posterShape: 'poster', - banner: item.backdrop_path ? `https://image.tmdb.org/t/p/original${item.backdrop_path}` : undefined, - year: type === 'movie' - ? (item.release_date ? new Date(item.release_date).getFullYear() : undefined) - : (item.first_air_date ? new Date(item.first_air_date).getFullYear() : undefined), - description: item.overview, - genres, - inLibrary: this.library[`${type === 'movie' ? 'movie' : 'series'}:${id}`] !== undefined, - }; - } - - /** - * Get the current data source preference - */ - async getDataSourcePreference(): Promise { - try { - const dataSource = await mmkvStorage.getItem(DATA_SOURCE_KEY); - return dataSource as DataSource || DataSource.STREMIO_ADDONS; - } catch (error) { - logger.error('Failed to get data source preference:', error); - return DataSource.STREMIO_ADDONS; - } - } - - /** - * Set the data source preference - */ async setDataSourcePreference(dataSource: DataSource): Promise { - try { - await mmkvStorage.setItem(DATA_SOURCE_KEY, dataSource); - } catch (error) { - logger.error('Failed to set data source preference:', error); - } + await setDataSourcePreference(dataSource); } async getContentDetails(type: string, id: string, preferredAddonId?: string): Promise { - console.log(`🔍 [CatalogService] getContentDetails called:`, { type, id, preferredAddonId }); - try { - // Try up to 2 times with increasing delays to reduce CPU load - let meta = null; - let lastError = null; - - for (let i = 0; i < 2; i++) { - try { - console.log(`🔍 [CatalogService] Attempt ${i + 1}/2 for getContentDetails:`, { type, id, preferredAddonId }); - - // Skip meta requests for non-content ids (e.g., provider slugs) - const isValidId = await stremioService.isValidContentId(type, id); - console.log(`🔍 [CatalogService] Content ID validation:`, { type, id, isValidId }); - - if (!isValidId) { - console.log(`🔍 [CatalogService] Invalid content ID, breaking retry loop`); - break; - } - - console.log(`🔍 [CatalogService] Calling stremioService.getMetaDetails:`, { type, id, preferredAddonId }); - meta = await stremioService.getMetaDetails(type, id, preferredAddonId); - console.log(`🔍 [CatalogService] stremioService.getMetaDetails result:`, { - hasMeta: !!meta, - metaId: meta?.id, - metaName: meta?.name, - metaType: meta?.type - }); - - if (meta) break; - await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i))); - } catch (error) { - lastError = error; - console.log(`🔍 [CatalogService] Attempt ${i + 1} failed:`, { - errorMessage: error instanceof Error ? error.message : String(error), - isAxiosError: (error as any)?.isAxiosError, - responseStatus: (error as any)?.response?.status, - responseData: (error as any)?.response?.data - }); - logger.error(`Attempt ${i + 1} failed to get content details for ${type}:${id}:`, error); - await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i))); - } - } - - if (meta) { - console.log(`🔍 [CatalogService] Meta found, converting to StreamingContent:`, { - metaId: meta.id, - metaName: meta.name, - metaType: meta.type - }); - - // Add to recent content using enhanced conversion for full metadata - const content = this.convertMetaToStreamingContentEnhanced(meta); - this.addToRecentContent(content); - - // Check if it's in the library - content.inLibrary = this.library[`${type}:${id}`] !== undefined; - - console.log(`🔍 [CatalogService] Successfully converted meta to StreamingContent:`, { - contentId: content.id, - contentName: content.name, - contentType: content.type, - inLibrary: content.inLibrary - }); - - return content; - } - - console.log(`🔍 [CatalogService] No meta found, checking lastError:`, { - hasLastError: !!lastError, - lastErrorMessage: lastError instanceof Error ? lastError.message : String(lastError) - }); - - if (lastError) { - console.log(`🔍 [CatalogService] Throwing lastError:`, { - errorMessage: lastError instanceof Error ? lastError.message : String(lastError), - isAxiosError: (lastError as any)?.isAxiosError, - responseStatus: (lastError as any)?.response?.status - }); - throw lastError; - } - - console.log(`🔍 [CatalogService] No meta and no error, returning null`); - return null; - } catch (error) { - console.log(`🔍 [CatalogService] getContentDetails caught error:`, { - errorMessage: error instanceof Error ? error.message : String(error), - isAxiosError: (error as any)?.isAxiosError, - responseStatus: (error as any)?.response?.status, - responseData: (error as any)?.response?.data - }); - logger.error(`Failed to get content details for ${type}:${id}:`, error); - return null; - } + return getContentDetails(this, type, id, preferredAddonId); } - // Public method for getting enhanced metadata details (used by MetadataScreen) - async getEnhancedContentDetails(type: string, id: string, preferredAddonId?: string): Promise { - console.log(`🔍 [CatalogService] getEnhancedContentDetails called:`, { type, id, preferredAddonId }); - logger.log(`🔍 [MetadataScreen] Fetching enhanced metadata for ${type}:${id} ${preferredAddonId ? `from addon ${preferredAddonId}` : ''}`); - - try { - const result = await this.getContentDetails(type, id, preferredAddonId); - console.log(`🔍 [CatalogService] getEnhancedContentDetails result:`, { - hasResult: !!result, - resultId: result?.id, - resultName: result?.name, - resultType: result?.type - }); - return result; - } catch (error) { - console.log(`🔍 [CatalogService] getEnhancedContentDetails error:`, { - errorMessage: error instanceof Error ? error.message : String(error), - isAxiosError: (error as any)?.isAxiosError, - responseStatus: (error as any)?.response?.status, - responseData: (error as any)?.response?.data - }); - throw error; - } + async getEnhancedContentDetails( + type: string, + id: string, + preferredAddonId?: string + ): Promise { + return getEnhancedContentDetails(this, type, id, preferredAddonId); } - // Public method for getting basic content details without enhanced processing (used by ContinueWatching, etc.) - async getBasicContentDetails(type: string, id: string, preferredAddonId?: string): Promise { - try { - // Try up to 3 times with increasing delays - let meta = null; - let lastError = null; - - for (let i = 0; i < 3; i++) { - try { - // Skip meta requests for non-content ids (e.g., provider slugs) - if (!(await stremioService.isValidContentId(type, id))) { - break; - } - meta = await stremioService.getMetaDetails(type, id, preferredAddonId); - if (meta) break; - await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i))); - } catch (error) { - lastError = error; - logger.error(`Attempt ${i + 1} failed to get basic content details for ${type}:${id}:`, error); - await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i))); - } - } - - if (meta) { - // Use basic conversion without enhanced metadata processing - const content = this.convertMetaToStreamingContent(meta); - - // Check if it's in the library - content.inLibrary = this.library[`${type}:${id}`] !== undefined; - - return content; - } - - if (lastError) { - throw lastError; - } - - return null; - } catch (error) { - logger.error(`Failed to get basic content details for ${type}:${id}:`, error); - return null; - } + async getBasicContentDetails( + type: string, + id: string, + preferredAddonId?: string + ): Promise { + return getBasicContentDetails(this, type, id, preferredAddonId); } - private convertMetaToStreamingContent(meta: Meta): StreamingContent { - // Basic conversion for catalog display - no enhanced metadata processing - // Use addon's poster if available, otherwise use placeholder - let posterUrl = meta.poster; - if (!posterUrl || posterUrl.trim() === '' || posterUrl === 'null' || posterUrl === 'undefined') { - posterUrl = 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image'; - } - - // Use addon's logo if available, otherwise undefined - let logoUrl = (meta as any).logo; - if (!logoUrl || logoUrl.trim() === '' || logoUrl === 'null' || logoUrl === 'undefined') { - logoUrl = undefined; - } - - return { - id: meta.id, - type: meta.type, - name: meta.name, - poster: posterUrl, - posterShape: meta.posterShape || 'poster', // Use addon's shape or default to poster type - banner: meta.background, - logo: logoUrl, - imdbRating: meta.imdbRating, - year: meta.year, - genres: meta.genres, - description: meta.description, - runtime: meta.runtime, - inLibrary: this.library[`${meta.type}:${meta.id}`] !== undefined, - certification: meta.certification, - releaseInfo: meta.releaseInfo, - }; + onLibraryAdd(listener: (item: StreamingContent) => void): () => void { + return onLibraryAdd(this, listener); } - // Enhanced conversion for detailed metadata (used only when fetching individual content details) - private convertMetaToStreamingContentEnhanced(meta: Meta): StreamingContent { - // Enhanced conversion to utilize all available metadata from addons - const converted: StreamingContent = { - id: meta.id, - type: meta.type, - name: meta.name, - poster: meta.poster || 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image', - posterShape: meta.posterShape || 'poster', - banner: meta.background, - // Use addon's logo if available, otherwise undefined - logo: (meta as any).logo || undefined, - imdbRating: meta.imdbRating, - year: meta.year, - genres: meta.genres, - description: meta.description, - runtime: meta.runtime, - inLibrary: this.library[`${meta.type}:${meta.id}`] !== undefined, - certification: meta.certification, - // Enhanced fields from addon metadata - directors: (meta as any).director ? - (Array.isArray((meta as any).director) ? (meta as any).director : [(meta as any).director]) - : undefined, - writer: (meta as any).writer || undefined, - country: (meta as any).country || undefined, - imdb_id: (meta as any).imdb_id || undefined, - slug: (meta as any).slug || undefined, - releaseInfo: meta.releaseInfo || (meta as any).releaseInfo || undefined, - trailerStreams: (meta as any).trailerStreams || undefined, - links: (meta as any).links || undefined, - behaviorHints: (meta as any).behaviorHints || undefined, - }; - - // Extract addon cast data if available - // Check for both app_extras.cast (structured) and cast (simple array) formats - if ((meta as any).app_extras?.cast && Array.isArray((meta as any).app_extras.cast)) { - // Structured format with name, character, photo - converted.addonCast = (meta as any).app_extras.cast.map((castMember: any, index: number) => ({ - id: index + 1, // Use index as numeric ID - name: castMember.name || 'Unknown', - character: castMember.character || '', - profile_path: castMember.photo || null - })); - } else if (meta.cast && Array.isArray(meta.cast)) { - // Simple array format with just names - converted.addonCast = meta.cast.map((castName: string, index: number) => ({ - id: index + 1, // Use index as numeric ID - name: castName || 'Unknown', - character: '', // No character info available in simple format - profile_path: null // No profile images available in simple format - })); - } - - // Log if rich metadata is found - if ((meta as any).trailerStreams?.length > 0) { - logger.log(`🎬 Enhanced metadata: Found ${(meta as any).trailerStreams.length} trailers for ${meta.name}`); - } - - if ((meta as any).links?.length > 0) { - logger.log(`🔗 Enhanced metadata: Found ${(meta as any).links.length} links for ${meta.name}`); - } - - if (converted.addonCast && converted.addonCast.length > 0) { - logger.log(`🎭 Enhanced metadata: Found ${converted.addonCast.length} cast members from addon for ${meta.name}`); - } - - // Handle videos/episodes if available - if ((meta as any).videos) { - converted.videos = (meta as any).videos; - } - - return converted; + onLibraryRemove(listener: (type: string, id: string) => void): () => void { + return onLibraryRemove(this, listener); } - private notifyLibrarySubscribers(): void { - const items = Object.values(this.library); - this.librarySubscribers.forEach(callback => callback(items)); + async getLibraryItems(): Promise { + return getLibraryItems(this); } - public onLibraryAdd(listener: (item: StreamingContent) => void): () => void { - this.libraryAddListeners.push(listener); - return () => { - this.libraryAddListeners = this.libraryAddListeners.filter(l => l !== listener); - }; + subscribeToLibraryUpdates(callback: (items: StreamingContent[]) => void): () => void { + return subscribeToLibraryUpdates(this, callback); } - public onLibraryRemove(listener: (type: string, id: string) => void): () => void { - this.libraryRemoveListeners.push(listener); - return () => { - this.libraryRemoveListeners = this.libraryRemoveListeners.filter(l => l !== listener); - }; + async addToLibrary(content: StreamingContent): Promise { + await addToLibrary(this, content); } - public async getLibraryItems(): Promise { - // Only ensure initialization if not already done to avoid redundant calls - if (!this.isInitialized) { - await this.ensureInitialized(); - } - return Object.values(this.library); - } - - public subscribeToLibraryUpdates(callback: (items: StreamingContent[]) => void): () => void { - this.librarySubscribers.push(callback); - // Defer initial callback to next tick to avoid synchronous state updates during render - // This prevents infinite loops when the callback triggers setState in useEffect - Promise.resolve().then(() => { - this.getLibraryItems().then(items => { - // Only call if still subscribed (callback might have been unsubscribed) - if (this.librarySubscribers.includes(callback)) { - callback(items); - } - }); - }); - - // Return unsubscribe function - return () => { - const index = this.librarySubscribers.indexOf(callback); - if (index > -1) { - this.librarySubscribers.splice(index, 1); - } - }; - } - - public async addToLibrary(content: StreamingContent): Promise { - logger.log(`[CatalogService] addToLibrary() called for: ${content.type}:${content.id} (${content.name})`); - await this.ensureInitialized(); - const key = `${content.type}:${content.id}`; - const itemCountBefore = Object.keys(this.library).length; - logger.log(`[CatalogService] Adding to library with key: "${key}". Current library keys: [${Object.keys(this.library).length}] items`); - this.library[key] = { - ...content, - addedToLibraryAt: Date.now() // Add timestamp - }; - const itemCountAfter = Object.keys(this.library).length; - logger.log(`[CatalogService] Library updated: ${itemCountBefore} -> ${itemCountAfter} items. New library keys: [${Object.keys(this.library).slice(0, 5).join(', ')}${Object.keys(this.library).length > 5 ? '...' : ''}]`); - await this.saveLibrary(); - logger.log(`[CatalogService] addToLibrary() completed for: ${content.type}:${content.id}`); - this.notifyLibrarySubscribers(); - try { this.libraryAddListeners.forEach(l => l(content)); } catch { } - - // Auto-setup notifications for series when added to library - if (content.type === 'series') { - try { - await notificationService.updateNotificationsForSeries(content.id); - console.log(`[CatalogService] Auto-setup notifications for series: ${content.name}`); - } catch (error) { - console.error(`[CatalogService] Failed to setup notifications for ${content.name}:`, error); - } - } - } - - public async removeFromLibrary(type: string, id: string): Promise { - logger.log(`[CatalogService] removeFromLibrary() called for: ${type}:${id}`); - await this.ensureInitialized(); - const key = `${type}:${id}`; - const itemCountBefore = Object.keys(this.library).length; - const itemExisted = key in this.library; - logger.log(`[CatalogService] Removing key: "${key}". Currently library has ${itemCountBefore} items with keys: [${Object.keys(this.library).slice(0, 5).join(', ')}${Object.keys(this.library).length > 5 ? '...' : ''}]`); - delete this.library[key]; - const itemCountAfter = Object.keys(this.library).length; - logger.log(`[CatalogService] Library updated: ${itemCountBefore} -> ${itemCountAfter} items (existed: ${itemExisted})`); - await this.saveLibrary(); - logger.log(`[CatalogService] removeFromLibrary() completed for: ${type}:${id}`); - this.notifyLibrarySubscribers(); - try { this.libraryRemoveListeners.forEach(l => l(type, id)); } catch { } - - // Cancel notifications for series when removed from library - if (type === 'series') { - try { - // Cancel all notifications for this series - const scheduledNotifications = notificationService.getScheduledNotifications(); - const seriesToCancel = scheduledNotifications.filter(notification => notification.seriesId === id); - for (const notification of seriesToCancel) { - await notificationService.cancelNotification(notification.id); - } - console.log(`[CatalogService] Cancelled ${seriesToCancel.length} notifications for removed series: ${id}`); - } catch (error) { - console.error(`[CatalogService] Failed to cancel notifications for removed series ${id}:`, error); - } - } - } - - private addToRecentContent(content: StreamingContent): void { - // Remove if it already exists to prevent duplicates - this.recentContent = this.recentContent.filter(item => - !(item.id === content.id && item.type === content.type) - ); - - // Add to the beginning of the array - this.recentContent.unshift(content); - - // Trim the array if it exceeds the maximum - if (this.recentContent.length > this.MAX_RECENT_ITEMS) { - this.recentContent = this.recentContent.slice(0, this.MAX_RECENT_ITEMS); - } - - this.saveRecentContent(); + async removeFromLibrary(type: string, id: string): Promise { + await removeFromLibrary(this, type, id); } getRecentContent(): StreamingContent[] { - return this.recentContent; + return getRecentContent(this); } - /** - * Get all available discover filters (genres, etc.) from installed addon catalogs - * This aggregates genre options from all addons that have catalog extras with options - */ - async getDiscoverFilters(): Promise<{ - genres: string[]; - types: string[]; - catalogsByType: Record; - }> { - const addons = await this.getAllAddons(); - const allGenres = new Set(); - const allTypes = new Set(); - const catalogsByType: Record = {}; - - for (const addon of addons) { - if (!addon.catalogs) continue; - - for (const catalog of addon.catalogs) { - if (!this.canBrowseCatalog(catalog)) { - continue; - } - - // Track content types - if (catalog.type) { - allTypes.add(catalog.type); - } - - // Get genres from catalog extras - const catalogGenres: string[] = []; - if (catalog.extra && Array.isArray(catalog.extra)) { - for (const extra of catalog.extra) { - if (extra.name === 'genre' && extra.options && Array.isArray(extra.options)) { - for (const genre of extra.options) { - allGenres.add(genre); - catalogGenres.push(genre); - } - } - } - } - - // Track catalogs by type for filtering - if (catalog.type) { - if (!catalogsByType[catalog.type]) { - catalogsByType[catalog.type] = []; - } - catalogsByType[catalog.type].push({ - addonId: addon.id, - addonName: addon.name, - catalogId: catalog.id, - catalogName: catalog.name || catalog.id, - genres: catalogGenres - }); - } - } - } - - // Sort genres alphabetically - const sortedGenres = Array.from(allGenres).sort((a, b) => a.localeCompare(b)); - const sortedTypes = Array.from(allTypes); - - return { - genres: sortedGenres, - types: sortedTypes, - catalogsByType - }; + async getDiscoverFilters() { + return getDiscoverFilters(); } - /** - * Discover content by type and optional genre filter - * Fetches from all installed addons that have catalogs matching the criteria - */ - async discoverContent( - type: string, - genre?: string, - limit: number = 20 - ): Promise<{ addonName: string; items: StreamingContent[] }[]> { - const addons = await this.getAllAddons(); - const results: { addonName: string; items: StreamingContent[] }[] = []; - const manifests = await stremioService.getInstalledAddonsAsync(); - - // Find catalogs that match the type - const catalogPromises: Promise<{ addonName: string; items: StreamingContent[] } | null>[] = []; - - for (const addon of addons) { - if (!addon.catalogs) continue; - - // Find catalogs matching the type - const matchingCatalogs = addon.catalogs.filter(catalog => - catalog.type === type && this.canBrowseCatalog(catalog) - ); - - for (const catalog of matchingCatalogs) { - // Check if this catalog supports the genre filter - const supportsGenre = catalog.extra?.some(e => e.name === 'genre') || - catalog.extraSupported?.includes('genre'); - - // If genre is specified, only use catalogs that support genre OR have no filter restrictions - // If genre is specified but catalog doesn't support genre filter, skip it - if (genre && !supportsGenre) { - continue; - } - - const manifest = manifests.find(m => m.id === addon.id); - if (!manifest) continue; - - const fetchPromise = (async () => { - try { - const filters = genre ? [{ title: 'genre', value: genre }] : []; - const metas = await stremioService.getCatalog(manifest, type, catalog.id, 1, filters); - - if (metas && metas.length > 0) { - const items = metas.slice(0, limit).map(meta => { - const content = this.convertMetaToStreamingContent(meta); - content.addonId = addon.id; - return content; - }); - return { - addonName: addon.name, - items - }; - } - return null; - } catch (error) { - logger.error(`Discover failed for ${catalog.id} in addon ${addon.id}:`, error); - return null; - } - })(); - - catalogPromises.push(fetchPromise); - } - } - - const catalogResults = await Promise.all(catalogPromises); - - // Filter out null results and deduplicate by addon - const addonMap = new Map(); - for (const result of catalogResults) { - if (result && result.items.length > 0) { - const existing = addonMap.get(result.addonName) || []; - // Merge items, avoiding duplicates - const existingIds = new Set(existing.map(item => `${item.type}:${item.id}`)); - const newItems = result.items.filter(item => !existingIds.has(`${item.type}:${item.id}`)); - addonMap.set(result.addonName, [...existing, ...newItems]); - } - } - - // Convert map to array - for (const [addonName, items] of addonMap) { - results.push({ addonName, items: items.slice(0, limit) }); - } - - return results; + async discoverContent(type: string, genre?: string, limit = 20) { + return discoverContent(this.library, type, genre, limit); } - /** - * Discover content from a specific catalog with optional genre filter - * @param addonId - The addon ID - * @param catalogId - The catalog ID - * @param type - Content type (movie/series) - * @param genre - Optional genre filter - * @param page - Page number for pagination (default 1) - */ async discoverContentFromCatalog( addonId: string, catalogId: string, type: string, genre?: string, - page: number = 1 + page = 1 ): Promise { - try { - const manifests = await stremioService.getInstalledAddonsAsync(); - const manifest = manifests.find(m => m.id === addonId); - - if (!manifest) { - logger.error(`Addon ${addonId} not found`); - return []; - } - - const catalog = (manifest.catalogs || []).find(item => item.type === type && item.id === catalogId); - if (!catalog || !this.canBrowseCatalog(catalog)) { - logger.warn(`Catalog ${catalogId} in addon ${addonId} is not browseable`); - return []; - } - - const filters = genre ? [{ title: 'genre', value: genre }] : []; - const metas = await stremioService.getCatalog(manifest, type, catalogId, page, filters); - - if (metas && metas.length > 0) { - return metas.map(meta => { - const content = this.convertMetaToStreamingContent(meta); - content.addonId = addonId; - return content; - }); - } - return []; - } catch (error) { - logger.error(`Discover from catalog failed for ${addonId}/${catalogId}:`, error); - return []; - } + return discoverContentFromCatalog(this.library, addonId, catalogId, type, genre, page); } async searchContent(query: string): Promise { - if (!query || query.trim().length < 2) { - return []; - } - - const addons = await this.getAllAddons(); - const results: StreamingContent[] = []; - const searchPromises: Promise[] = []; - - for (const addon of addons) { - if (addon.catalogs && addon.catalogs.length > 0) { - for (const catalog of addon.catalogs) { - if (!this.canSearchCatalog(catalog)) { - continue; - } - - const addonManifest = await stremioService.getInstalledAddonsAsync(); - const manifest = addonManifest.find(a => a.id === addon.id); - if (!manifest) continue; - - const searchPromise = (async () => { - try { - const filters = [{ title: 'search', value: query }]; - const metas = await stremioService.getCatalog(manifest, catalog.type, catalog.id, 1, filters); - - if (metas && metas.length > 0) { - const items = metas.map(meta => { - const content = this.convertMetaToStreamingContent(meta); - content.addonId = addon.id; - return content; - }); - results.push(...items); - } - } catch (error) { - logger.error(`Search failed for ${catalog.id} in addon ${addon.id}:`, error); - } - })(); - - searchPromises.push(searchPromise); - } - } - } - - await Promise.all(searchPromises); - - // Remove duplicates based on id and type - const uniqueResults = Array.from( - new Map(results.map(item => [`${item.type}:${item.id}`, item])).values() - ); - - return uniqueResults; + return searchContent(this.library, query); } - /** - * Search across all installed addons that support search functionality. - * This dynamically queries any addon with catalogs that have 'search' in their extraSupported or extra fields. - * Results are grouped by addon source with headers. - * - * @param query - The search query string - * @returns Promise - Search results grouped by addon with headers - */ async searchContentCinemeta(query: string): Promise { - if (!query) { - return { byAddon: [], allResults: [] }; - } - - const trimmedQuery = query.trim().toLowerCase(); - logger.log('Searching across all addons for:', trimmedQuery); - - const addons = await this.getAllAddons(); - const byAddon: AddonSearchResults[] = []; - - // Get manifests separately to ensure we have correct URLs - const manifests = await stremioService.getInstalledAddonsAsync(); - const manifestMap = new Map(manifests.map(m => [m.id, m])); - - // Find all addons that support search - const searchableAddons = addons.filter(addon => { - if (!addon.catalogs) return false; - - return addon.catalogs.some(catalog => this.canSearchCatalog(catalog)); - }); - - logger.log(`Found ${searchableAddons.length} searchable addons:`, searchableAddons.map(a => a.name).join(', ')); - - // Search each addon and keep results grouped - for (const [addonIndex, addon] of searchableAddons.entries()) { - // Get the manifest to ensure we have the correct URL - const manifest = manifestMap.get(addon.id); - if (!manifest) { - logger.warn(`Manifest not found for addon ${addon.name} (${addon.id})`); - continue; - } - - const searchableCatalogs = (addon.catalogs || []).filter(catalog => this.canSearchCatalog(catalog)); - - // Search all catalogs for this addon in parallel - const catalogPromises = searchableCatalogs.map(catalog => - this.searchAddonCatalog(manifest, catalog.type, catalog.id, trimmedQuery) - ); - - const catalogResults = await Promise.allSettled(catalogPromises); - - // Collect all results for this addon - const addonResults: StreamingContent[] = []; - catalogResults.forEach((result) => { - if (result.status === 'fulfilled' && result.value) { - addonResults.push(...result.value); - } else if (result.status === 'rejected') { - logger.error(`Search failed for ${addon.name}:`, result.reason); - } - }); - - // Only add addon section if it has results - if (addonResults.length > 0) { - // Deduplicate within this addon's results - const seen = new Set(); - const uniqueAddonResults = addonResults.filter(item => { - const key = `${item.type}:${item.id}`; - if (seen.has(key)) return false; - seen.add(key); - return true; - }); - - byAddon.push({ - addonId: addon.id, - addonName: addon.name, - sectionName: addon.name, - catalogIndex: addonIndex, - results: uniqueAddonResults, - }); - } - } - - // Create deduplicated flat list for backwards compatibility - const allResults: StreamingContent[] = []; - const globalSeen = new Set(); - - byAddon.forEach(addonGroup => { - addonGroup.results.forEach(item => { - const key = `${item.type}:${item.id}`; - if (!globalSeen.has(key)) { - globalSeen.add(key); - allResults.push(item); - } - }); - }); - - logger.log(`Search complete: ${byAddon.length} addons returned results, ${allResults.length} unique items total`); - - return { byAddon, allResults }; + return searchContentCinemeta(this.library, query); } - /** - * Live search that emits results per-addon as they arrive. - * Returns a handle with cancel() and a done promise. - */ startLiveSearch( query: string, onAddonResults: (section: AddonSearchResults) => void ): { cancel: () => void; done: Promise } { - const controller = { cancelled: false } as { cancelled: boolean }; - - const done = (async () => { - if (!query || !query.trim()) return; - - const trimmedQuery = query.trim().toLowerCase(); - logger.log('Live search across addons for:', trimmedQuery); - - const addons = await this.getAllAddons(); - logger.log(`Total addons available: ${addons.length}`); - - // Get manifests separately to ensure we have correct URLs - const manifests = await stremioService.getInstalledAddonsAsync(); - const manifestMap = new Map(manifests.map(m => [m.id, m])); - - // Determine searchable addons - const searchableAddons = addons.filter(addon => - (addon.catalogs || []).some(catalog => this.canSearchCatalog(catalog)) - ); - - logger.log(`Found ${searchableAddons.length} searchable addons:`, searchableAddons.map(a => `${a.name} (${a.id})`).join(', ')); - - if (searchableAddons.length === 0) { - logger.warn('No searchable addons found. Make sure you have addons installed that support search functionality.'); - return; - } - - // Build addon order map for deterministic section sorting - const addonOrderRef: Record = {}; - searchableAddons.forEach((addon, i) => { addonOrderRef[addon.id] = i; }); - - // Human-readable labels for known content types - const CATALOG_TYPE_LABELS: Record = { - 'movie': 'Movies', - 'series': 'TV Shows', - 'anime.series': 'Anime Series', - 'anime.movie': 'Anime Movies', - 'other': 'Other', - 'tv': 'TV', - 'channel': 'Channels', - }; - const GENERIC_CATALOG_NAMES = new Set(['search', 'Search']); - - // Collect all sections from all addons first, then sort and dedup before emitting. - // This avoids race conditions where concurrent addon workers steal each other's IDs - // from a shared globalSeen set before they get a chance to emit. - type PendingSection = { - addonId: string; - addonName: string; - sectionName: string; - catalogIndex: number; - results: StreamingContent[]; - }; - const allPendingSections: PendingSection[] = []; - - await Promise.all( - searchableAddons.map(async (addon) => { - if (controller.cancelled) return; - try { - const manifest = manifestMap.get(addon.id); - if (!manifest) { - logger.warn(`Manifest not found for addon ${addon.name} (${addon.id})`); - return; - } - - const searchableCatalogs = (addon.catalogs || []).filter(catalog => this.canSearchCatalog(catalog)); - logger.log(`Searching ${addon.name} (${addon.id}) with ${searchableCatalogs.length} searchable catalogs`); - - const settled = await Promise.allSettled( - searchableCatalogs.map(c => this.searchAddonCatalog(manifest, c.type, c.id, trimmedQuery)) - ); - if (controller.cancelled) return; - - const hasMultipleCatalogs = searchableCatalogs.length > 1; - const addonRank = addonOrderRef[addon.id] ?? Number.MAX_SAFE_INTEGER; - - if (hasMultipleCatalogs) { - for (let ci = 0; ci < searchableCatalogs.length; ci++) { - const s = settled[ci]; - const catalog = searchableCatalogs[ci]; - if (s.status === 'rejected' || !(s as PromiseFulfilledResult).value?.length) { - if (s.status === 'rejected') logger.warn(`Search failed for ${catalog.id} in ${addon.name}:`, s.reason); - continue; - } - - const results = (s as PromiseFulfilledResult).value; - - // Within-catalog dedup: prefer dot-type over generic for same ID - const bestById = new Map(); - for (const item of results) { - const existing = bestById.get(item.id); - if (!existing || (!existing.type.includes('.') && item.type.includes('.'))) { - bestById.set(item.id, item); - } - } - - // Stamp catalog type onto results - const stamped = Array.from(bestById.values()).map(item => - catalog.type && item.type !== catalog.type ? { ...item, type: catalog.type } : item - ); - - // Build section name — use type label if catalog name is generic - const typeLabel = CATALOG_TYPE_LABELS[catalog.type] - || catalog.type.replace(/[._]/g, ' ').replace(/\w/g, (c: string) => c.toUpperCase()); - const catalogLabel = (!catalog.name || GENERIC_CATALOG_NAMES.has(catalog.name) || catalog.name === addon.name) - ? typeLabel - : catalog.name; - const sectionName = `${addon.name} - ${catalogLabel}`; - const catalogIndex = addonRank * 1000 + ci; - - allPendingSections.push({ addonId: `${addon.id}||${catalog.type}||${catalog.id}`, addonName: addon.name, sectionName, catalogIndex, results: stamped }); - } - } else { - const s = settled[0]; - const catalog = searchableCatalogs[0]; - if (!s || s.status === 'rejected' || !(s as PromiseFulfilledResult).value?.length) { - if (s?.status === 'rejected') logger.warn(`Search failed for ${addon.name}:`, s.reason); - return; - } - - const results = (s as PromiseFulfilledResult).value; - const bestById = new Map(); - for (const item of results) { - const existing = bestById.get(item.id); - if (!existing || (!existing.type.includes('.') && item.type.includes('.'))) { - bestById.set(item.id, item); - } - } - const stamped = Array.from(bestById.values()).map(item => - catalog.type && item.type !== catalog.type ? { ...item, type: catalog.type } : item - ); - - allPendingSections.push({ addonId: addon.id, addonName: addon.name, sectionName: addon.name, catalogIndex: addonRank * 1000, results: stamped }); - } - } catch (e) { - logger.error(`Error searching addon ${addon.name} (${addon.id}):`, e); - } - }) - ); - - if (controller.cancelled) return; - - // Sort by catalogIndex (addon manifest order + position within addon) then emit. - // No cross-section dedup — each section is shown separately so duplicates across - // sections are intentional (e.g. same movie in Cinemeta and People Search). - allPendingSections.sort((a, b) => a.catalogIndex - b.catalogIndex); - - for (const section of allPendingSections) { - if (controller.cancelled) return; - if (section.results.length > 0) { - logger.log(`Emitting ${section.results.length} results from ${section.sectionName}`); - onAddonResults({ addonId: section.addonId, addonName: section.addonName, sectionName: section.sectionName, catalogIndex: section.catalogIndex, results: section.results }); - } - } - })(); - - return { - cancel: () => { controller.cancelled = true; }, - done, - }; - } - - /** - * Search a specific catalog from a specific addon. - * Handles URL construction for both Cinemeta (hardcoded) and other addons (dynamic). - * - * @param manifest - The addon manifest containing id, name, and url - * @param type - Content type (movie, series, anime, etc.) - * @param catalogId - The catalog ID to search within - * @param query - The search query string - * @returns Promise - Search results from this specific addon catalog - */ - private async searchAddonCatalog( - manifest: Manifest, - type: string, - catalogId: string, - query: string - ): Promise { - try { - let url: string; - - // Special handling for Cinemeta (hardcoded URL) - if (manifest.id === 'com.linvo.cinemeta') { - const encodedCatalogId = encodeURIComponent(catalogId); - const encodedQuery = encodeURIComponent(query); - url = `https://v3-cinemeta.strem.io/catalog/${type}/${encodedCatalogId}/search=${encodedQuery}.json`; - } - // Handle other addons - else { - // Choose best available URL - const chosenUrl: string | undefined = manifest.url || manifest.originalUrl; - if (!chosenUrl) { - logger.warn(`Addon ${manifest.name} (${manifest.id}) has no URL, skipping search`); - return []; - } - - // Extract base URL and preserve query params (same logic as stremioService.getAddonBaseURL) - const [baseUrlPart, queryParams] = chosenUrl.split('?'); - let cleanBaseUrl = baseUrlPart.replace(/manifest\.json$/, '').replace(/\/$/, ''); - - // Ensure URL has protocol - if (!cleanBaseUrl.startsWith('http')) { - cleanBaseUrl = `https://${cleanBaseUrl}`; - } - - const encodedCatalogId = encodeURIComponent(catalogId); - const encodedQuery = encodeURIComponent(query); - - // Try path-style URL first (per Stremio protocol) - url = `${cleanBaseUrl}/catalog/${type}/${encodedCatalogId}/search=${encodedQuery}.json`; - - // Append original query params if they existed - if (queryParams) { - url += `?${queryParams}`; - } - } - - logger.log(`Searching ${manifest.name} (${type}/${catalogId}):`, url); - - const response = await axios.get<{ metas: any[] }>(url, createSafeAxiosConfig(10000)); - - const metas = response.data?.metas || []; - - if (metas.length > 0) { - const items = metas.map(meta => { - const content = this.convertMetaToStreamingContent(meta); - content.addonId = manifest.id; - // The meta's own type field may be generic (e.g. "series") even when - // the catalog it came from is more specific (e.g. "anime.series"). - // Stamp the catalog type so grouping in the UI is correct. - if (type && content.type !== type) { - content.type = type; - } - return content; - }); - logger.log(`Found ${items.length} results from ${manifest.name}`); - return items; - } - - return []; - } catch (error: any) { - // Don't throw, just log and return empty - const errorMsg = error?.response?.status - ? `HTTP ${error.response.status}` - : error?.message || 'Unknown error'; - const errorUrl = error?.config?.url || 'unknown URL'; - logger.error(`Search failed for ${manifest.name} (${type}/${catalogId}) at ${errorUrl}: ${errorMsg}`); - if (error?.response?.data) { - logger.error(`Response data:`, error.response.data); - } - return []; - } + return startLiveSearch(this.library, query, onAddonResults); } async getStremioId(type: string, tmdbId: string): Promise { - if (__DEV__) { - console.log('=== CatalogService.getStremioId ==='); - console.log('Input type:', type); - console.log('Input tmdbId:', tmdbId); - } - - try { - // For movies, use the tt prefix with IMDb ID - if (type === 'movie') { - if (__DEV__) console.log('Processing movie - fetching TMDB details...'); - const tmdbService = TMDBService.getInstance(); - const movieDetails = await tmdbService.getMovieDetails(tmdbId); - - if (__DEV__) console.log('Movie details result:', { - id: movieDetails?.id, - title: movieDetails?.title, - imdb_id: movieDetails?.imdb_id, - hasImdbId: !!movieDetails?.imdb_id - }); - - if (movieDetails?.imdb_id) { - if (__DEV__) console.log('Successfully found IMDb ID:', movieDetails.imdb_id); - return movieDetails.imdb_id; - } else { - console.warn('No IMDb ID found for movie:', tmdbId); - return null; - } - } - // For TV shows, get the IMDb ID like movies - else if (type === 'tv' || type === 'series') { - if (__DEV__) console.log('Processing TV show - fetching TMDB details for IMDb ID...'); - const tmdbService = TMDBService.getInstance(); - - // Get TV show external IDs to find IMDb ID - const externalIds = await tmdbService.getShowExternalIds(parseInt(tmdbId)); - - if (__DEV__) console.log('TV show external IDs result:', { - tmdbId: tmdbId, - imdb_id: externalIds?.imdb_id, - hasImdbId: !!externalIds?.imdb_id - }); - - if (externalIds?.imdb_id) { - if (__DEV__) console.log('Successfully found IMDb ID for TV show:', externalIds.imdb_id); - return externalIds.imdb_id; - } else { - console.warn('No IMDb ID found for TV show, falling back to kitsu format:', tmdbId); - const fallbackId = `kitsu:${tmdbId}`; - if (__DEV__) console.log('Generated fallback Stremio ID for TV:', fallbackId); - return fallbackId; - } - } - else { - console.warn('Unknown type provided:', type); - return null; - } - } catch (error: any) { - if (__DEV__) { - console.error('=== Error in getStremioId ==='); - console.error('Type:', type); - console.error('TMDB ID:', tmdbId); - console.error('Error details:', error); - console.error('Error message:', error.message); - } - logger.error('Error getting Stremio ID:', error); - return null; - } + return getStremioId(type, tmdbId); } } export const catalogService = CatalogService.getInstance(); -export default catalogService; +export default catalogService; From 741550d72dd5fffb9c367d68d5cf686c56121787 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 17 Mar 2026 06:12:04 +0530 Subject: [PATCH 05/10] logs cleanup --- App.tsx | 2 - src/components/common/DogLoadingSpinner.tsx | 4 +- src/components/common/LoadingSpinner.tsx | 4 +- src/components/metadata/MetadataDetails.tsx | 2 +- src/components/metadata/RatingsSection.tsx | 2 +- src/components/metadata/SeriesContent.tsx | 20 +- src/components/metadata/TrailersSection.tsx | 10 +- src/components/promotions/CampaignManager.tsx | 2 - src/hooks/useMDBListRatings.ts | 2 +- src/hooks/useMetadata.ts | 289 +++++++----------- src/navigation/AppNavigator.tsx | 4 - src/screens/MDBListSettingsScreen.tsx | 40 +-- src/screens/MetadataScreen.tsx | 67 ++-- src/services/campaignService.ts | 7 - src/services/catalog/content-details.ts | 116 ------- src/services/catalog/library.ts | 21 +- src/services/mdblistConstants.ts | 31 ++ src/services/mdblistService.ts | 3 +- src/services/telemetryService.ts | 1 - src/utils/logger.ts | 4 +- 20 files changed, 218 insertions(+), 413 deletions(-) create mode 100644 src/services/mdblistConstants.ts diff --git a/App.tsx b/App.tsx index 7ce219f0..960fe6c8 100644 --- a/App.tsx +++ b/App.tsx @@ -195,11 +195,9 @@ const ThemedApp = () => { // Initialize memory monitoring service to prevent OutOfMemoryError memoryMonitorService; // Just accessing it starts the monitoring - console.log('Memory monitoring service initialized'); // Initialize AI service await aiService.initialize(); - console.log('AI service initialized'); } catch (error) { console.error('Error initializing app:', error); diff --git a/src/components/common/DogLoadingSpinner.tsx b/src/components/common/DogLoadingSpinner.tsx index f65e18dd..4a553e82 100644 --- a/src/components/common/DogLoadingSpinner.tsx +++ b/src/components/common/DogLoadingSpinner.tsx @@ -76,9 +76,7 @@ const LoadingSpinner: React.FC = ({ renderMode: 'SOFTWARE' as any, // Fallback to software rendering if hardware fails })} // Error handling - onAnimationFinish={() => { - if (__DEV__) console.log('Lottie animation finished'); - }} + onAnimationFinish={() => {}} onAnimationFailure={(error) => { if (__DEV__) console.warn('Lottie animation failed:', error); }} diff --git a/src/components/common/LoadingSpinner.tsx b/src/components/common/LoadingSpinner.tsx index 45b8654f..6097a3a9 100644 --- a/src/components/common/LoadingSpinner.tsx +++ b/src/components/common/LoadingSpinner.tsx @@ -76,9 +76,7 @@ const LoadingSpinner: React.FC = ({ renderMode: 'SOFTWARE' as any, // Fallback to software rendering if hardware fails })} // Error handling - onAnimationFinish={() => { - if (__DEV__) console.log('Lottie animation finished'); - }} + onAnimationFinish={() => {}} onAnimationFailure={(error) => { if (__DEV__) console.warn('Lottie animation failed:', error); }} diff --git a/src/components/metadata/MetadataDetails.tsx b/src/components/metadata/MetadataDetails.tsx index cde9ab4d..8d869051 100644 --- a/src/components/metadata/MetadataDetails.tsx +++ b/src/components/metadata/MetadataDetails.tsx @@ -19,7 +19,7 @@ import Animated, { Extrapolate, } from 'react-native-reanimated'; import { useTheme } from '../../contexts/ThemeContext'; -import { isMDBListEnabled } from '../../screens/MDBListSettingsScreen'; +import { isMDBListEnabled } from '../../services/mdblistConstants'; import { getAgeRatingColor } from '../../utils/ageRatingColors'; import AgeRatingBadge from '../common/AgeRatingBadge'; diff --git a/src/components/metadata/RatingsSection.tsx b/src/components/metadata/RatingsSection.tsx index 6d552425..ea569ec0 100644 --- a/src/components/metadata/RatingsSection.tsx +++ b/src/components/metadata/RatingsSection.tsx @@ -5,7 +5,7 @@ import { useTheme } from '../../contexts/ThemeContext'; import FastImage from '@d11/react-native-fast-image'; import { useMDBListRatings } from '../../hooks/useMDBListRatings'; import { mmkvStorage } from '../../services/mmkvStorage'; -import { isMDBListEnabled, RATING_PROVIDERS_STORAGE_KEY } from '../../screens/MDBListSettingsScreen'; +import { isMDBListEnabled, RATING_PROVIDERS_STORAGE_KEY } from '../../services/mdblistConstants'; // Import SVG icons import LetterboxdIcon from '../../../assets/rating-icons/letterboxd.svg'; diff --git a/src/components/metadata/SeriesContent.tsx b/src/components/metadata/SeriesContent.tsx index c0c23833..b2eb5c78 100644 --- a/src/components/metadata/SeriesContent.tsx +++ b/src/components/metadata/SeriesContent.tsx @@ -15,10 +15,18 @@ import { useFocusEffect } from '@react-navigation/native'; import Animated, { FadeIn, FadeOut, SlideInRight, SlideOutLeft } from 'react-native-reanimated'; import { TraktService } from '../../services/traktService'; import { watchedService } from '../../services/watchedService'; -import { logger } from '../../utils/logger'; import { mmkvStorage } from '../../services/mmkvStorage'; import { MalSync } from '../../services/mal/MalSync'; +const noop = (..._args: unknown[]) => {}; +const logger = { + log: noop, + error: noop, + warn: noop, + info: noop, + debug: noop, +}; + // ... other imports const BREAKPOINTS = { phone: 0, @@ -212,10 +220,10 @@ const SeriesContentComponent: React.FC = ({ const savedMode = await mmkvStorage.getItem('global_season_view_mode'); if (savedMode === 'text' || savedMode === 'posters') { setSeasonViewMode(savedMode); - if (__DEV__) console.log('[SeriesContent] Loaded global view mode:', savedMode); + if (__DEV__) logger.log('[SeriesContent] Loaded global view mode:', savedMode); } } catch (error) { - if (__DEV__) console.log('[SeriesContent] Error loading global view mode preference:', error); + if (__DEV__) logger.log('[SeriesContent] Error loading global view mode preference:', error); } }; @@ -239,7 +247,7 @@ const SeriesContentComponent: React.FC = ({ const updateViewMode = (newMode: 'posters' | 'text') => { setSeasonViewMode(newMode); mmkvStorage.setItem('global_season_view_mode', newMode).catch((error: any) => { - if (__DEV__) console.log('[SeriesContent] Error saving global view mode preference:', error); + if (__DEV__) logger.log('[SeriesContent] Error saving global view mode preference:', error); }); }; @@ -491,7 +499,7 @@ const SeriesContentComponent: React.FC = ({ useEffect(() => { return () => { // Clear any pending timeouts - if (__DEV__) console.log('[SeriesContent] Component unmounted, cleaning up memory'); + if (__DEV__) logger.log('[SeriesContent] Component unmounted, cleaning up memory'); // Force garbage collection if available (development only) if (__DEV__ && global.gc) { @@ -854,7 +862,7 @@ const SeriesContentComponent: React.FC = ({ onPress={() => { const newMode = seasonViewMode === 'posters' ? 'text' : 'posters'; updateViewMode(newMode); - if (__DEV__) console.log('[SeriesContent] View mode changed to:', newMode, 'Current ref value:', seasonViewMode); + if (__DEV__) logger.log('[SeriesContent] View mode changed to:', newMode, 'Current ref value:', seasonViewMode); }} activeOpacity={0.7} > diff --git a/src/components/metadata/TrailersSection.tsx b/src/components/metadata/TrailersSection.tsx index 579a51cc..ca0b789d 100644 --- a/src/components/metadata/TrailersSection.tsx +++ b/src/components/metadata/TrailersSection.tsx @@ -17,12 +17,20 @@ import FastImage from '@d11/react-native-fast-image'; import { useTheme } from '../../contexts/ThemeContext'; import { useSettings } from '../../hooks/useSettings'; import { useTrailer } from '../../contexts/TrailerContext'; -import { logger } from '../../utils/logger'; import TrailerService from '../../services/trailerService'; import { TMDBService } from '../../services/tmdbService'; import TrailerModal from './TrailerModal'; import Animated, { useSharedValue, withTiming, withDelay, useAnimatedStyle } from 'react-native-reanimated'; +const noop = (..._args: unknown[]) => {}; +const logger = { + log: noop, + error: noop, + warn: noop, + info: noop, + debug: noop, +}; + // Enhanced responsive breakpoints for Trailers Section const BREAKPOINTS = { phone: 0, diff --git a/src/components/promotions/CampaignManager.tsx b/src/components/promotions/CampaignManager.tsx index 725f0fe1..d24cfabe 100644 --- a/src/components/promotions/CampaignManager.tsx +++ b/src/components/promotions/CampaignManager.tsx @@ -216,11 +216,9 @@ export const CampaignManager: React.FC = () => { const checkForCampaigns = useCallback(async () => { try { - console.log('[CampaignManager] Checking for campaigns...'); await new Promise(resolve => setTimeout(resolve, 1500)); const campaign = await campaignService.getActiveCampaign(); - console.log('[CampaignManager] Got campaign:', campaign?.id, campaign?.type); if (campaign) { setActiveCampaign(campaign); diff --git a/src/hooks/useMDBListRatings.ts b/src/hooks/useMDBListRatings.ts index 46bcc868..d866f5ff 100644 --- a/src/hooks/useMDBListRatings.ts +++ b/src/hooks/useMDBListRatings.ts @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react'; import { mdblistService, MDBListRatings } from '../services/mdblistService'; import { logger } from '../utils/logger'; -import { isMDBListEnabled } from '../screens/MDBListSettingsScreen'; +import { isMDBListEnabled } from '../services/mdblistConstants'; export const useMDBListRatings = (imdbId: string, mediaType: 'movie' | 'show') => { const [ratings, setRatings] = useState(null); diff --git a/src/hooks/useMetadata.ts b/src/hooks/useMetadata.ts index 0b5c90c8..291c8293 100644 --- a/src/hooks/useMetadata.ts +++ b/src/hooks/useMetadata.ts @@ -7,7 +7,6 @@ import { cacheService } from '../services/cacheService'; import { localScraperService, ScraperInfo } from '../services/pluginService'; import { Cast, Episode, GroupedEpisodes, GroupedStreams } from '../types/metadata'; import { TMDBService } from '../services/tmdbService'; -import { logger } from '../utils/logger'; import { usePersistentSeasons } from './usePersistentSeasons'; import { mmkvStorage } from '../services/mmkvStorage'; import { Stream } from '../types/metadata'; @@ -15,6 +14,15 @@ import { storageService } from '../services/storageService'; import { useSettings } from './useSettings'; import { MalSync } from '../services/mal/MalSync'; +const noop = (..._args: unknown[]) => {}; +const logger = { + log: noop, + error: noop, + warn: noop, + info: noop, + debug: noop, +}; + // Constants for timeouts and retries const API_TIMEOUT = 10000; // 10 seconds const MAX_RETRIES = 1; // Reduced since stremioService already retries @@ -163,8 +171,6 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat // Memory optimization: Stream cleanup and garbage collection const cleanupStreams = useCallback(() => { - if (__DEV__) console.log('[useMetadata] Running stream cleanup to free memory'); - // Clear preloaded streams cache setPreloadedStreams({}); setPreloadedEpisodeStreams({}); @@ -222,25 +228,11 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat (streams, addonId, addonName, error, installationId) => { const processTime = Date.now() - sourceStartTime; - console.log('🔍 [processStremioSource] Callback received:', { - addonId, - addonName, - installationId, - streamCount: streams?.length || 0, - error: error?.message || null, - processTime - }); - // ALWAYS remove from active fetching list when callback is received // This ensures that even failed scrapers are removed from the "Fetching from:" chip if (addonName) { setActiveFetchingScrapers(prev => { const updated = prev.filter(name => name !== addonName); - console.log('🔍 [processStremioSource] Removing from activeFetchingScrapers:', { - addonName, - before: prev, - after: updated - }); return updated; }); } @@ -496,18 +488,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat const loadMetadata = async () => { try { - console.log('🚀 [useMetadata] loadMetadata CALLED for:', { id, type }); - console.log('🔍 [useMetadata] loadMetadata started:', { - id, - type, - addonId, - loadAttempts, - maxRetries: MAX_RETRIES, - settingsLoaded: settingsLoaded - }); - if (loadAttempts >= MAX_RETRIES) { - console.log('🔍 [useMetadata] Max retries exceeded:', { loadAttempts, maxRetries: MAX_RETRIES }); setError(`Failed to load content after ${MAX_RETRIES + 1} attempts. Please check your connection and try again.`); setLoading(false); return; @@ -520,14 +501,6 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat // Check metadata screen cache const cachedScreen = cacheService.getMetadataScreen(id, normalizedType); if (cachedScreen) { - console.log('🔍 [useMetadata] Using cached metadata:', { - id, - type, - hasMetadata: !!cachedScreen.metadata, - hasCast: !!cachedScreen.cast, - hasEpisodes: !!cachedScreen.episodes, - tmdbId: cachedScreen.tmdbId - }); setMetadata(cachedScreen.metadata); setCast(cachedScreen.cast); if (normalizedType === 'series' && cachedScreen.episodes) { @@ -545,7 +518,6 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat setLoading(false); return; } else { - console.log('🔍 [useMetadata] No cached metadata found, proceeding with fresh fetch'); } // Handle TMDB-specific IDs @@ -556,7 +528,6 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat // STRICT MODE: Do NOT convert to IMDb/Cinemeta. // We want to force the app to use AnimeKitsu (or other MAL-compatible addons) for metadata. // This ensures we get correct Season/Episode mapping (Separate entries) instead of Cinemeta's "S1E26" mess. - console.log('🔍 [useMetadata] Keeping MAL ID for metadata fetch:', id); // Note: Stream fetching (stremioService) WILL still convert this to IMDb secretly // to ensure Torrentio works, but the Metadata UI will stay purely MAL-based. @@ -564,13 +535,10 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat if (id.startsWith('tmdb:')) { // Always try the original TMDB ID first - let addons decide if they support it - console.log('🔍 [useMetadata] TMDB ID detected, trying original ID first:', { originalId: id }); - // If enrichment disabled, try original ID first, then fallback to conversion if needed if (!settings.enrichMetadataWithTMDB) { // Keep the original TMDB ID - let the addon system handle it dynamically actualId = id; - console.log('🔍 [useMetadata] TMDB enrichment disabled, using original TMDB ID:', { actualId }); } else { const tmdbId = id.split(':')[1]; // For TMDB IDs, we need to handle metadata differently @@ -730,7 +698,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat // Load series data (episodes) setTmdbId(parseInt(tmdbId)); - loadSeriesData().catch((error) => { if (__DEV__) console.error(error); }); + loadSeriesData().catch((error) => { if (__DEV__) logger.error(error); }); (async () => { const items = await catalogService.getLibraryItems(); @@ -749,7 +717,6 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat } // Load all data in parallel - console.log('🔍 [useMetadata] Starting parallel data fetch:', { type, actualId, addonId, apiTimeout: API_TIMEOUT }); if (__DEV__) logger.log('[loadMetadata] fetching addon metadata', { type, actualId, addonId }); let contentResult: any = null; @@ -761,7 +728,6 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat if (preferExternal) { // Try external meta addons first try { - console.log('🔍 [useMetadata] Trying external meta addons first'); const [content, castData] = await Promise.allSettled([ withRetry(async () => { // Get all installed addons @@ -791,20 +757,17 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat ); if (result) { - console.log('🔍 [useMetadata] Got metadata from external addon:', addon.name); if (actualId.startsWith('tt')) { setImdbId(actualId); } return result; } } catch (error) { - console.log('🔍 [useMetadata] External addon failed:', addon.name, error); continue; } } // If no external addon worked, fall back to catalog addon - console.log('🔍 [useMetadata] No external meta addon worked, falling back to catalog addon'); const result = await withTimeout( catalogService.getEnhancedContentDetails(normalizedType, actualId, addonId), API_TIMEOUT @@ -819,39 +782,29 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat contentResult = content; if (content.status === 'fulfilled' && content.value) { - console.log('🔍 [useMetadata] Successfully got metadata with external meta addon priority'); + if (__DEV__) { + logger.log('[useMetadata] External meta addon priority success'); + } } else { - console.log('🔍 [useMetadata] External meta addon priority failed, will try fallback'); lastError = (content as any)?.reason; } } catch (error) { - console.log('🔍 [useMetadata] External meta addon attempt failed:', { error: error instanceof Error ? error.message : String(error) }); lastError = error; } } else { // Original behavior: try with original ID first try { - console.log('🔍 [useMetadata] Attempting metadata fetch with original ID:', { type, actualId, addonId }); const [content, castData] = await Promise.allSettled([ // Load content with timeout and retry withRetry(async () => { - console.log('⚡ [useMetadata] Calling catalogService.getEnhancedContentDetails...'); - console.log('🔍 [useMetadata] Calling catalogService.getEnhancedContentDetails:', { type, actualId, addonId }); const result = await withTimeout( catalogService.getEnhancedContentDetails(normalizedType, actualId, addonId), API_TIMEOUT ); - console.log('✅ [useMetadata] catalogService returned:', result ? 'DATA' : 'NULL'); // Store the actual ID used (could be IMDB) if (actualId.startsWith('tt')) { setImdbId(actualId); } - console.log('🔍 [useMetadata] catalogService.getEnhancedContentDetails result:', { - hasResult: Boolean(result), - resultId: result?.id, - resultName: result?.name, - resultType: result?.type - }); if (__DEV__) logger.log('[loadMetadata] addon metadata fetched', { hasResult: Boolean(result) }); return result; }), @@ -861,13 +814,13 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat contentResult = content; if (content.status === 'fulfilled' && content.value) { - console.log('🔍 [useMetadata] Successfully got metadata with original ID'); + if (__DEV__) { + logger.log('[useMetadata] Original ID metadata fetch succeeded'); + } } else { - console.log('🔍 [useMetadata] Original ID failed, will try fallback conversion'); lastError = (content as any)?.reason; } } catch (error) { - console.log('🔍 [useMetadata] Original ID attempt failed:', { error: error instanceof Error ? error.message : String(error) }); lastError = error; } } @@ -875,12 +828,10 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat // If original TMDB ID failed and enrichment is disabled, try ID conversion as fallback if (!contentResult || (contentResult.status === 'fulfilled' && !contentResult.value) || contentResult.status === 'rejected') { if (id.startsWith('tmdb:') && !settings.enrichMetadataWithTMDB) { - console.log('🔍 [useMetadata] Original TMDB ID failed, trying ID conversion fallback'); const tmdbRaw = id.split(':')[1]; try { const stremioId = await catalogService.getStremioId(normalizedType === 'series' ? 'tv' : 'movie', tmdbRaw); if (stremioId && stremioId !== id) { - console.log('🔍 [useMetadata] Trying converted ID:', { originalId: id, convertedId: stremioId }); const [content, castData] = await Promise.allSettled([ withRetry(async () => { const result = await withTimeout( @@ -897,7 +848,9 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat contentResult = content; } } catch (e) { - console.log('🔍 [useMetadata] ID conversion fallback also failed:', { error: e instanceof Error ? e.message : String(e) }); + if (__DEV__) { + logger.log('[useMetadata] ID conversion fallback failed'); + } } } } @@ -905,22 +858,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat const content = contentResult || { status: 'rejected' as const, reason: lastError || new Error('No content result') }; const castData = { status: 'fulfilled' as const, value: undefined }; - console.log('🔍 [useMetadata] Promise.allSettled results:', { - contentStatus: content.status, - contentFulfilled: content.status === 'fulfilled', - hasContentValue: content.status === 'fulfilled' ? !!content.value : false, - castStatus: castData.status, - castFulfilled: castData.status === 'fulfilled' - }); - if (content.status === 'fulfilled' && content.value) { - console.log('🔍 [useMetadata] Content fetch successful:', { - id: content.value?.id, - type: content.value?.type, - name: content.value?.name, - hasDescription: !!content.value?.description, - hasPoster: !!content.value?.poster - }); if (__DEV__) logger.log('[loadMetadata] addon metadata:success', { id: content.value?.id, type: content.value?.type, name: content.value?.name }); // Start with addon metadata @@ -1025,7 +963,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat } } } catch (e) { - if (__DEV__) console.log('[useMetadata] failed to merge TMDB title/description', e); + if (__DEV__) logger.log('[useMetadata] failed to merge TMDB title/description', e); } // Centralized logo fetching logic @@ -1050,7 +988,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat // Use TMDB logo if found, otherwise fall back to addon logo finalMetadata.logo = logoUrl || addonLogo || undefined; if (__DEV__) { - console.log('[useMetadata] Logo fetch result:', { + logger.log('[useMetadata] Logo fetch result:', { contentType, tmdbIdForLogo, preferredLanguage, @@ -1062,13 +1000,13 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat } else { // No TMDB ID, fall back to addon logo finalMetadata.logo = addonLogo || undefined; - if (__DEV__) console.log('[useMetadata] No TMDB ID found for logo, using addon logo'); + if (__DEV__) logger.log('[useMetadata] No TMDB ID found for logo, using addon logo'); } } else { // When enrichment or logos is OFF, use addon logo finalMetadata.logo = addonLogo || finalMetadata.logo || undefined; if (__DEV__) { - console.log('[useMetadata] TMDB logo enrichment disabled, using addon logo:', { + logger.log('[useMetadata] TMDB logo enrichment disabled, using addon logo:', { hasAddonLogo: !!finalMetadata.logo, enrichmentEnabled: settings.enrichMetadataWithTMDB, logosEnabled: settings.tmdbEnrichLogos @@ -1077,7 +1015,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat } } catch (error) { // Handle error silently, keep existing logo behavior - if (__DEV__) console.error('[useMetadata] Unexpected error in logo fetch:', error); + if (__DEV__) logger.error('[useMetadata] Unexpected error in logo fetch:', error); finalMetadata.logo = undefined; } @@ -1114,17 +1052,8 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat const reason = (content as any)?.reason; const reasonMessage = reason?.message || String(reason); - console.log('🔍 [useMetadata] Content fetch failed:', { - status: content.status, - reason: reasonMessage, - fullReason: reason, - isAxiosError: reason?.isAxiosError, - responseStatus: reason?.response?.status, - responseData: reason?.response?.data - }); - if (__DEV__) { - console.log('[loadMetadata] addon metadata:not found or failed', { + logger.log('[loadMetadata] addon metadata:not found or failed', { status: content.status, reason: reasonMessage, fullReason: reason @@ -1139,28 +1068,23 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat reasonMessage.includes('Network Error') || reasonMessage.includes('Request failed') )) { - console.log('🔍 [useMetadata] Detected server/network error, preserving original error'); // This was a server/network error, preserve the original error message throw reason instanceof Error ? reason : new Error(reasonMessage); } else { - console.log('🔍 [useMetadata] Detected content not found error, throwing generic error'); // This was likely a content not found error throw new Error('Content not found'); } } } catch (error) { - console.log('🔍 [useMetadata] loadMetadata caught error:', { - errorMessage: error instanceof Error ? error.message : String(error), - errorType: typeof error, - isAxiosError: (error as any)?.isAxiosError, - responseStatus: (error as any)?.response?.status, - responseData: (error as any)?.response?.data, - stack: error instanceof Error ? error.stack : undefined - }); - if (__DEV__) { - console.error('Failed to load metadata:', error); - console.log('Error message being set:', error instanceof Error ? error.message : String(error)); + logger.log('[loadMetadata] failed with error', { + errorMessage: error instanceof Error ? error.message : String(error), + errorType: typeof error, + isAxiosError: (error as any)?.isAxiosError, + responseStatus: (error as any)?.response?.status, + responseData: (error as any)?.response?.data, + stack: error instanceof Error ? error.stack : undefined + }); } // Preserve the original error details for better error parsing @@ -1173,7 +1097,6 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat setGroupedEpisodes({}); setEpisodes([]); } finally { - console.log('🔍 [useMetadata] loadMetadata completed, setting loading to false'); setLoading(false); } }; @@ -1309,7 +1232,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat if (__DEV__) logger.log('[useMetadata] merged episode names/overviews from TMDB (batch)'); } } catch (e) { - if (__DEV__) console.log('[useMetadata] failed to merge episode text from TMDB', e); + if (__DEV__) logger.log('[useMetadata] failed to merge episode text from TMDB', e); } } @@ -1477,7 +1400,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat } } } catch (error) { - if (__DEV__) console.error('Failed to load episodes:', error); + if (__DEV__) logger.error('Failed to load episodes:', error); } finally { setLoadingSeasons(false); } @@ -1531,7 +1454,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat } if (allEmbeddedStreams.length > 0) { - if (__DEV__) console.log(`✅ [extractEmbeddedStreams] Found ${allEmbeddedStreams.length} embedded streams from ${addonName}`); + if (__DEV__) logger.log(`✅ [extractEmbeddedStreams] Found ${allEmbeddedStreams.length} embedded streams from ${addonName}`); // Add to grouped streams setGroupedStreams(prevStreams => ({ @@ -1566,7 +1489,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat ); if (episodeVideo && episodeVideo.streams && episodeVideo.streams.length > 0) { - if (__DEV__) console.log(`✅ [extractEmbeddedStreams] Found embedded streams for episode ${episodeToUse}`); + if (__DEV__) logger.log(`✅ [extractEmbeddedStreams] Found embedded streams for episode ${episodeToUse}`); const episodeStreamsList: Stream[] = episodeVideo.streams.map((stream: any) => ({ ...stream, @@ -1592,7 +1515,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat const loadStreams = async () => { const startTime = Date.now(); try { - if (__DEV__) console.log('🚀 [loadStreams] START - Loading streams for:', id); + if (__DEV__) logger.log('🚀 [loadStreams] START - Loading streams for:', id); updateLoadingState(); // Reset scraper tracking @@ -1600,22 +1523,22 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat setActiveFetchingScrapers([]); setAddonResponseOrder([]); // Reset response order - if (__DEV__) console.log('🔍 [loadStreams] Getting TMDB ID for:', id); + if (__DEV__) logger.log('🔍 [loadStreams] Getting TMDB ID for:', id); let tmdbId; let stremioId = id; let effectiveStreamType: string = type; if (id.startsWith('tmdb:')) { tmdbId = id.split(':')[1]; - if (__DEV__) console.log('✅ [loadStreams] Using TMDB ID from ID:', tmdbId); + if (__DEV__) logger.log('✅ [loadStreams] Using TMDB ID from ID:', tmdbId); // Try to get IMDb ID from metadata first, then convert if needed if (metadata?.imdb_id) { stremioId = metadata.imdb_id; - if (__DEV__) console.log('✅ [loadStreams] Using IMDb ID from metadata for Stremio:', stremioId); + if (__DEV__) logger.log('✅ [loadStreams] Using IMDb ID from metadata for Stremio:', stremioId); } else if (imdbId) { stremioId = imdbId; - if (__DEV__) console.log('✅ [loadStreams] Using stored IMDb ID for Stremio:', stremioId); + if (__DEV__) logger.log('✅ [loadStreams] Using stored IMDb ID for Stremio:', stremioId); } else { // Convert TMDB ID to IMDb ID for Stremio addons (they expect IMDb format) try { @@ -1629,28 +1552,28 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat if (externalIds?.imdb_id) { stremioId = externalIds.imdb_id; - if (__DEV__) console.log('✅ [loadStreams] Converted TMDB to IMDb ID for Stremio:', stremioId); + if (__DEV__) logger.log('✅ [loadStreams] Converted TMDB to IMDb ID for Stremio:', stremioId); } else { - if (__DEV__) console.log('⚠️ [loadStreams] No IMDb ID found for TMDB ID, using original:', stremioId); + if (__DEV__) logger.log('⚠️ [loadStreams] No IMDb ID found for TMDB ID, using original:', stremioId); } } catch (error) { - if (__DEV__) console.log('⚠️ [loadStreams] Failed to convert TMDB to IMDb, using original ID:', error); + if (__DEV__) logger.log('⚠️ [loadStreams] Failed to convert TMDB to IMDb, using original ID:', error); } } } else if (id.startsWith('tt')) { // This is already an IMDB ID, perfect for Stremio stremioId = id; if (settings.enrichMetadataWithTMDB) { - if (__DEV__) console.log('📝 [loadStreams] Converting IMDB ID to TMDB ID...'); + if (__DEV__) logger.log('📝 [loadStreams] Converting IMDB ID to TMDB ID...'); tmdbId = await withTimeout(tmdbService.findTMDBIdByIMDB(id), API_TIMEOUT); - if (__DEV__) console.log('✅ [loadStreams] Converted to TMDB ID:', tmdbId); + if (__DEV__) logger.log('✅ [loadStreams] Converted to TMDB ID:', tmdbId); } else { - if (__DEV__) console.log('📝 [loadStreams] TMDB enrichment disabled, skipping IMDB to TMDB conversion'); + if (__DEV__) logger.log('📝 [loadStreams] TMDB enrichment disabled, skipping IMDB to TMDB conversion'); } } else { tmdbId = id; stremioId = id; - if (__DEV__) console.log('ℹ️ [loadStreams] Using ID as both TMDB and Stremio ID:', tmdbId); + if (__DEV__) logger.log('ℹ️ [loadStreams] Using ID as both TMDB and Stremio ID:', tmdbId); } // Initialize scraper tracking @@ -1710,14 +1633,14 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat if (fallback.length > 0) { effectiveStreamType = fallbackType; eligibleStreamAddons = fallback; - if (__DEV__) console.log(`[useMetadata.loadStreams] No addons for '${requestedStreamType}', falling back to '${fallbackType}'`); + if (__DEV__) logger.log(`[useMetadata.loadStreams] No addons for '${requestedStreamType}', falling back to '${fallbackType}'`); break; } } } const streamAddons = eligibleStreamAddons; - if (__DEV__) console.log('[useMetadata.loadStreams] Eligible stream addons:', streamAddons.map(a => a.id), { requestedStreamType, effectiveStreamType }); + if (__DEV__) logger.log('[useMetadata.loadStreams] Eligible stream addons:', streamAddons.map(a => a.id), { requestedStreamType, effectiveStreamType }); // Initialize scraper statuses for tracking const initialStatuses: ScraperStatus[] = []; @@ -1764,11 +1687,11 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat setLoadingStreams(false); } } catch (error) { - if (__DEV__) console.error('Failed to initialize scraper tracking:', error); + if (__DEV__) logger.error('Failed to initialize scraper tracking:', error); } // Start Stremio request using the converted ID format - if (__DEV__) console.log('🎬 [loadStreams] Using ID for Stremio addons:', stremioId); + if (__DEV__) logger.log('🎬 [loadStreams] Using ID for Stremio addons:', stremioId); // Use the effective type we selected when building the eligible addon list. // This stays aligned with Stremio manifest filtering rules and avoids hard-mapping non-standard types. processStremioSource(effectiveStreamType, stremioId, false); @@ -1807,7 +1730,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat }, 60000); } catch (error) { - if (__DEV__) console.error('❌ [loadStreams] Failed to load streams:', error); + if (__DEV__) logger.error('❌ [loadStreams] Failed to load streams:', error); // Preserve the original error details for better error parsing const errorMessage = error instanceof Error ? error.message : 'Failed to load streams'; setError(errorMessage); @@ -1818,7 +1741,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat const loadEpisodeStreams = async (episodeId: string) => { const startTime = Date.now(); try { - if (__DEV__) console.log('🚀 [loadEpisodeStreams] START - Loading episode streams for:', episodeId); + if (__DEV__) logger.log('🚀 [loadEpisodeStreams] START - Loading episode streams for:', episodeId); updateEpisodeLoadingState(); // Reset scraper tracking for episodes @@ -1861,7 +1784,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat const fallback = pickStreamCapableAddons(fallbackType); if (fallback.length > 0) { streamAddons = fallback; - if (__DEV__) console.log(`[useMetadata.loadEpisodeStreams] No addons for '${requestedEpisodeType}', falling back to '${fallbackType}'`); + if (__DEV__) logger.log(`[useMetadata.loadEpisodeStreams] No addons for '${requestedEpisodeType}', falling back to '${fallbackType}'`); break; } } @@ -1912,12 +1835,12 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat setLoadingEpisodeStreams(false); } } catch (error) { - if (__DEV__) console.error('Failed to initialize episode scraper tracking:', error); + if (__DEV__) logger.error('Failed to initialize episode scraper tracking:', error); } // Get TMDB ID for external sources and determine the correct ID for Stremio addons const isImdb = id.startsWith('tt'); - if (__DEV__) console.log('🔍 [loadEpisodeStreams] Getting TMDB ID for:', id); + if (__DEV__) logger.log('🔍 [loadEpisodeStreams] Getting TMDB ID for:', id); let tmdbId; let stremioEpisodeId = episodeId; // Default to original episode ID let isCollection = false; @@ -1965,13 +1888,13 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat showIdStr = parts.join(':'); } - if (__DEV__) console.log(`🔍 [loadEpisodeStreams] Parsed ID: show=${showIdStr}, s=${seasonNum}, e=${episodeNum}`); + if (__DEV__) logger.log(`🔍 [loadEpisodeStreams] Parsed ID: show=${showIdStr}, s=${seasonNum}, e=${episodeNum}`); } catch (e) { - if (__DEV__) console.warn('⚠️ [loadEpisodeStreams] Failed to parse episode ID:', episodeId); + if (__DEV__) logger.warn('⚠️ [loadEpisodeStreams] Failed to parse episode ID:', episodeId); } if (isCollection && collectionAddon) { - if (__DEV__) console.log(`🎬 [loadEpisodeStreams] Detected collection from addon: ${collectionAddon.name}, treating episodes as individual movies`); + if (__DEV__) logger.log(`🎬 [loadEpisodeStreams] Detected collection from addon: ${collectionAddon.name}, treating episodes as individual movies`); // For collections, extract the individual movie ID from the episodeId // episodeId format for collections: "tt7888964" (IMDb ID of individual movie) @@ -1981,7 +1904,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat tmdbId = await withTimeout(tmdbService.findTMDBIdByIMDB(episodeId), API_TIMEOUT); } stremioEpisodeId = episodeId; // Use the IMDb ID directly for Stremio addons - if (__DEV__) console.log('✅ [loadEpisodeStreams] Collection movie - using IMDb ID:', episodeId, 'TMDB ID:', tmdbId); + if (__DEV__) logger.log('✅ [loadEpisodeStreams] Collection movie - using IMDb ID:', episodeId, 'TMDB ID:', tmdbId); } else { // Fallback: try to verify if it's a tmdb id const isTmdb = episodeId.startsWith('tmdb:') || !isNaN(Number(episodeId)); @@ -1992,20 +1915,20 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat } else { stremioEpisodeId = episodeId; } - if (__DEV__) console.log('⚠️ [loadEpisodeStreams] Collection movie - using episodeId as-is:', episodeId); + if (__DEV__) logger.log('⚠️ [loadEpisodeStreams] Collection movie - using episodeId as-is:', episodeId); } } else if (id.startsWith('tmdb:')) { tmdbId = id.split(':')[1]; - if (__DEV__) console.log('✅ [loadEpisodeStreams] Using TMDB ID from ID:', tmdbId); + if (__DEV__) logger.log('✅ [loadEpisodeStreams] Using TMDB ID from ID:', tmdbId); // Try to get IMDb ID from metadata first, then convert if needed if (metadata?.imdb_id) { // Use format: imdb_id:season:episode stremioEpisodeId = `${metadata.imdb_id}:${seasonNum}:${episodeNum}`; - if (__DEV__) console.log('✅ [loadEpisodeStreams] Using IMDb ID from metadata for Stremio episode:', stremioEpisodeId); + if (__DEV__) logger.log('✅ [loadEpisodeStreams] Using IMDb ID from metadata for Stremio episode:', stremioEpisodeId); } else if (imdbId) { stremioEpisodeId = `${imdbId}:${seasonNum}:${episodeNum}`; - if (__DEV__) console.log('✅ [loadEpisodeStreams] Using stored IMDb ID for Stremio episode:', stremioEpisodeId); + if (__DEV__) logger.log('✅ [loadEpisodeStreams] Using stored IMDb ID for Stremio episode:', stremioEpisodeId); } else { // Convert TMDB ID to IMDb ID for Stremio addons try { @@ -2013,27 +1936,27 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat if (externalIds?.imdb_id) { stremioEpisodeId = `${externalIds.imdb_id}:${seasonNum}:${episodeNum}`; - if (__DEV__) console.log('✅ [loadEpisodeStreams] Converted TMDB to IMDb ID for Stremio episode:', stremioEpisodeId); + if (__DEV__) logger.log('✅ [loadEpisodeStreams] Converted TMDB to IMDb ID for Stremio episode:', stremioEpisodeId); } else { // Fallback to TMDB format if conversions fail // e.g. tmdb:123:1:1 stremioEpisodeId = `${id}:${seasonNum}:${episodeNum}`; - if (__DEV__) console.log('⚠️ [loadEpisodeStreams] No IMDb ID found for TMDB ID, using TMDB episode ID:', stremioEpisodeId); + if (__DEV__) logger.log('⚠️ [loadEpisodeStreams] No IMDb ID found for TMDB ID, using TMDB episode ID:', stremioEpisodeId); } } catch (error) { stremioEpisodeId = `${id}:${seasonNum}:${episodeNum}`; - if (__DEV__) console.log('⚠️ [loadEpisodeStreams] Failed to convert TMDB to IMDb, using TMDB episode ID:', error); + if (__DEV__) logger.log('⚠️ [loadEpisodeStreams] Failed to convert TMDB to IMDb, using TMDB episode ID:', error); } } } else if (isImdb) { // This is already an IMDB ID, perfect for Stremio if (settings.enrichMetadataWithTMDB) { - if (__DEV__) console.log('📝 [loadEpisodeStreams] Converting IMDB ID to TMDB ID...'); + if (__DEV__) logger.log('📝 [loadEpisodeStreams] Converting IMDB ID to TMDB ID...'); tmdbId = await withTimeout(tmdbService.findTMDBIdByIMDB(id), API_TIMEOUT); } else { - if (__DEV__) console.log('📝 [loadEpisodeStreams] TMDB enrichment disabled, skipping IMDB to TMDB conversion'); + if (__DEV__) logger.log('📝 [loadEpisodeStreams] TMDB enrichment disabled, skipping IMDB to TMDB conversion'); } - if (__DEV__) console.log('✅ [loadEpisodeStreams] Converted to TMDB ID:', tmdbId); + if (__DEV__) logger.log('✅ [loadEpisodeStreams] Converted to TMDB ID:', tmdbId); // Ensure consistent format or fallback to episodeId if parsing failed. // If the episode's namespace differs from the show's tt id (e.g. kitsu:48363:8 @@ -2048,7 +1971,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat const baseId = showIdStr && showIdStr !== id ? showIdStr : id; stremioEpisodeId = `${baseId}:${seasonNum}:${episodeNum}`; } - if (__DEV__) console.log('🔧 [loadEpisodeStreams] Normalized episode ID for addons:', stremioEpisodeId); + if (__DEV__) logger.log('🔧 [loadEpisodeStreams] Normalized episode ID for addons:', stremioEpisodeId); } else { tmdbId = id; // If season/episode parsing failed (empty strings), use the raw episode ID @@ -2067,22 +1990,22 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat const baseId = showIdStr && showIdStr !== id ? showIdStr : id; stremioEpisodeId = `${baseId}:${seasonNum}:${episodeNum}`; } - if (__DEV__) console.log('ℹ️ [loadEpisodeStreams] Using ID as both TMDB and Stremio ID:', tmdbId, '| stremioEpisodeId:', stremioEpisodeId); + if (__DEV__) logger.log('ℹ️ [loadEpisodeStreams] Using ID as both TMDB and Stremio ID:', tmdbId, '| stremioEpisodeId:', stremioEpisodeId); } // Extract episode info from the episodeId for logging const episodeQuery = `?s=${seasonNum}&e=${episodeNum}`; - if (__DEV__) console.log(`ℹ️ [loadEpisodeStreams] Episode query: ${episodeQuery}`); + if (__DEV__) logger.log(`ℹ️ [loadEpisodeStreams] Episode query: ${episodeQuery}`); - if (__DEV__) console.log('🔄 [loadEpisodeStreams] Starting stream requests'); + if (__DEV__) logger.log('🔄 [loadEpisodeStreams] Starting stream requests'); // Start Stremio request using the converted episode ID format - if (__DEV__) console.log('🎬 [loadEpisodeStreams] Using episode ID for Stremio addons:', stremioEpisodeId); + if (__DEV__) logger.log('🎬 [loadEpisodeStreams] Using episode ID for Stremio addons:', stremioEpisodeId); // For collections, treat episodes as individual movies, not series // For other types (e.g. StreamsPPV), preserve the original type unless it's explicitly 'series' logic we want const contentType = isCollection ? 'movie' : type; - if (__DEV__) console.log(`🎬 [loadEpisodeStreams] Using content type: ${contentType} for ${isCollection ? 'collection' : type}`); + if (__DEV__) logger.log(`🎬 [loadEpisodeStreams] Using content type: ${contentType} for ${isCollection ? 'collection' : type}`); processStremioSource(contentType, stremioEpisodeId, true); @@ -2121,7 +2044,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat }, 60000); } catch (error) { - if (__DEV__) console.error('❌ [loadEpisodeStreams] Failed to load episode streams:', error); + if (__DEV__) logger.error('❌ [loadEpisodeStreams] Failed to load episode streams:', error); // Preserve the original error details for better error parsing const errorMessage = error instanceof Error ? error.message : 'Failed to load episode streams'; setError(errorMessage); @@ -2187,7 +2110,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat // This will be handled by the StreamsScreen component // The useMetadata hook focuses on metadata and episodes } catch (error) { - if (__DEV__) console.log('[useMetadata] Error checking cached streams on mount:', error); + if (__DEV__) logger.log('[useMetadata] Error checking cached streams on mount:', error); } }; @@ -2206,7 +2129,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat useEffect(() => { if (metadata && metadata.videos && metadata.videos.length > 0) { logger.log(`🎬 Metadata updated with ${metadata.videos.length} episodes, reloading series data`); - loadSeriesData().catch((error) => { if (__DEV__) console.error(error); }); + loadSeriesData().catch((error) => { if (__DEV__) logger.error(error); }); // Also extract embedded streams from metadata videos (PPV-style addons) extractEmbeddedStreams(); } @@ -2214,7 +2137,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat const loadRecommendations = useCallback(async () => { if (!settings.enrichMetadataWithTMDB) { - if (__DEV__) console.log('[useMetadata] enrichment disabled; skip recommendations'); + if (__DEV__) logger.log('[useMetadata] enrichment disabled; skip recommendations'); return; } if (!tmdbId) return; @@ -2236,7 +2159,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat setRecommendations(formattedRecommendations); } catch (error) { - if (__DEV__) console.error('Failed to load recommendations:', error); + if (__DEV__) logger.error('Failed to load recommendations:', error); setRecommendations([]); } finally { setLoadingRecommendations(false); @@ -2253,7 +2176,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat // For anime IDs we always try to resolve tmdbId and imdbId regardless of enrichment setting, // because they're needed for Trakt scrobbling even when TMDB enrichment is disabled. if (!settings.enrichMetadataWithTMDB && !isAnimeId) { - if (__DEV__) console.log('[useMetadata] enrichment disabled; skip TMDB id extraction (extract path)'); + if (__DEV__) logger.log('[useMetadata] enrichment disabled; skip TMDB id extraction (extract path)'); return; } @@ -2262,7 +2185,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat const tmdbSvc = TMDBService.getInstance(); const fetchedTmdbId = await tmdbSvc.extractTMDBIdFromStremioId(id); if (fetchedTmdbId) { - if (__DEV__) console.log('[useMetadata] extracted TMDB id from content id', { id, fetchedTmdbId }); + if (__DEV__) logger.log('[useMetadata] extracted TMDB id from content id', { id, fetchedTmdbId }); setTmdbId(fetchedTmdbId); // For anime IDs, also resolve the IMDb ID from TMDB external IDs so Trakt can scrobble @@ -2270,11 +2193,11 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat try { const externalIds = await tmdbSvc.getShowExternalIds(fetchedTmdbId); if (externalIds?.imdb_id) { - if (__DEV__) console.log('[useMetadata] resolved imdbId for anime via TMDB', { id, imdbId: externalIds.imdb_id }); + if (__DEV__) logger.log('[useMetadata] resolved imdbId for anime via TMDB', { id, imdbId: externalIds.imdb_id }); setImdbId(externalIds.imdb_id); } } catch (e) { - if (__DEV__) console.warn('[useMetadata] could not resolve imdbId from TMDB for anime ID', { id }); + if (__DEV__) logger.warn('[useMetadata] could not resolve imdbId from TMDB for anime ID', { id }); } } @@ -2289,24 +2212,24 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat if (settings.tmdbEnrichCertification) { const certification = await tmdbSvc.getCertification(normalizedType, fetchedTmdbId); if (certification) { - if (__DEV__) console.log('[useMetadata] fetched certification via TMDB id (extract path)', { type, fetchedTmdbId, certification }); + if (__DEV__) logger.log('[useMetadata] fetched certification via TMDB id (extract path)', { type, fetchedTmdbId, certification }); setMetadata(prev => prev ? { ...prev, tmdbId: fetchedTmdbId, certification } : null); } else { - if (__DEV__) console.warn('[useMetadata] certification not returned from TMDB (extract path)', { type, fetchedTmdbId }); + if (__DEV__) logger.warn('[useMetadata] certification not returned from TMDB (extract path)', { type, fetchedTmdbId }); } } else { // Just set the TMDB ID without certification setMetadata(prev => prev ? { ...prev, tmdbId: fetchedTmdbId } : null); } } else { - if (__DEV__) console.warn('[useMetadata] Could not determine TMDB ID for recommendations / certification', { id }); + if (__DEV__) logger.warn('[useMetadata] Could not determine TMDB ID for recommendations / certification', { id }); } } catch (error) { - if (__DEV__) console.error('[useMetadata] Error fetching TMDB ID (extract path):', error); + if (__DEV__) logger.error('[useMetadata] Error fetching TMDB ID (extract path):', error); } } }; @@ -2318,7 +2241,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat if (tmdbId) { // Check both master switch AND granular recommendations setting if (settings.enrichMetadataWithTMDB && settings.tmdbEnrichRecommendations) { - if (__DEV__) console.log('[useMetadata] tmdbId available; loading recommendations', { tmdbId }); + if (__DEV__) logger.log('[useMetadata] tmdbId available; loading recommendations', { tmdbId }); loadRecommendations(); } // Reset recommendations when tmdbId changes @@ -2343,32 +2266,32 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat const maybeAttachCertification = async () => { // Check both master switch AND granular certification setting if (!settings.enrichMetadataWithTMDB || !settings.tmdbEnrichCertification) { - if (__DEV__) console.log('[useMetadata] certification enrichment disabled; skip (attach path)'); + if (__DEV__) logger.log('[useMetadata] certification enrichment disabled; skip (attach path)'); return; } try { if (!metadata) { - if (__DEV__) console.warn('[useMetadata] skip certification attach: metadata not ready'); + if (__DEV__) logger.warn('[useMetadata] skip certification attach: metadata not ready'); return; } if (!tmdbId) { - if (__DEV__) console.warn('[useMetadata] skip certification attach: tmdbId not available yet'); + if (__DEV__) logger.warn('[useMetadata] skip certification attach: tmdbId not available yet'); return; } if ((metadata as any).certification) { - if (__DEV__) console.log('[useMetadata] certification already present on metadata; skipping fetch'); + if (__DEV__) logger.log('[useMetadata] certification already present on metadata; skipping fetch'); return; } const tmdbSvc = TMDBService.getInstance(); const cert = await tmdbSvc.getCertification(normalizedType, tmdbId); if (cert) { - if (__DEV__) console.log('[useMetadata] fetched certification (attach path)', { type, tmdbId, cert }); + if (__DEV__) logger.log('[useMetadata] fetched certification (attach path)', { type, tmdbId, cert }); setMetadata(prev => prev ? { ...prev, tmdbId, certification: cert } : prev); } else { - if (__DEV__) console.warn('[useMetadata] TMDB returned no certification (attach path)', { type, tmdbId }); + if (__DEV__) logger.warn('[useMetadata] TMDB returned no certification (attach path)', { type, tmdbId }); } } catch (err) { - if (__DEV__) console.error('[useMetadata] error attaching certification', err); + if (__DEV__) logger.error('[useMetadata] error attaching certification', err); } }; maybeAttachCertification(); @@ -2405,7 +2328,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat const tmdbService = TMDBService.getInstance(); let productionInfo: any[] = []; - if (__DEV__) console.log('[useMetadata] fetchProductionInfo starting', { + if (__DEV__) logger.log('[useMetadata] fetchProductionInfo starting', { contentKey, type, tmdbId, @@ -2423,7 +2346,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat const lang = settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en'; const showDetails = await tmdbService.getTVShowDetails(tmdbId, lang); if (showDetails) { - if (__DEV__) console.log('[useMetadata] fetchProductionInfo got showDetails', { + if (__DEV__) logger.log('[useMetadata] fetchProductionInfo got showDetails', { hasNetworks: !!showDetails.networks, networksCount: showDetails.networks?.length || 0 }); @@ -2472,7 +2395,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat const lang = settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en'; const movieDetails = await tmdbService.getMovieDetails(String(tmdbId), lang); if (movieDetails) { - if (__DEV__) console.log('[useMetadata] fetchProductionInfo got movieDetails', { + if (__DEV__) logger.log('[useMetadata] fetchProductionInfo got movieDetails', { hasProductionCompanies: !!movieDetails.production_companies, productionCompaniesCount: movieDetails.production_companies?.length || 0 }); @@ -2557,7 +2480,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat } } } catch (error) { - if (__DEV__) console.warn('[useMetadata] Failed to fetch movie images for:', part.id, error); + if (__DEV__) logger.warn('[useMetadata] Failed to fetch movie images for:', part.id, error); } return { @@ -2592,7 +2515,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat })); } } catch (error) { - if (__DEV__) console.error('[useMetadata] Error fetching collection:', error); + if (__DEV__) logger.error('[useMetadata] Error fetching collection:', error); } finally { setLoadingCollection(false); } @@ -2604,7 +2527,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat setMetadata((prev: any) => ({ ...prev, networks: productionInfo })); } } catch (error) { - if (__DEV__) console.error('[useMetadata] Failed to fetch production info:', error); + if (__DEV__) logger.error('[useMetadata] Failed to fetch production info:', error); } }; @@ -2641,7 +2564,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat // Reset production info fetch tracking productionInfoFetchedRef.current = null; - if (__DEV__) console.log('[useMetadata] Component unmounted, memory cleaned up'); + if (__DEV__) logger.log('[useMetadata] Component unmounted, memory cleaned up'); }; }, [cleanupStreams]); diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 39597426..b789cbed 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -1941,10 +1941,8 @@ const ConditionalPostHogProvider: React.FC<{ children: React.ReactNode }> = ({ c if (posthogRef.current) { if (settings.analyticsEnabled) { posthogRef.current.optIn(); - console.log('[Telemetry] PostHog opted in'); } else { posthogRef.current.optOut(); - console.log('[Telemetry] PostHog opted out'); } } } @@ -1996,10 +1994,8 @@ const PostHogOptController: React.FC<{ onPostHogReady(posthog); if (enabled) { posthog.optIn(); - console.log('[Telemetry] PostHog opted in'); } else { posthog.optOut(); - console.log('[Telemetry] PostHog opted out'); } } }, [enabled, posthog, onPostHogReady]); diff --git a/src/screens/MDBListSettingsScreen.tsx b/src/screens/MDBListSettingsScreen.tsx index 93e9391a..9a433f70 100644 --- a/src/screens/MDBListSettingsScreen.tsx +++ b/src/screens/MDBListSettingsScreen.tsx @@ -26,39 +26,19 @@ import { useTheme } from '../contexts/ThemeContext'; import { logger } from '../utils/logger'; import { RATING_PROVIDERS } from '../components/metadata/RatingsSection'; import CustomAlert from '../components/CustomAlert'; // Moved CustomAlert import here +import { + MDBLIST_API_KEY_STORAGE_KEY, + MDBLIST_ENABLED_STORAGE_KEY, + RATING_PROVIDERS_STORAGE_KEY, + isMDBListEnabled, + getMDBListAPIKey, +} from '../services/mdblistConstants'; + +// Re-export for backwards compatibility +export { MDBLIST_API_KEY_STORAGE_KEY, RATING_PROVIDERS_STORAGE_KEY, MDBLIST_ENABLED_STORAGE_KEY, isMDBListEnabled, getMDBListAPIKey }; -export const MDBLIST_API_KEY_STORAGE_KEY = 'mdblist_api_key'; -export const RATING_PROVIDERS_STORAGE_KEY = 'rating_providers_config'; -export const MDBLIST_ENABLED_STORAGE_KEY = 'mdblist_enabled'; const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; -// Function to check if MDBList is enabled -export const isMDBListEnabled = async (): Promise => { - try { - const enabledSetting = await mmkvStorage.getItem(MDBLIST_ENABLED_STORAGE_KEY); - return enabledSetting === 'true'; - } catch (error) { - logger.error('[MDBList] Error checking if MDBList is enabled:', error); - return false; // Default to disabled if there's an error - } -}; - -// Function to get MDBList API key if enabled -export const getMDBListAPIKey = async (): Promise => { - try { - const isEnabled = await isMDBListEnabled(); - if (!isEnabled) { - logger.log('[MDBList] MDBList is disabled, not retrieving API key'); - return null; - } - - return await mmkvStorage.getItem(MDBLIST_API_KEY_STORAGE_KEY); - } catch (error) { - logger.error('[MDBList] Error retrieving API key:', error); - return null; - } -}; - // Create a styles creator function that accepts the theme colors const createStyles = (colors: any) => StyleSheet.create({ container: { diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx index c2538a04..f4a1f581 100644 --- a/src/screens/MetadataScreen.tsx +++ b/src/screens/MetadataScreen.tsx @@ -84,11 +84,19 @@ const BREAKPOINTS = { const MemoizedRatingsSection = memo(RatingsSection); const MemoizedCommentsSection = memo(CommentsSection); const MemoizedCastDetailsModal = memo(CastDetailsModal); +const noop = (..._args: unknown[]) => {}; +const logger = { + log: noop, + error: noop, + warn: noop, + info: noop, + debug: noop, +}; // ... other imports const MetadataScreen: React.FC = () => { - useEffect(() => { console.log('✅ MetadataScreen MOUNTED'); }, []); + useEffect(() => { logger.log('✅ MetadataScreen MOUNTED'); }, []); const navigation = useNavigation>(); const route = useRoute>(); const { id, type, episodeId, addonId } = route.params; @@ -184,16 +192,16 @@ const MetadataScreen: React.FC = () => { // Debug state changes React.useEffect(() => { - console.log('MetadataScreen: commentBottomSheetVisible changed to:', commentBottomSheetVisible); + logger.log('MetadataScreen: commentBottomSheetVisible changed to:', commentBottomSheetVisible); }, [commentBottomSheetVisible]); React.useEffect(() => { - console.log('MetadataScreen: selectedComment changed to:', selectedComment?.id); + logger.log('MetadataScreen: selectedComment changed to:', selectedComment?.id); }, [selectedComment]); // Log useMetadata hook state changes for debugging React.useEffect(() => { - console.log('🔍 [MetadataScreen] useMetadata state:', { + logger.log('🔍 [MetadataScreen] useMetadata state:', { loading, hasMetadata: !!metadata, metadataId: metadata?.id, @@ -357,7 +365,7 @@ const MetadataScreen: React.FC = () => { // Debug logging for color extraction timing useEffect(() => { if (__DEV__ && heroImageUri && dominantColor) { - if (__DEV__) console.log('[MetadataScreen] Dynamic background color:', { + if (__DEV__) logger.log('[MetadataScreen] Dynamic background color:', { dominantColor, fallback: currentTheme.colors.darkBackground, finalColor: dynamicBackgroundColor, @@ -422,7 +430,7 @@ const MetadataScreen: React.FC = () => { const isAuthenticated = await traktService.isAuthenticated(); if (!isAuthenticated) { - if (__DEV__) console.log(`[MetadataScreen] Not authenticated with Trakt`); + if (__DEV__) logger.log(`[MetadataScreen] Not authenticated with Trakt`); return; } @@ -449,7 +457,7 @@ const MetadataScreen: React.FC = () => { if (relevantProgress.length === 0) return; // Log only essential progress information for performance - if (__DEV__) console.log(`[MetadataScreen] Found ${relevantProgress.length} Trakt progress items for ${type}`); + if (__DEV__) logger.log(`[MetadataScreen] Found ${relevantProgress.length} Trakt progress items for ${type}`); // Find most recent progress if multiple episodes if (Object.keys(groupedEpisodes).length > 0 && relevantProgress.length > 1) { @@ -458,12 +466,12 @@ const MetadataScreen: React.FC = () => { )[0]; if (mostRecent.episode && mostRecent.show) { - if (__DEV__) console.log(`[MetadataScreen] Most recent: S${mostRecent.episode.season}E${mostRecent.episode.number} - ${mostRecent.progress.toFixed(1)}%`); + if (__DEV__) logger.log(`[MetadataScreen] Most recent: S${mostRecent.episode.season}E${mostRecent.episode.number} - ${mostRecent.progress.toFixed(1)}%`); } } } catch (error) { - if (__DEV__) console.error(`[MetadataScreen] Failed to fetch Trakt progress:`, error); + if (__DEV__) logger.error(`[MetadataScreen] Failed to fetch Trakt progress:`, error); } }, [shouldLoadSecondaryData, metadata, id, type]); @@ -495,7 +503,7 @@ const MetadataScreen: React.FC = () => { const timer = setTimeout(() => { const renderTime = Date.now() - startTime; if (renderTime > 100) { - if (__DEV__) console.warn(`[MetadataScreen] Slow render detected: ${renderTime}ms for ${metadata.name}`); + if (__DEV__) logger.warn(`[MetadataScreen] Slow render detected: ${renderTime}ms for ${metadata.name}`); } }, 0); return () => clearTimeout(timer); @@ -513,11 +521,11 @@ const MetadataScreen: React.FC = () => { const totalMB = Math.round(memory.totalJSHeapSize / 1048576); const limitMB = Math.round(memory.jsHeapSizeLimit / 1048576); - if (__DEV__) console.log(`[MetadataScreen] Memory usage: ${usedMB}MB / ${totalMB}MB (limit: ${limitMB}MB)`); + if (__DEV__) logger.log(`[MetadataScreen] Memory usage: ${usedMB}MB / ${totalMB}MB (limit: ${limitMB}MB)`); // Trigger cleanup if memory usage is high if (usedMB > limitMB * 0.8) { - if (__DEV__) console.warn(`[MetadataScreen] High memory usage detected (${usedMB}MB), triggering cleanup`); + if (__DEV__) logger.warn(`[MetadataScreen] High memory usage detected (${usedMB}MB), triggering cleanup`); // Force garbage collection if available if (global.gc) { global.gc(); @@ -536,16 +544,7 @@ const MetadataScreen: React.FC = () => { // Memoized derived values for performance const isReady = useMemo(() => !loading && metadata && !metadataError, [loading, metadata, metadataError]); - // Log readiness state for debugging - React.useEffect(() => { - console.log('🔍 [MetadataScreen] Readiness state:', { - isReady, - loading, - hasMetadata: !!metadata, - hasError: !!metadataError, - errorMessage: metadataError - }); - }, [isReady, loading, metadata, metadataError]); + // Optimized content ready state management useEffect(() => { @@ -627,13 +626,13 @@ const MetadataScreen: React.FC = () => { const nextEpisodeId = isImdb ? `${id}:${currentSeason || episodes[0]?.season_number || 1}:${currentEpisode + 1}` : `${id}:${currentEpisode + 1}`; - if (__DEV__) console.log(`[MetadataScreen] Created next episode ID: ${nextEpisodeId}`); + if (__DEV__) logger.log(`[MetadataScreen] Created next episode ID: ${nextEpisodeId}`); const nextEpisodeExists = episodes.some(ep => ep.episode_number === (currentEpisode + 1)); if (nextEpisodeExists) { - if (__DEV__) console.log(`[MetadataScreen] Verified next episode exists`); + if (__DEV__) logger.log(`[MetadataScreen] Verified next episode exists`); } else { - if (__DEV__) console.log(`[MetadataScreen] Warning: Next episode not found`); + if (__DEV__) logger.log(`[MetadataScreen] Warning: Next episode not found`); } targetEpisodeId = nextEpisodeId; @@ -643,7 +642,7 @@ const MetadataScreen: React.FC = () => { // Fallback logic: if not finished or nextEp not found if (!targetEpisodeId) { targetEpisodeId = watchProgress?.episodeId || episodeId || (episodes.length > 0 ? buildEpisodeId(episodes[0]) : undefined); - if (__DEV__) console.log(`[MetadataScreen] Using fallback episode ID: ${targetEpisodeId}`); + if (__DEV__) logger.log(`[MetadataScreen] Using fallback episode ID: ${targetEpisodeId}`); } if (targetEpisodeId) { @@ -657,7 +656,7 @@ const MetadataScreen: React.FC = () => { else if (epParts.length === 2 && isImdb) { normalizedEpisodeId = `${id}:${epParts[0]}:${epParts[1]}`; } - if (__DEV__) console.log(`[MetadataScreen] Navigating to streams with episodeId: ${normalizedEpisodeId}`); + if (__DEV__) logger.log(`[MetadataScreen] Navigating to streams with episodeId: ${normalizedEpisodeId}`); navigation.navigate('Streams', { id, type, episodeId: normalizedEpisodeId }); return; } @@ -671,14 +670,14 @@ const MetadataScreen: React.FC = () => { fallbackEpisodeId = isImdb ? `${id}:${p[0]}:${p[1]}` : `${id}:${p[1]}`; } } - if (__DEV__) console.log(`[MetadataScreen] Navigating with fallback episodeId: ${fallbackEpisodeId}`); + if (__DEV__) logger.log(`[MetadataScreen] Navigating with fallback episodeId: ${fallbackEpisodeId}`); navigation.navigate('Streams', { id, type, episodeId: fallbackEpisodeId }); }, [navigation, id, type, episodes, episodeId, watchProgressData.watchProgress]); const handleEpisodeSelect = useCallback((episode: Episode) => { if (!isScreenFocused) return; - if (__DEV__) console.log('[MetadataScreen] Selected Episode:', episode.episode_number, episode.season_number); + if (__DEV__) logger.log('[MetadataScreen] Selected Episode:', episode.episode_number, episode.season_number); let episodeId: string; if (episode.stremioId) { @@ -716,15 +715,11 @@ const MetadataScreen: React.FC = () => { }, [isScreenFocused]); const handleCommentPress = useCallback((comment: any) => { - console.log('MetadataScreen: handleCommentPress called with comment:', comment?.id); if (!isScreenFocused) { - console.log('MetadataScreen: Screen not focused, ignoring'); return; } - console.log('MetadataScreen: Setting selected comment and opening bottomsheet'); setSelectedComment(comment); setCommentBottomSheetVisible(true); - console.log('MetadataScreen: State should be updated now'); }, [isScreenFocused]); const handleCommentBottomSheetClose = useCallback(() => { @@ -774,7 +769,6 @@ const MetadataScreen: React.FC = () => { // Parse error to extract code and user-friendly message const parseError = (error: string) => { - console.log('🔍 Parsing error in MetadataScreen:', error); // Check for HTTP status codes - handle multiple formats // Match patterns like: "status code 500", "status": 500, "Request failed with status code 500" @@ -785,7 +779,6 @@ const MetadataScreen: React.FC = () => { if (statusCodeMatch) { const code = parseInt(statusCodeMatch[1]); - console.log('✅ Found status code:', code); switch (code) { case 404: return { code: '404', message: t('metadata.content_not_found'), userMessage: t('metadata.content_not_found_desc') }; @@ -893,13 +886,11 @@ const MetadataScreen: React.FC = () => { // Show error if exists if (metadataError || (!loading && !metadata)) { - console.log('❌ MetadataScreen ERROR state:', { metadataError, loading, hasMetadata: !!metadata }); return ErrorComponent; } // Show loading screen if metadata is not yet available or exit animation hasn't completed if (loading || !isContentReady || !loadingScreenExited) { - console.log('⏳ MetadataScreen LOADING state:', { loading, isContentReady, loadingScreenExited, hasMetadata: !!metadata }); return ( 0 && (now - this.lastFetch) < this.CACHE_TTL) { - console.log('[CampaignService] Using cached campaigns'); return this.getNextValidCampaign(); } const platform = Platform.OS; const url = `${CAMPAIGN_API_URL}/api/campaigns/queue?platform=${platform}`; - console.log('[CampaignService] Fetching from:', url); const response = await fetch( `${CAMPAIGN_API_URL}/api/campaigns/queue?platform=${platform}`, { @@ -100,14 +96,11 @@ class CampaignService { } }); - console.log('[CampaignService] Fetched campaigns:', campaigns.length, 'CAMPAIGN_API_URL:', CAMPAIGN_API_URL); - this.campaignQueue = campaigns; this.currentIndex = 0; this.lastFetch = now; const result = this.getNextValidCampaign(); - console.log('[CampaignService] Next valid campaign:', result?.id, result?.type); return result; } catch (error) { console.warn('[CampaignService] Error fetching campaigns:', error); diff --git a/src/services/catalog/content-details.ts b/src/services/catalog/content-details.ts index bfec521a..c30fb6e7 100644 --- a/src/services/catalog/content-details.ts +++ b/src/services/catalog/content-details.ts @@ -31,32 +31,19 @@ export async function getContentDetails( id: string, preferredAddonId?: string ): Promise { - console.log('🔍 [CatalogService] getContentDetails called:', { type, id, preferredAddonId }); - try { let meta = null; let lastError = null; for (let attempt = 0; attempt < 2; attempt += 1) { try { - console.log(`🔍 [CatalogService] Attempt ${attempt + 1}/2 for getContentDetails:`, { type, id, preferredAddonId }); const isValidId = await stremioService.isValidContentId(type, id); - console.log('🔍 [CatalogService] Content ID validation:', { type, id, isValidId }); if (!isValidId) { - console.log('🔍 [CatalogService] Invalid content ID, breaking retry loop'); break; } - - console.log('🔍 [CatalogService] Calling stremioService.getMetaDetails:', { type, id, preferredAddonId }); meta = await stremioService.getMetaDetails(type, id, preferredAddonId); - console.log('🔍 [CatalogService] stremioService.getMetaDetails result:', { - hasMeta: !!meta, - metaId: meta?.id, - metaName: meta?.name, - metaType: meta?.type, - }); if (meta) { break; @@ -65,61 +52,24 @@ export async function getContentDetails( await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, attempt))); } catch (error) { lastError = error; - console.log(`🔍 [CatalogService] Attempt ${attempt + 1} failed:`, { - errorMessage: error instanceof Error ? error.message : String(error), - isAxiosError: (error as any)?.isAxiosError, - responseStatus: (error as any)?.response?.status, - responseData: (error as any)?.response?.data, - }); logger.error(`Attempt ${attempt + 1} failed to get content details for ${type}:${id}:`, error); await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, attempt))); } } if (meta) { - console.log('🔍 [CatalogService] Meta found, converting to StreamingContent:', { - metaId: meta.id, - metaName: meta.name, - metaType: meta.type, - }); - const content = convertMetaToStreamingContentEnhanced(meta, state.library); addToRecentContent(state, content); content.inLibrary = state.library[createLibraryKey(type, id)] !== undefined; - console.log('🔍 [CatalogService] Successfully converted meta to StreamingContent:', { - contentId: content.id, - contentName: content.name, - contentType: content.type, - inLibrary: content.inLibrary, - }); - return content; } - console.log('🔍 [CatalogService] No meta found, checking lastError:', { - hasLastError: !!lastError, - lastErrorMessage: lastError instanceof Error ? lastError.message : String(lastError), - }); - if (lastError) { - console.log('🔍 [CatalogService] Throwing lastError:', { - errorMessage: lastError instanceof Error ? lastError.message : String(lastError), - isAxiosError: (lastError as any)?.isAxiosError, - responseStatus: (lastError as any)?.response?.status, - }); throw lastError; } - - console.log('🔍 [CatalogService] No meta and no error, returning null'); return null; } catch (error) { - console.log('🔍 [CatalogService] getContentDetails caught error:', { - errorMessage: error instanceof Error ? error.message : String(error), - isAxiosError: (error as any)?.isAxiosError, - responseStatus: (error as any)?.response?.status, - responseData: (error as any)?.response?.data, - }); logger.error(`Failed to get content details for ${type}:${id}:`, error); return null; } @@ -131,25 +81,11 @@ export async function getEnhancedContentDetails( id: string, preferredAddonId?: string ): Promise { - console.log('🔍 [CatalogService] getEnhancedContentDetails called:', { type, id, preferredAddonId }); - logger.log(`🔍 [MetadataScreen] Fetching enhanced metadata for ${type}:${id} ${preferredAddonId ? `from addon ${preferredAddonId}` : ''}`); try { const result = await getContentDetails(state, type, id, preferredAddonId); - console.log('🔍 [CatalogService] getEnhancedContentDetails result:', { - hasResult: !!result, - resultId: result?.id, - resultName: result?.name, - resultType: result?.type, - }); return result; } catch (error) { - console.log('🔍 [CatalogService] getEnhancedContentDetails error:', { - errorMessage: error instanceof Error ? error.message : String(error), - isAxiosError: (error as any)?.isAxiosError, - responseStatus: (error as any)?.response?.status, - responseData: (error as any)?.response?.data, - }); throw error; } } @@ -201,81 +137,29 @@ export async function getBasicContentDetails( } export async function getStremioId(type: string, tmdbId: string): Promise { - if (__DEV__) { - console.log('=== CatalogService.getStremioId ==='); - console.log('Input type:', type); - console.log('Input tmdbId:', tmdbId); - } - try { if (type === 'movie') { - if (__DEV__) { - console.log('Processing movie - fetching TMDB details...'); - } - const movieDetails = await TMDBService.getInstance().getMovieDetails(tmdbId); - if (__DEV__) { - console.log('Movie details result:', { - id: movieDetails?.id, - title: movieDetails?.title, - imdb_id: movieDetails?.imdb_id, - hasImdbId: !!movieDetails?.imdb_id, - }); - } - if (movieDetails?.imdb_id) { - if (__DEV__) { - console.log('Successfully found IMDb ID:', movieDetails.imdb_id); - } return movieDetails.imdb_id; } - - console.warn('No IMDb ID found for movie:', tmdbId); return null; } if (type === 'tv' || type === 'series') { - if (__DEV__) { - console.log('Processing TV show - fetching TMDB details for IMDb ID...'); - } - const externalIds = await TMDBService.getInstance().getShowExternalIds(parseInt(tmdbId, 10)); - if (__DEV__) { - console.log('TV show external IDs result:', { - tmdbId, - imdb_id: externalIds?.imdb_id, - hasImdbId: !!externalIds?.imdb_id, - }); - } - if (externalIds?.imdb_id) { - if (__DEV__) { - console.log('Successfully found IMDb ID for TV show:', externalIds.imdb_id); - } return externalIds.imdb_id; } - console.warn('No IMDb ID found for TV show, falling back to kitsu format:', tmdbId); const fallbackId = `kitsu:${tmdbId}`; - if (__DEV__) { - console.log('Generated fallback Stremio ID for TV:', fallbackId); - } return fallbackId; } - console.warn('Unknown type provided:', type); return null; } catch (error: any) { - if (__DEV__) { - console.error('=== Error in getStremioId ==='); - console.error('Type:', type); - console.error('TMDB ID:', tmdbId); - console.error('Error details:', error); - console.error('Error message:', error.message); - } - logger.error('Error getting Stremio ID:', error); return null; } diff --git a/src/services/catalog/library.ts b/src/services/catalog/library.ts index 2751b396..fe99eb3e 100644 --- a/src/services/catalog/library.ts +++ b/src/services/catalog/library.ts @@ -1,9 +1,13 @@ -import { notificationService } from '../notificationService'; import { mmkvStorage } from '../mmkvStorage'; import { logger } from '../../utils/logger'; import type { StreamingContent } from './types'; +// Lazy import to break require cycle: +// catalogService -> content-details -> content-mappers -> library -> notificationService -> catalogService +const getNotificationService = () => + require('../notificationService').notificationService; + export interface CatalogLibraryState { LEGACY_LIBRARY_KEY: string; RECENT_CONTENT_KEY: string; @@ -259,10 +263,9 @@ export async function addToLibrary(state: CatalogLibraryState, content: Streamin if (content.type === 'series') { try { - await notificationService.updateNotificationsForSeries(content.id); - console.log(`[CatalogService] Auto-setup notifications for series: ${content.name}`); + await getNotificationService().updateNotificationsForSeries(content.id); } catch (error) { - console.error(`[CatalogService] Failed to setup notifications for ${content.name}:`, error); + logger.error(`[CatalogService] Failed to setup notifications for ${content.name}:`, error); } } } @@ -299,16 +302,14 @@ export async function removeFromLibrary( if (type === 'series') { try { - const scheduledNotifications = notificationService.getScheduledNotifications(); - const seriesToCancel = scheduledNotifications.filter(notification => notification.seriesId === id); + const scheduledNotifications = getNotificationService().getScheduledNotifications(); + const seriesToCancel = scheduledNotifications.filter((notification: any) => notification.seriesId === id); for (const notification of seriesToCancel) { - await notificationService.cancelNotification(notification.id); + await getNotificationService().cancelNotification(notification.id); } - - console.log(`[CatalogService] Cancelled ${seriesToCancel.length} notifications for removed series: ${id}`); } catch (error) { - console.error(`[CatalogService] Failed to cancel notifications for removed series ${id}:`, error); + logger.error(`[CatalogService] Failed to cancel notifications for removed series ${id}:`, error); } } } diff --git a/src/services/mdblistConstants.ts b/src/services/mdblistConstants.ts new file mode 100644 index 00000000..94949a5d --- /dev/null +++ b/src/services/mdblistConstants.ts @@ -0,0 +1,31 @@ +import { mmkvStorage } from './mmkvStorage'; +import { logger } from '../utils/logger'; + +export const MDBLIST_API_KEY_STORAGE_KEY = 'mdblist_api_key'; +export const MDBLIST_ENABLED_STORAGE_KEY = 'mdblist_enabled'; +export const RATING_PROVIDERS_STORAGE_KEY = 'rating_providers_config'; + +// Function to check if MDBList is enabled +export const isMDBListEnabled = async (): Promise => { + try { + const enabledSetting = await mmkvStorage.getItem(MDBLIST_ENABLED_STORAGE_KEY); + return enabledSetting === 'true'; + } catch (error) { + logger.error('[MDBList] Error checking if MDBList is enabled:', error); + return false; + } +}; + +// Function to get MDBList API key if enabled +export const getMDBListAPIKey = async (): Promise => { + try { + const isEnabled = await isMDBListEnabled(); + if (!isEnabled) { + return null; + } + return await mmkvStorage.getItem(MDBLIST_API_KEY_STORAGE_KEY); + } catch (error) { + logger.error('[MDBList] Error getting API key:', error); + return null; + } +}; diff --git a/src/services/mdblistService.ts b/src/services/mdblistService.ts index e6959e38..9b867bbe 100644 --- a/src/services/mdblistService.ts +++ b/src/services/mdblistService.ts @@ -3,8 +3,7 @@ import { logger } from '../utils/logger'; import { MDBLIST_API_KEY_STORAGE_KEY, MDBLIST_ENABLED_STORAGE_KEY, - isMDBListEnabled -} from '../screens/MDBListSettingsScreen'; +} from './mdblistConstants'; export interface MDBListRatings { trakt?: number; diff --git a/src/services/telemetryService.ts b/src/services/telemetryService.ts index d29844bf..20ac59d7 100644 --- a/src/services/telemetryService.ts +++ b/src/services/telemetryService.ts @@ -148,7 +148,6 @@ class TelemetryService { } this.initialized = true; - console.log('[TelemetryService] Initialized with settings:', this.settings); } catch (error) { console.error('[TelemetryService] Error initializing:', error); // Use defaults on error diff --git a/src/utils/logger.ts b/src/utils/logger.ts index f5952ede..445592f9 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -3,7 +3,7 @@ class Logger { constructor() { // __DEV__ is a global variable in React Native - this.isEnabled = __DEV__; + this.isEnabled = false; } log(...args: any[]) { @@ -37,4 +37,4 @@ class Logger { } } -export const logger = new Logger(); \ No newline at end of file +export const logger = new Logger(); From 556b0f47247fd02a3bb524fc8391f2621660f91f Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 17 Mar 2026 06:20:51 +0530 Subject: [PATCH 06/10] update herosection play icon --- assets/player-icons/ic_player_play_black.svg | 4 ++++ src/components/home/AppleTVHero.tsx | 19 ++++++++++++---- src/components/metadata/HeroSection.tsx | 24 ++++++++++++-------- src/screens/LibraryScreen.tsx | 17 ++++++-------- 4 files changed, 39 insertions(+), 25 deletions(-) create mode 100644 assets/player-icons/ic_player_play_black.svg diff --git a/assets/player-icons/ic_player_play_black.svg b/assets/player-icons/ic_player_play_black.svg new file mode 100644 index 00000000..fb9e4a14 --- /dev/null +++ b/assets/player-icons/ic_player_play_black.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/components/home/AppleTVHero.tsx b/src/components/home/AppleTVHero.tsx index a06ddb7c..e06487ac 100644 --- a/src/components/home/AppleTVHero.tsx +++ b/src/components/home/AppleTVHero.tsx @@ -18,6 +18,7 @@ import { RootStackParamList } from '../../navigation/AppNavigator'; import { LinearGradient } from 'expo-linear-gradient'; import FastImage from '@d11/react-native-fast-image'; import { MaterialIcons, Entypo } from '@expo/vector-icons'; +import PlayerPlayIconBlack from '../../../assets/player-icons/ic_player_play_black.svg'; import Animated, { FadeIn, FadeOut, @@ -1316,11 +1317,19 @@ const AppleTVHero: React.FC = ({ onPress={handlePlayAction} activeOpacity={0.85} > - + {shouldResume ? ( + + ) : ( + + )} {shouldResume ? t('home.resume') : t('home.play')} diff --git a/src/components/metadata/HeroSection.tsx b/src/components/metadata/HeroSection.tsx index 20f04b5e..478596c4 100644 --- a/src/components/metadata/HeroSection.tsx +++ b/src/components/metadata/HeroSection.tsx @@ -57,6 +57,7 @@ import { TMDBService } from '../../services/tmdbService'; import TrailerService, { TrailerPlaybackSource } from '../../services/trailerService'; import TrailerPlayer from '../video/TrailerPlayer'; import { HERO_HEIGHT, SCREEN_WIDTH as width, IS_TABLET as isTablet } from '../../constants/dimensions'; +import PlayerPlayIconBlack from '../../../assets/player-icons/ic_player_play_black.svg'; const { height } = Dimensions.get('window'); @@ -355,16 +356,19 @@ const ActionButtons = memo(({ onPress={handleShowStreams} activeOpacity={0.85} > - { - if (isWatched) { - return type === 'movie' ? 'replay' : 'play-arrow'; - } - return playButtonText === 'Resume' ? 'play-circle-outline' : 'play-arrow'; - })()} - size={isTablet ? 28 : 24} - color={isWatched && type === 'movie' ? "#fff" : "#000"} - /> + {isWatched && type === 'movie' ? ( + + ) : ( + + )} {finalPlayButtonText} diff --git a/src/screens/LibraryScreen.tsx b/src/screens/LibraryScreen.tsx index 79b0d2ff..b22ada28 100644 --- a/src/screens/LibraryScreen.tsx +++ b/src/screens/LibraryScreen.tsx @@ -1782,11 +1782,8 @@ const LibraryScreen = () => { setFilter(filterType); }} activeOpacity={0.7} - > - {iconName && ( - - )} - + { style={styles.filtersContainer} contentContainerStyle={styles.filtersContent} > - {renderFilter('trakt', 'Trakt', 'pan-tool')} - {renderFilter('simkl', 'SIMKL', 'video-library')} - {renderFilter('mal', 'MAL', 'book')} - {renderFilter('movies', t('search.movies'), 'movie')} - {renderFilter('series', t('search.tv_shows'), 'live-tv')} + {renderFilter('trakt', 'Trakt')} + {renderFilter('simkl', 'SIMKL')} + {renderFilter('mal', 'MAL')} + {renderFilter('movies', t('search.movies'))} + {renderFilter('series', t('search.tv_shows'))} )} From d4947325537a32737f41f1ead562afc199cd2f89 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 17 Mar 2026 06:34:13 +0530 Subject: [PATCH 07/10] added catalog load timeout --- src/screens/HomeScreen.tsx | 59 +++++++++++++++++++++++++++++++------- 1 file changed, 49 insertions(+), 10 deletions(-) diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 65eb0491..b7baa4c0 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -76,6 +76,26 @@ import { useScrollToTop } from '../contexts/ScrollToTopContext'; const CATALOG_SETTINGS_KEY = 'catalog_settings'; const MAX_CONCURRENT_CATALOG_REQUESTS = 4; const HOME_LOADING_SCREEN_TIMEOUT_MS = 5000; +const HOME_CATALOG_REQUEST_TIMEOUT_MS = 20000; + +const withTimeout = async (promise: Promise, timeoutMs: number): Promise => { + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + reject(new Error(`Request timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + promise.then( + (result) => { + clearTimeout(timeoutId); + resolve(result); + }, + (error) => { + clearTimeout(timeoutId); + reject(error); + } + ); + }); +}; // In-memory cache for catalog settings to avoid repeated MMKV reads let cachedCatalogSettings: Record | null = null; @@ -134,6 +154,7 @@ const HomeScreen = () => { const [catalogs, setCatalogs] = useState<(CatalogContent | null)[]>([]); const [catalogsLoading, setCatalogsLoading] = useState(true); const [loadedCatalogCount, setLoadedCatalogCount] = useState(0); + const [pendingCatalogIndexes, setPendingCatalogIndexes] = useState>({}); const [hasAddons, setHasAddons] = useState(null); const [hintVisible, setHintVisible] = useState(false); const [loadingScreenTimedOut, setLoadingScreenTimedOut] = useState(false); @@ -192,6 +213,7 @@ const HomeScreen = () => { setCatalogsLoading(true); setCatalogs([]); setLoadedCatalogCount(0); + setPendingCatalogIndexes({}); try { // Check cache first @@ -279,13 +301,17 @@ const HomeScreen = () => { if (isEnabled) { const currentIndex = catalogIndex; - const catalogLoader = async () => { - try { - const manifest = manifestByAddonId.get(addon.id); - if (!manifest) return; + const catalogLoader = async () => { + try { + const manifest = manifestByAddonId.get(addon.id); + if (!manifest) return; - const metas = await stremioService.getCatalog(manifest, catalog.type, catalog.id, 1); - if (metas && metas.length > 0) { + const metas = await withTimeout( + stremioService.getCatalog(manifest, catalog.type, catalog.id, 1), + HOME_CATALOG_REQUEST_TIMEOUT_MS + ); + + if (metas && metas.length > 0) { // Aggressively limit items per catalog on Android to reduce memory usage const limit = Platform.OS === 'android' ? 18 : 30; const limitedMetas = metas.slice(0, limit); @@ -344,6 +370,15 @@ const HomeScreen = () => { } catch (error) { if (__DEV__) console.error(`[HomeScreen] Failed to load ${catalog.name} from ${addon.name}:`, error); } finally { + setPendingCatalogIndexes((prev) => { + if (!prev[currentIndex]) { + return prev; + } + const next = { ...prev }; + delete next[currentIndex]; + return next; + }); + // Update loading count - ensure on main thread InteractionManager.runAfterInteractions(() => { setLoadedCatalogCount(prev => { @@ -362,8 +397,12 @@ const HomeScreen = () => { } }; - catalogQueue.push(catalogLoader); - catalogIndex++; + catalogQueue.push(catalogLoader); + setPendingCatalogIndexes((prev) => ({ + ...prev, + [currentIndex]: true, + })); + catalogIndex++; } } } @@ -722,7 +761,7 @@ const HomeScreen = () => { catalogsToShow.forEach((catalog, index) => { if (catalog) { data.push({ type: 'catalog', catalog, key: `${catalog.addon}-${catalog.id}-${index}` }); - } else { + } else if (catalogsLoading && pendingCatalogIndexes[index]) { // Add a key for placeholders data.push({ type: 'placeholder', key: `placeholder-${index}` }); } @@ -734,7 +773,7 @@ const HomeScreen = () => { } return data; - }, [hasAddons, catalogs, visibleCatalogCount, settings.showThisWeekSection]); + }, [hasAddons, catalogs, catalogsLoading, pendingCatalogIndexes, visibleCatalogCount, settings.showThisWeekSection]); const handleLoadMoreCatalogs = useCallback(() => { setVisibleCatalogCount(prev => Math.min(prev + 3, catalogs.length)); From cd2a80a5424f60d645207ddae003c49bc38e2fc5 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 17 Mar 2026 06:40:41 +0530 Subject: [PATCH 08/10] ref: to use blurview on ios appletv hero action button --- src/components/home/AppleTVHero.tsx | 27 ++++++++++++++++++++++----- src/hooks/useSettings.ts | 2 +- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/components/home/AppleTVHero.tsx b/src/components/home/AppleTVHero.tsx index e06487ac..633bbd23 100644 --- a/src/components/home/AppleTVHero.tsx +++ b/src/components/home/AppleTVHero.tsx @@ -1339,11 +1339,21 @@ const AppleTVHero: React.FC = ({ onPress={handleSaveAction} activeOpacity={0.85} > - + {Platform.OS === 'ios' ? ( + + + + ) : ( + + )} @@ -1496,11 +1506,18 @@ const styles = StyleSheet.create({ height: 52, borderRadius: 30, backgroundColor: 'rgba(255,255,255,0.2)', + overflow: 'hidden', alignItems: 'center', justifyContent: 'center', borderWidth: 1.5, borderColor: 'rgba(255,255,255,0.3)', }, + saveButtonBlur: { + width: '100%', + height: '100%', + alignItems: 'center', + justifyContent: 'center', + }, paginationContainer: { flexDirection: 'row', alignItems: 'center', diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index ca9dca76..98b74aa0 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -172,7 +172,7 @@ export const DEFAULT_SETTINGS: AppSettings = { // AI aiChatEnabled: false, // Metadata enrichment - enrichMetadataWithTMDB: true, + enrichMetadataWithTMDB: false, useTmdbLocalizedMetadata: false, // Granular TMDB enrichment controls (all enabled by default for backward compatibility) tmdbEnrichCast: true, From 97578181768f08787d663b1af9cfd035201222c6 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 17 Mar 2026 06:57:20 +0530 Subject: [PATCH 09/10] fix: removed unncessary screen dimming metadascreen --- src/screens/MetadataScreen.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx index f4a1f581..d96ba25b 100644 --- a/src/screens/MetadataScreen.tsx +++ b/src/screens/MetadataScreen.tsx @@ -751,8 +751,8 @@ const MetadataScreen: React.FC = () => { // Ultra-optimized animated styles - minimal calculations with conditional updates const containerStyle = useAnimatedStyle(() => ({ - opacity: isScreenFocused ? animations.screenOpacity.value : 0.8, - }), [isScreenFocused]); + opacity: animations.screenOpacity.value, + }), []); const contentStyle = useAnimatedStyle(() => ({ opacity: animations.contentOpacity.value, From cd64a36d133627baa03e5d49c00e829936b4c3fa Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 17 Mar 2026 07:06:42 +0530 Subject: [PATCH 10/10] feat: update searchscreen loading spinner --- src/screens/SearchScreen.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/screens/SearchScreen.tsx b/src/screens/SearchScreen.tsx index e27337d0..f2146253 100644 --- a/src/screens/SearchScreen.tsx +++ b/src/screens/SearchScreen.tsx @@ -46,10 +46,10 @@ import { MAX_RECENT_SEARCHES, } from '../components/search/searchUtils'; import { searchStyles as styles } from '../components/search/searchStyles'; -import { SearchAnimation } from '../components/search/SearchAnimation'; import { AddonSection } from '../components/search/AddonSection'; import { DiscoverSection } from '../components/search/DiscoverSection'; import { DiscoverBottomSheets } from '../components/search/DiscoverBottomSheets'; +import LoadingSpinner from '../components/common/LoadingSpinner'; const { width } = Dimensions.get('window'); const AnimatedTouchable = Animated.createAnimatedComponent(TouchableOpacity); @@ -761,7 +761,9 @@ const SearchScreen = () => { {searching && results.byAddon.length === 0 ? ( - + + + ) : searched && !hasResultsToShow && !searching ? (