ref: StremioService.ts into managable chunks

This commit is contained in:
tapframe 2026-03-17 05:26:39 +05:30
parent 5f49a7f2ab
commit 0d62ad1297
11 changed files with 2264 additions and 2162 deletions

View file

@ -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,
});
}

View file

@ -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<string, Manifest> = 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<void> | null = null;
catalogHasMore: Map<string, boolean> = 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<void> {
await initializeAddons(this);
}
async ensureInitialized(): Promise<void> {
if (!this.initialized && this.initializationPromise) {
await this.initializationPromise;
}
}
async retryRequest<T>(request: () => Promise<T>, retries = 1, delay = 1000): Promise<T> {
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<void> {
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<void> {
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<boolean> {
return isValidContentIdImpl(
this,
type,
id,
() => this.getAllSupportedTypes(),
value => this.getAllSupportedIdPrefixes(value)
);
}
async getManifest(url: string): Promise<Manifest> {
return getManifestImpl(this, url);
}
async installAddon(url: string): Promise<void> {
await installAddonImpl(this, url);
}
async removeAddon(installationId: string): Promise<void> {
await removeAddonImpl(this, installationId);
}
getInstalledAddons(): Manifest[] {
return getInstalledAddonsImpl(this);
}
async getInstalledAddonsAsync(): Promise<Manifest[]> {
return getInstalledAddonsAsyncImpl(this);
}
isPreInstalledAddon(id: string): boolean {
void id;
return isPreInstalledAddonImpl();
}
async hasUserRemovedAddon(addonId: string): Promise<boolean> {
return hasUserRemovedAddonImpl(addonId);
}
async unmarkAddonAsRemovedByUser(addonId: string): Promise<void> {
await unmarkAddonAsRemovedByUserImpl(addonId);
}
async getAllCatalogs(): Promise<Record<string, Meta[]>> {
return getAllCatalogsImpl(this);
}
async getCatalog(
manifest: Manifest,
type: string,
id: string,
page = 1,
filters: CatalogFilter[] = []
): Promise<Meta[]> {
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<MetaDetails | null> {
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<void> {
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<Subtitle[]> {
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<boolean> {
return applyAddonOrderFromManifestUrlsImpl(this, manifestUrls);
}
async hasStreamProviders(type?: string): Promise<boolean> {
return hasStreamProvidersImpl(this, type);
}
async getAddonCatalogs(type: string, id: string): Promise<AddonCatalogItem[]> {
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;

View file

@ -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<string> {
return (await mmkvStorage.getItem('@user:current')) || 'local';
}
export async function initializeAddons(ctx: StremioServiceContext): Promise<void> {
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<string>();
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<string>();
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<Manifest> {
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<void> {
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<void> {
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<Manifest[]> {
await ctx.ensureInitialized();
return getInstalledAddons(ctx);
}
export function isPreInstalledAddon(): boolean {
return false;
}
export async function hasUserRemovedAddon(addonId: string): Promise<boolean> {
try {
const removedAddons = await mmkvStorage.getItem('user_removed_addons');
if (!removedAddons) {
return false;
}
const removedList = JSON.parse(removedAddons);
return Array.isArray(removedList) && removedList.includes(addonId);
} catch {
return false;
}
}
async function markAddonAsRemovedByUser(addonId: string): Promise<void> {
try {
const removedAddons = await mmkvStorage.getItem('user_removed_addons');
let removedList = removedAddons ? JSON.parse(removedAddons) : [];
if (!Array.isArray(removedList)) {
removedList = [];
}
if (!removedList.includes(addonId)) {
removedList.push(addonId);
await mmkvStorage.setItem('user_removed_addons', JSON.stringify(removedList));
}
} catch {
// Best-effort cleanup only.
}
}
export async function unmarkAddonAsRemovedByUser(addonId: string): Promise<void> {
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<void> {
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.
}
}

View file

@ -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<boolean> {
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<string, string[]>();
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<string>();
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;
}

View file

@ -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<boolean> {
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<string>(['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<Record<string, Meta[]>> {
const result: Record<string, Meta[]> = {};
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<Meta[]> {
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<MetaDetails | null> {
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<MetaDetails | null> {
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<AddonCatalogItem[]> {
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;
}

View file

@ -0,0 +1,39 @@
import type {
CatalogFilter,
Manifest,
Meta,
MetaDetails,
Stream,
} from './types';
export interface StremioServiceContext {
installedAddons: Map<string, Manifest>;
addonOrder: string[];
STORAGE_KEY: string;
ADDON_ORDER_KEY: string;
DEFAULT_PAGE_SIZE: number;
initialized: boolean;
initializationPromise: Promise<void> | null;
catalogHasMore: Map<string, boolean>;
ensureInitialized(): Promise<void>;
retryRequest<T>(request: () => Promise<T>, retries?: number, delay?: number): Promise<T>;
saveInstalledAddons(): Promise<void>;
saveAddonOrder(): Promise<void>;
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<boolean>;
getCatalog(
manifest: Manifest,
type: string,
id: string,
page?: number,
filters?: CatalogFilter[]
): Promise<Meta[]>;
getMetaDetails(type: string, id: string, preferredAddonId?: string): Promise<MetaDetails | null>;
hasUserRemovedAddon(addonId: string): Promise<boolean>;
unmarkAddonAsRemovedByUser(addonId: string): Promise<void>;
}

View file

@ -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;

View file

@ -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<void> {
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<void> {
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<boolean> {
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;
}

View file

@ -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<Subtitle[]> {
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<string>();
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;
}

View file

@ -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<string, string>;
subtitles?: Subtitle[];
sources?: string[];
behaviorHints?: {
bingeGroup?: string;
notWebReady?: boolean;
countryWhitelist?: string[];
cached?: boolean;
proxyHeaders?: {
request?: Record<string, string>;
response?: Record<string, string>;
};
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 };

File diff suppressed because it is too large Load diff