mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-23 02:58:05 +00:00
ref: StremioService.ts into managable chunks
This commit is contained in:
parent
5f49a7f2ab
commit
0d62ad1297
11 changed files with 2264 additions and 2162 deletions
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
456
src/services/stremio/StremioService.ts
Normal file
456
src/services/stremio/StremioService.ts
Normal 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;
|
||||
449
src/services/stremio/addon-management.ts
Normal file
449
src/services/stremio/addon-management.ts
Normal 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.
|
||||
}
|
||||
}
|
||||
112
src/services/stremio/addon-order.ts
Normal file
112
src/services/stremio/addon-order.ts
Normal 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;
|
||||
}
|
||||
423
src/services/stremio/catalog-operations.ts
Normal file
423
src/services/stremio/catalog-operations.ts
Normal 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;
|
||||
}
|
||||
39
src/services/stremio/context.ts
Normal file
39
src/services/stremio/context.ts
Normal 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>;
|
||||
}
|
||||
9
src/services/stremio/events.ts
Normal file
9
src/services/stremio/events.ts
Normal 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;
|
||||
391
src/services/stremio/stream-operations.ts
Normal file
391
src/services/stremio/stream-operations.ts
Normal 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;
|
||||
}
|
||||
126
src/services/stremio/subtitle-operations.ts
Normal file
126
src/services/stremio/subtitle-operations.ts
Normal 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;
|
||||
}
|
||||
236
src/services/stremio/types.ts
Normal file
236
src/services/stremio/types.ts
Normal 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
Loading…
Reference in a new issue