mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-20 16:22:04 +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(', '));
|
logger.log(`Found ${searchableAddons.length} searchable addons:`, searchableAddons.map(a => a.name).join(', '));
|
||||||
|
|
||||||
// Search each addon and keep results grouped
|
// 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
|
// Get the manifest to ensure we have the correct URL
|
||||||
const manifest = manifestMap.get(addon.id);
|
const manifest = manifestMap.get(addon.id);
|
||||||
if (!manifest) {
|
if (!manifest) {
|
||||||
|
|
@ -1473,6 +1473,8 @@ class CatalogService {
|
||||||
byAddon.push({
|
byAddon.push({
|
||||||
addonId: addon.id,
|
addonId: addon.id,
|
||||||
addonName: addon.name,
|
addonName: addon.name,
|
||||||
|
sectionName: addon.name,
|
||||||
|
catalogIndex: addonIndex,
|
||||||
results: uniqueAddonResults,
|
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