ref: catalog service into managable chunks

This commit is contained in:
tapframe 2026-03-17 05:46:49 +05:30
parent 0d62ad1297
commit b15b01d1f5
8 changed files with 1922 additions and 1751 deletions

View file

@ -0,0 +1,84 @@
import type { Manifest } from '../stremioService';
import type { StreamingAddon, StreamingCatalog } from './types';
export function convertManifestToStreamingAddon(manifest: Manifest): StreamingAddon {
return {
id: manifest.id,
name: manifest.name,
version: manifest.version,
description: manifest.description,
types: manifest.types || [],
catalogs: (manifest.catalogs || []).map(catalog => ({
...catalog,
extraSupported: catalog.extraSupported || [],
extra: (catalog.extra || []).map(extra => ({
name: extra.name,
isRequired: extra.isRequired,
options: extra.options,
optionsLimit: extra.optionsLimit,
})),
})),
resources: manifest.resources || [],
url: (manifest.url || manifest.originalUrl) as any,
originalUrl: (manifest.originalUrl || manifest.url) as any,
transportUrl: manifest.url,
transportName: manifest.name,
};
}
export async function getAllAddons(getInstalledAddons: () => Promise<Manifest[]>): Promise<StreamingAddon[]> {
const addons = await getInstalledAddons();
return addons.map(convertManifestToStreamingAddon);
}
export function catalogSupportsExtra(catalog: StreamingCatalog, extraName: string): boolean {
return (catalog.extraSupported || []).includes(extraName) ||
(catalog.extra || []).some(extra => extra.name === extraName);
}
export function getRequiredCatalogExtras(catalog: StreamingCatalog): string[] {
return (catalog.extra || []).filter(extra => extra.isRequired).map(extra => extra.name);
}
export function canBrowseCatalog(catalog: StreamingCatalog): boolean {
if (
(catalog.id && catalog.id.startsWith('search.')) ||
(catalog.type && catalog.type.startsWith('search'))
) {
return false;
}
const requiredExtras = getRequiredCatalogExtras(catalog);
return requiredExtras.every(extraName => extraName === 'genre');
}
export function isVisibleOnHome(catalog: StreamingCatalog, addonCatalogs: StreamingCatalog[]): boolean {
if (
(catalog.id && catalog.id.startsWith('search.')) ||
(catalog.type && catalog.type.startsWith('search'))
) {
return false;
}
const requiredExtras = getRequiredCatalogExtras(catalog);
if (requiredExtras.length > 0) {
return false;
}
const addonUsesShowInHome = addonCatalogs.some(addonCatalog => addonCatalog.showInHome === true);
if (addonUsesShowInHome) {
return catalog.showInHome === true;
}
return true;
}
export function canSearchCatalog(catalog: StreamingCatalog): boolean {
if (!catalogSupportsExtra(catalog, 'search')) {
return false;
}
const requiredExtras = getRequiredCatalogExtras(catalog);
return requiredExtras.every(extraName => extraName === 'search');
}

View file

@ -0,0 +1,282 @@
import { stremioService } from '../stremioService';
import { mmkvStorage } from '../mmkvStorage';
import { TMDBService } from '../tmdbService';
import { logger } from '../../utils/logger';
import { convertMetaToStreamingContent, convertMetaToStreamingContentEnhanced } from './content-mappers';
import { addToRecentContent, createLibraryKey, type CatalogLibraryState } from './library';
import { DATA_SOURCE_KEY, DataSource, type StreamingContent } from './types';
export async function getDataSourcePreference(): Promise<DataSource> {
try {
const dataSource = await mmkvStorage.getItem(DATA_SOURCE_KEY);
return (dataSource as DataSource) || DataSource.STREMIO_ADDONS;
} catch (error) {
logger.error('Failed to get data source preference:', error);
return DataSource.STREMIO_ADDONS;
}
}
export async function setDataSourcePreference(dataSource: DataSource): Promise<void> {
try {
await mmkvStorage.setItem(DATA_SOURCE_KEY, dataSource);
} catch (error) {
logger.error('Failed to set data source preference:', error);
}
}
export async function getContentDetails(
state: CatalogLibraryState,
type: string,
id: string,
preferredAddonId?: string
): Promise<StreamingContent | null> {
console.log('🔍 [CatalogService] getContentDetails called:', { type, id, preferredAddonId });
try {
let meta = null;
let lastError = null;
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
console.log(`🔍 [CatalogService] Attempt ${attempt + 1}/2 for getContentDetails:`, { type, id, preferredAddonId });
const isValidId = await stremioService.isValidContentId(type, id);
console.log('🔍 [CatalogService] Content ID validation:', { type, id, isValidId });
if (!isValidId) {
console.log('🔍 [CatalogService] Invalid content ID, breaking retry loop');
break;
}
console.log('🔍 [CatalogService] Calling stremioService.getMetaDetails:', { type, id, preferredAddonId });
meta = await stremioService.getMetaDetails(type, id, preferredAddonId);
console.log('🔍 [CatalogService] stremioService.getMetaDetails result:', {
hasMeta: !!meta,
metaId: meta?.id,
metaName: meta?.name,
metaType: meta?.type,
});
if (meta) {
break;
}
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, attempt)));
} catch (error) {
lastError = error;
console.log(`🔍 [CatalogService] Attempt ${attempt + 1} failed:`, {
errorMessage: error instanceof Error ? error.message : String(error),
isAxiosError: (error as any)?.isAxiosError,
responseStatus: (error as any)?.response?.status,
responseData: (error as any)?.response?.data,
});
logger.error(`Attempt ${attempt + 1} failed to get content details for ${type}:${id}:`, error);
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, attempt)));
}
}
if (meta) {
console.log('🔍 [CatalogService] Meta found, converting to StreamingContent:', {
metaId: meta.id,
metaName: meta.name,
metaType: meta.type,
});
const content = convertMetaToStreamingContentEnhanced(meta, state.library);
addToRecentContent(state, content);
content.inLibrary = state.library[createLibraryKey(type, id)] !== undefined;
console.log('🔍 [CatalogService] Successfully converted meta to StreamingContent:', {
contentId: content.id,
contentName: content.name,
contentType: content.type,
inLibrary: content.inLibrary,
});
return content;
}
console.log('🔍 [CatalogService] No meta found, checking lastError:', {
hasLastError: !!lastError,
lastErrorMessage: lastError instanceof Error ? lastError.message : String(lastError),
});
if (lastError) {
console.log('🔍 [CatalogService] Throwing lastError:', {
errorMessage: lastError instanceof Error ? lastError.message : String(lastError),
isAxiosError: (lastError as any)?.isAxiosError,
responseStatus: (lastError as any)?.response?.status,
});
throw lastError;
}
console.log('🔍 [CatalogService] No meta and no error, returning null');
return null;
} catch (error) {
console.log('🔍 [CatalogService] getContentDetails caught error:', {
errorMessage: error instanceof Error ? error.message : String(error),
isAxiosError: (error as any)?.isAxiosError,
responseStatus: (error as any)?.response?.status,
responseData: (error as any)?.response?.data,
});
logger.error(`Failed to get content details for ${type}:${id}:`, error);
return null;
}
}
export async function getEnhancedContentDetails(
state: CatalogLibraryState,
type: string,
id: string,
preferredAddonId?: string
): Promise<StreamingContent | null> {
console.log('🔍 [CatalogService] getEnhancedContentDetails called:', { type, id, preferredAddonId });
logger.log(`🔍 [MetadataScreen] Fetching enhanced metadata for ${type}:${id} ${preferredAddonId ? `from addon ${preferredAddonId}` : ''}`);
try {
const result = await getContentDetails(state, type, id, preferredAddonId);
console.log('🔍 [CatalogService] getEnhancedContentDetails result:', {
hasResult: !!result,
resultId: result?.id,
resultName: result?.name,
resultType: result?.type,
});
return result;
} catch (error) {
console.log('🔍 [CatalogService] getEnhancedContentDetails error:', {
errorMessage: error instanceof Error ? error.message : String(error),
isAxiosError: (error as any)?.isAxiosError,
responseStatus: (error as any)?.response?.status,
responseData: (error as any)?.response?.data,
});
throw error;
}
}
export async function getBasicContentDetails(
state: CatalogLibraryState,
type: string,
id: string,
preferredAddonId?: string
): Promise<StreamingContent | null> {
try {
let meta = null;
let lastError = null;
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
if (!(await stremioService.isValidContentId(type, id))) {
break;
}
meta = await stremioService.getMetaDetails(type, id, preferredAddonId);
if (meta) {
break;
}
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, attempt)));
} catch (error) {
lastError = error;
logger.error(`Attempt ${attempt + 1} failed to get basic content details for ${type}:${id}:`, error);
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, attempt)));
}
}
if (meta) {
const content = convertMetaToStreamingContent(meta, state.library);
content.inLibrary = state.library[createLibraryKey(type, id)] !== undefined;
return content;
}
if (lastError) {
throw lastError;
}
return null;
} catch (error) {
logger.error(`Failed to get basic content details for ${type}:${id}:`, error);
return null;
}
}
export async function getStremioId(type: string, tmdbId: string): Promise<string | null> {
if (__DEV__) {
console.log('=== CatalogService.getStremioId ===');
console.log('Input type:', type);
console.log('Input tmdbId:', tmdbId);
}
try {
if (type === 'movie') {
if (__DEV__) {
console.log('Processing movie - fetching TMDB details...');
}
const movieDetails = await TMDBService.getInstance().getMovieDetails(tmdbId);
if (__DEV__) {
console.log('Movie details result:', {
id: movieDetails?.id,
title: movieDetails?.title,
imdb_id: movieDetails?.imdb_id,
hasImdbId: !!movieDetails?.imdb_id,
});
}
if (movieDetails?.imdb_id) {
if (__DEV__) {
console.log('Successfully found IMDb ID:', movieDetails.imdb_id);
}
return movieDetails.imdb_id;
}
console.warn('No IMDb ID found for movie:', tmdbId);
return null;
}
if (type === 'tv' || type === 'series') {
if (__DEV__) {
console.log('Processing TV show - fetching TMDB details for IMDb ID...');
}
const externalIds = await TMDBService.getInstance().getShowExternalIds(parseInt(tmdbId, 10));
if (__DEV__) {
console.log('TV show external IDs result:', {
tmdbId,
imdb_id: externalIds?.imdb_id,
hasImdbId: !!externalIds?.imdb_id,
});
}
if (externalIds?.imdb_id) {
if (__DEV__) {
console.log('Successfully found IMDb ID for TV show:', externalIds.imdb_id);
}
return externalIds.imdb_id;
}
console.warn('No IMDb ID found for TV show, falling back to kitsu format:', tmdbId);
const fallbackId = `kitsu:${tmdbId}`;
if (__DEV__) {
console.log('Generated fallback Stremio ID for TV:', fallbackId);
}
return fallbackId;
}
console.warn('Unknown type provided:', type);
return null;
} catch (error: any) {
if (__DEV__) {
console.error('=== Error in getStremioId ===');
console.error('Type:', type);
console.error('TMDB ID:', tmdbId);
console.error('Error details:', error);
console.error('Error message:', error.message);
}
logger.error('Error getting Stremio ID:', error);
return null;
}
}

View file

@ -0,0 +1,157 @@
import { TMDBService } from '../tmdbService';
import { logger } from '../../utils/logger';
import type { Meta } from '../stremioService';
import { createLibraryKey } from './library';
import type { StreamingContent } from './types';
const FALLBACK_POSTER_URL = 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image';
export function convertMetaToStreamingContent(
meta: Meta,
library: Record<string, StreamingContent>
): StreamingContent {
let posterUrl = meta.poster;
if (!posterUrl || posterUrl.trim() === '' || posterUrl === 'null' || posterUrl === 'undefined') {
posterUrl = FALLBACK_POSTER_URL;
}
let logoUrl = (meta as any).logo;
if (!logoUrl || logoUrl.trim() === '' || logoUrl === 'null' || logoUrl === 'undefined') {
logoUrl = undefined;
}
return {
id: meta.id,
type: meta.type,
name: meta.name,
poster: posterUrl,
posterShape: meta.posterShape || 'poster',
banner: meta.background,
logo: logoUrl,
imdbRating: meta.imdbRating,
year: meta.year,
genres: meta.genres,
description: meta.description,
runtime: meta.runtime,
inLibrary: library[createLibraryKey(meta.type, meta.id)] !== undefined,
certification: meta.certification,
releaseInfo: meta.releaseInfo,
};
}
export function convertMetaToStreamingContentEnhanced(
meta: Meta,
library: Record<string, StreamingContent>
): StreamingContent {
const converted: StreamingContent = {
id: meta.id,
type: meta.type,
name: meta.name,
poster: meta.poster || FALLBACK_POSTER_URL,
posterShape: meta.posterShape || 'poster',
banner: meta.background,
logo: (meta as any).logo || undefined,
imdbRating: meta.imdbRating,
year: meta.year,
genres: meta.genres,
description: meta.description,
runtime: meta.runtime,
inLibrary: library[createLibraryKey(meta.type, meta.id)] !== undefined,
certification: meta.certification,
directors: (meta as any).director
? (Array.isArray((meta as any).director) ? (meta as any).director : [(meta as any).director])
: undefined,
writer: (meta as any).writer || undefined,
country: (meta as any).country || undefined,
imdb_id: (meta as any).imdb_id || undefined,
slug: (meta as any).slug || undefined,
releaseInfo: meta.releaseInfo || (meta as any).releaseInfo || undefined,
trailerStreams: (meta as any).trailerStreams || undefined,
links: (meta as any).links || undefined,
behaviorHints: (meta as any).behaviorHints || undefined,
};
if ((meta as any).app_extras?.cast && Array.isArray((meta as any).app_extras.cast)) {
converted.addonCast = (meta as any).app_extras.cast.map((castMember: any, index: number) => ({
id: index + 1,
name: castMember.name || 'Unknown',
character: castMember.character || '',
profile_path: castMember.photo || null,
}));
} else if (meta.cast && Array.isArray(meta.cast)) {
converted.addonCast = meta.cast.map((castName: string, index: number) => ({
id: index + 1,
name: castName || 'Unknown',
character: '',
profile_path: null,
}));
}
if ((meta as any).trailerStreams?.length > 0) {
logger.log(`🎬 Enhanced metadata: Found ${(meta as any).trailerStreams.length} trailers for ${meta.name}`);
}
if ((meta as any).links?.length > 0) {
logger.log(`🔗 Enhanced metadata: Found ${(meta as any).links.length} links for ${meta.name}`);
}
if (converted.addonCast && converted.addonCast.length > 0) {
logger.log(`🎭 Enhanced metadata: Found ${converted.addonCast.length} cast members from addon for ${meta.name}`);
}
if ((meta as any).videos) {
converted.videos = (meta as any).videos;
}
return converted;
}
export async function convertTMDBToStreamingContent(
item: any,
type: 'movie' | 'tv',
library: Record<string, StreamingContent>
): Promise<StreamingContent> {
const id = item.external_ids?.imdb_id || `tmdb:${item.id}`;
const name = type === 'movie' ? item.title : item.name;
const posterPath = item.poster_path;
let genres: string[] = [];
if (item.genre_ids && item.genre_ids.length > 0) {
try {
const tmdbService = TMDBService.getInstance();
const genreLists = type === 'movie'
? await tmdbService.getMovieGenres()
: await tmdbService.getTvGenres();
genres = item.genre_ids
.map((genreId: number) => {
const genre = genreLists.find(currentGenre => currentGenre.id === genreId);
return genre ? genre.name : null;
})
.filter(Boolean) as string[];
} catch (error) {
logger.error('Failed to get genres for TMDB content:', error);
}
}
const contentType = type === 'movie' ? 'movie' : 'series';
return {
id,
type: contentType,
name: name || 'Unknown',
poster: posterPath
? `https://image.tmdb.org/t/p/w500${posterPath}`
: FALLBACK_POSTER_URL,
posterShape: 'poster',
banner: item.backdrop_path ? `https://image.tmdb.org/t/p/original${item.backdrop_path}` : undefined,
year: type === 'movie'
? (item.release_date ? new Date(item.release_date).getFullYear() : undefined)
: (item.first_air_date ? new Date(item.first_air_date).getFullYear() : undefined),
description: item.overview,
genres,
inLibrary: library[createLibraryKey(contentType, id)] !== undefined,
};
}

View file

@ -0,0 +1,398 @@
import { stremioService } from '../stremioService';
import { TMDBService } from '../tmdbService';
import { logger } from '../../utils/logger';
import { getCatalogDisplayName } from '../../utils/catalogNameUtils';
import {
canBrowseCatalog,
convertManifestToStreamingAddon,
getAllAddons,
isVisibleOnHome,
} from './catalog-utils';
import { convertMetaToStreamingContent, convertTMDBToStreamingContent } from './content-mappers';
import type { CatalogContent, DataSource, StreamingAddon, StreamingCatalog, StreamingContent } from './types';
export async function getAllStreamingAddons(): Promise<StreamingAddon[]> {
return getAllAddons(() => stremioService.getInstalledAddonsAsync());
}
export async function resolveHomeCatalogsToFetch(
limitIds?: string[]
): Promise<Array<{ addon: StreamingAddon; catalog: StreamingCatalog }>> {
const addons = await getAllStreamingAddons();
const potentialCatalogs: Array<{ addon: StreamingAddon; catalog: StreamingCatalog }> = [];
for (const addon of addons) {
for (const catalog of addon.catalogs || []) {
if (isVisibleOnHome(catalog, addon.catalogs)) {
potentialCatalogs.push({ addon, catalog });
}
}
}
if (limitIds && limitIds.length > 0) {
return potentialCatalogs.filter(item => {
const catalogId = `${item.addon.id}:${item.catalog.type}:${item.catalog.id}`;
return limitIds.includes(catalogId);
});
}
return potentialCatalogs.sort(() => 0.5 - Math.random()).slice(0, 5);
}
export async function fetchHomeCatalog(
library: Record<string, StreamingContent>,
addon: StreamingAddon,
catalog: StreamingCatalog
): Promise<CatalogContent | null> {
try {
const addonManifests = await stremioService.getInstalledAddonsAsync();
const manifest = addonManifests.find(currentAddon => currentAddon.id === addon.id);
if (!manifest) {
return null;
}
const metas = await stremioService.getCatalog(manifest, catalog.type, catalog.id, 1);
if (!metas || metas.length === 0) {
return null;
}
const items = metas.slice(0, 12).map(meta => convertMetaToStreamingContent(meta, library));
const originalName = catalog.name || catalog.id;
let displayName = await getCatalogDisplayName(addon.id, catalog.type, catalog.id, originalName);
const isCustom = displayName !== originalName;
if (!isCustom) {
const uniqueWords: string[] = [];
const seenWords = new Set<string>();
for (const word of displayName.split(' ')) {
const normalizedWord = word.toLowerCase();
if (!seenWords.has(normalizedWord)) {
uniqueWords.push(word);
seenWords.add(normalizedWord);
}
}
displayName = uniqueWords.join(' ');
const contentType = catalog.type === 'movie' ? 'Movies' : 'TV Shows';
if (!displayName.toLowerCase().includes(contentType.toLowerCase())) {
displayName = `${displayName} ${contentType}`;
}
}
return {
addon: addon.id,
type: catalog.type,
id: catalog.id,
name: displayName,
items,
};
} catch (error) {
logger.error(`Failed to load ${catalog.name} from ${addon.name}:`, error);
return null;
}
}
export async function getHomeCatalogs(
library: Record<string, StreamingContent>,
limitIds?: string[]
): Promise<CatalogContent[]> {
const catalogsToFetch = await resolveHomeCatalogsToFetch(limitIds);
const catalogResults = await Promise.all(
catalogsToFetch.map(({ addon, catalog }) => fetchHomeCatalog(library, addon, catalog))
);
return catalogResults.filter((catalog): catalog is CatalogContent => catalog !== null);
}
export async function getCatalogByType(
library: Record<string, StreamingContent>,
dataSourcePreference: DataSource,
type: string,
genreFilter?: string
): Promise<CatalogContent[]> {
if (dataSourcePreference === 'tmdb') {
return getCatalogByTypeFromTMDB(library, type, genreFilter);
}
const addons = await getAllStreamingAddons();
const typeAddons = addons.filter(addon => addon.catalogs.some(catalog => catalog.type === type));
const manifests = await stremioService.getInstalledAddonsAsync();
const manifestMap = new Map(manifests.map(manifest => [manifest.id, manifest]));
const catalogPromises: Array<Promise<CatalogContent | null>> = [];
for (const addon of typeAddons) {
const typeCatalogs = addon.catalogs.filter(
catalog => catalog.type === type && isVisibleOnHome(catalog, addon.catalogs)
);
for (const catalog of typeCatalogs) {
catalogPromises.push(
(async () => {
try {
const manifest = manifestMap.get(addon.id);
if (!manifest) {
return null;
}
const filters = genreFilter ? [{ title: 'genre', value: genreFilter }] : [];
const metas = await stremioService.getCatalog(manifest, type, catalog.id, 1, filters);
if (!metas || metas.length === 0) {
return null;
}
return {
addon: addon.id,
type,
id: catalog.id,
name: await getCatalogDisplayName(addon.id, catalog.type, catalog.id, catalog.name),
genre: genreFilter,
items: metas.map(meta => convertMetaToStreamingContent(meta, library)),
};
} catch (error) {
logger.error(`Failed to get catalog ${catalog.id} for addon ${addon.id}:`, error);
return null;
}
})()
);
}
}
const catalogResults = await Promise.all(catalogPromises);
return catalogResults.filter((catalog): catalog is CatalogContent => catalog !== null);
}
async function getCatalogByTypeFromTMDB(
library: Record<string, StreamingContent>,
type: string,
genreFilter?: string
): Promise<CatalogContent[]> {
const tmdbService = TMDBService.getInstance();
const tmdbType = type === 'movie' ? 'movie' : 'tv';
try {
if (!genreFilter || genreFilter === 'All') {
return Promise.all([
(async () => ({
addon: 'tmdb',
type,
id: 'trending',
name: `Trending ${type === 'movie' ? 'Movies' : 'TV Shows'}`,
items: await Promise.all(
(await tmdbService.getTrending(tmdbType, 'week')).map(item =>
convertTMDBToStreamingContent(item, tmdbType, library)
)
),
}))(),
(async () => ({
addon: 'tmdb',
type,
id: 'popular',
name: `Popular ${type === 'movie' ? 'Movies' : 'TV Shows'}`,
items: await Promise.all(
(await tmdbService.getPopular(tmdbType, 1)).map(item =>
convertTMDBToStreamingContent(item, tmdbType, library)
)
),
}))(),
(async () => ({
addon: 'tmdb',
type,
id: 'upcoming',
name: type === 'movie' ? 'Upcoming Movies' : 'On Air TV Shows',
items: await Promise.all(
(await tmdbService.getUpcoming(tmdbType, 1)).map(item =>
convertTMDBToStreamingContent(item, tmdbType, library)
)
),
}))(),
]);
}
return [{
addon: 'tmdb',
type,
id: 'discover',
name: `${genreFilter} ${type === 'movie' ? 'Movies' : 'TV Shows'}`,
genre: genreFilter,
items: await Promise.all(
(await tmdbService.discoverByGenre(tmdbType, genreFilter)).map(item =>
convertTMDBToStreamingContent(item, tmdbType, library)
)
),
}];
} catch (error) {
logger.error(`Failed to get catalog from TMDB for type ${type}, genre ${genreFilter}:`, error);
return [];
}
}
export async function getDiscoverFilters(): Promise<{
genres: string[];
types: string[];
catalogsByType: Record<
string,
Array<{ addonId: string; addonName: string; catalogId: string; catalogName: string; genres: string[] }>
>;
}> {
const addons = await getAllStreamingAddons();
const allGenres = new Set<string>();
const allTypes = new Set<string>();
const catalogsByType: Record<
string,
Array<{ addonId: string; addonName: string; catalogId: string; catalogName: string; genres: string[] }>
> = {};
for (const addon of addons) {
for (const catalog of addon.catalogs || []) {
if (!canBrowseCatalog(catalog)) {
continue;
}
if (catalog.type) {
allTypes.add(catalog.type);
}
const catalogGenres: string[] = [];
for (const extra of catalog.extra || []) {
if (extra.name === 'genre' && Array.isArray(extra.options)) {
for (const genre of extra.options) {
allGenres.add(genre);
catalogGenres.push(genre);
}
}
}
if (catalog.type) {
catalogsByType[catalog.type] ||= [];
catalogsByType[catalog.type].push({
addonId: addon.id,
addonName: addon.name,
catalogId: catalog.id,
catalogName: catalog.name || catalog.id,
genres: catalogGenres,
});
}
}
}
return {
genres: Array.from(allGenres).sort((left, right) => left.localeCompare(right)),
types: Array.from(allTypes),
catalogsByType,
};
}
export async function discoverContent(
library: Record<string, StreamingContent>,
type: string,
genre?: string,
limit = 20
): Promise<Array<{ addonName: string; items: StreamingContent[] }>> {
const addons = await getAllStreamingAddons();
const manifests = await stremioService.getInstalledAddonsAsync();
const manifestMap = new Map(manifests.map(manifest => [manifest.id, manifest]));
const catalogPromises: Array<Promise<{ addonName: string; items: StreamingContent[] } | null>> = [];
for (const addon of addons) {
const matchingCatalogs = addon.catalogs.filter(
catalog => catalog.type === type && canBrowseCatalog(catalog)
);
for (const catalog of matchingCatalogs) {
const supportsGenre = catalog.extra?.some(extra => extra.name === 'genre') ||
catalog.extraSupported?.includes('genre');
if (genre && !supportsGenre) {
continue;
}
const manifest = manifestMap.get(addon.id);
if (!manifest) {
continue;
}
catalogPromises.push(
(async () => {
try {
const filters = genre ? [{ title: 'genre', value: genre }] : [];
const metas = await stremioService.getCatalog(manifest, type, catalog.id, 1, filters);
if (!metas || metas.length === 0) {
return null;
}
return {
addonName: addon.name,
items: metas.slice(0, limit).map(meta => ({
...convertMetaToStreamingContent(meta, library),
addonId: addon.id,
})),
};
} catch (error) {
logger.error(`Discover failed for ${catalog.id} in addon ${addon.id}:`, error);
return null;
}
})()
);
}
}
const addonMap = new Map<string, StreamingContent[]>();
for (const result of await Promise.all(catalogPromises)) {
if (!result || result.items.length === 0) {
continue;
}
const existingItems = addonMap.get(result.addonName) || [];
const existingIds = new Set(existingItems.map(item => `${item.type}:${item.id}`));
const newItems = result.items.filter(item => !existingIds.has(`${item.type}:${item.id}`));
addonMap.set(result.addonName, [...existingItems, ...newItems]);
}
return Array.from(addonMap.entries()).map(([addonName, items]) => ({
addonName,
items: items.slice(0, limit),
}));
}
export async function discoverContentFromCatalog(
library: Record<string, StreamingContent>,
addonId: string,
catalogId: string,
type: string,
genre?: string,
page = 1
): Promise<StreamingContent[]> {
try {
const manifests = await stremioService.getInstalledAddonsAsync();
const manifest = manifests.find(currentManifest => currentManifest.id === addonId);
if (!manifest) {
logger.error(`Addon ${addonId} not found`);
return [];
}
const catalog = (manifest.catalogs || []).find(item => item.type === type && item.id === catalogId);
if (!catalog || !canBrowseCatalog(convertManifestToStreamingAddon(manifest).catalogs.find(
item => item.type === type && item.id === catalogId
) || { ...catalog, extraSupported: catalog.extraSupported || [], extra: catalog.extra || [] })) {
logger.warn(`Catalog ${catalogId} in addon ${addonId} is not browseable`);
return [];
}
const filters = genre ? [{ title: 'genre', value: genre }] : [];
const metas = await stremioService.getCatalog(manifest, type, catalogId, page, filters);
return (metas || []).map(meta => ({
...convertMetaToStreamingContent(meta, library),
addonId,
}));
} catch (error) {
logger.error(`Discover from catalog failed for ${addonId}/${catalogId}:`, error);
return [];
}
}

View file

@ -0,0 +1,329 @@
import { notificationService } from '../notificationService';
import { mmkvStorage } from '../mmkvStorage';
import { logger } from '../../utils/logger';
import type { StreamingContent } from './types';
export interface CatalogLibraryState {
LEGACY_LIBRARY_KEY: string;
RECENT_CONTENT_KEY: string;
MAX_RECENT_ITEMS: number;
library: Record<string, StreamingContent>;
recentContent: StreamingContent[];
librarySubscribers: Array<(items: StreamingContent[]) => void>;
libraryAddListeners: Array<(item: StreamingContent) => void>;
libraryRemoveListeners: Array<(type: string, id: string) => void>;
initPromise: Promise<void>;
isInitialized: boolean;
}
export function createLibraryKey(type: string, id: string): string {
return `${type}:${id}`;
}
export async function initializeCatalogState(state: CatalogLibraryState): Promise<void> {
logger.log('[CatalogService] Starting initialization...');
try {
logger.log('[CatalogService] Step 1: Initializing scope...');
await initializeScope();
logger.log('[CatalogService] Step 2: Loading library...');
await loadLibrary(state);
logger.log('[CatalogService] Step 3: Loading recent content...');
await loadRecentContent(state);
state.isInitialized = true;
logger.log(
`[CatalogService] Initialization completed successfully. Library contains ${Object.keys(state.library).length} items.`
);
} catch (error) {
logger.error('[CatalogService] Initialization failed:', error);
state.isInitialized = true;
}
}
export async function ensureCatalogInitialized(state: CatalogLibraryState): Promise<void> {
logger.log(`[CatalogService] ensureInitialized() called. isInitialized: ${state.isInitialized}`);
try {
await state.initPromise;
logger.log(
`[CatalogService] ensureInitialized() completed. Library ready with ${Object.keys(state.library).length} items.`
);
} catch (error) {
logger.error('[CatalogService] Error waiting for initialization:', error);
}
}
async function initializeScope(): Promise<void> {
try {
const currentScope = await mmkvStorage.getItem('@user:current');
if (!currentScope) {
await mmkvStorage.setItem('@user:current', 'local');
logger.log('[CatalogService] Initialized @user:current scope to "local"');
return;
}
logger.log(`[CatalogService] Using existing scope: "${currentScope}"`);
} catch (error) {
logger.error('[CatalogService] Failed to initialize scope:', error);
}
}
async function loadLibrary(state: CatalogLibraryState): Promise<void> {
try {
const scope = (await mmkvStorage.getItem('@user:current')) || 'local';
const scopedKey = `@user:${scope}:stremio-library`;
let storedLibrary = await mmkvStorage.getItem(scopedKey);
if (!storedLibrary) {
storedLibrary = await mmkvStorage.getItem(state.LEGACY_LIBRARY_KEY);
if (storedLibrary) {
await mmkvStorage.setItem(scopedKey, storedLibrary);
}
}
if (storedLibrary) {
const parsedLibrary = JSON.parse(storedLibrary);
logger.log(
`[CatalogService] Raw library data type: ${Array.isArray(parsedLibrary) ? 'ARRAY' : 'OBJECT'}, keys: ${JSON.stringify(Object.keys(parsedLibrary).slice(0, 5))}`
);
if (Array.isArray(parsedLibrary)) {
logger.log('[CatalogService] WARNING: Library is stored as ARRAY format. Converting to OBJECT format.');
const libraryObject: Record<string, StreamingContent> = {};
for (const item of parsedLibrary) {
libraryObject[createLibraryKey(item.type, item.id)] = item;
}
state.library = libraryObject;
logger.log(`[CatalogService] Converted ${parsedLibrary.length} items from array to object format`);
const normalizedLibrary = JSON.stringify(state.library);
await mmkvStorage.setItem(scopedKey, normalizedLibrary);
await mmkvStorage.setItem(state.LEGACY_LIBRARY_KEY, normalizedLibrary);
logger.log('[CatalogService] Re-saved library in correct format');
} else {
state.library = parsedLibrary;
}
logger.log(
`[CatalogService] Library loaded successfully with ${Object.keys(state.library).length} items from scope: ${scope}`
);
} else {
logger.log(`[CatalogService] No library data found for scope: ${scope}`);
state.library = {};
}
await mmkvStorage.setItem('@user:current', scope);
} catch (error: any) {
logger.error('Failed to load library:', error);
state.library = {};
}
}
async function saveLibrary(state: CatalogLibraryState): Promise<void> {
if (state.isInitialized) {
await ensureCatalogInitialized(state);
}
try {
const itemCount = Object.keys(state.library).length;
const scope = (await mmkvStorage.getItem('@user:current')) || 'local';
const scopedKey = `@user:${scope}:stremio-library`;
const libraryData = JSON.stringify(state.library);
logger.log(`[CatalogService] Saving library with ${itemCount} items to scope: "${scope}" (key: ${scopedKey})`);
await mmkvStorage.setItem(scopedKey, libraryData);
await mmkvStorage.setItem(state.LEGACY_LIBRARY_KEY, libraryData);
logger.log(`[CatalogService] Library saved successfully with ${itemCount} items`);
} catch (error: any) {
logger.error('Failed to save library:', error);
logger.error(
`[CatalogService] Library save failed details - scope: ${(await mmkvStorage.getItem('@user:current')) || 'unknown'}, itemCount: ${Object.keys(state.library).length}`
);
}
}
async function loadRecentContent(state: CatalogLibraryState): Promise<void> {
try {
const storedRecentContent = await mmkvStorage.getItem(state.RECENT_CONTENT_KEY);
if (storedRecentContent) {
state.recentContent = JSON.parse(storedRecentContent);
}
} catch (error: any) {
logger.error('Failed to load recent content:', error);
}
}
async function saveRecentContent(state: CatalogLibraryState): Promise<void> {
try {
await mmkvStorage.setItem(state.RECENT_CONTENT_KEY, JSON.stringify(state.recentContent));
} catch (error: any) {
logger.error('Failed to save recent content:', error);
}
}
function notifyLibrarySubscribers(state: CatalogLibraryState): void {
const items = Object.values(state.library);
state.librarySubscribers.forEach(callback => callback(items));
}
export async function getLibraryItems(state: CatalogLibraryState): Promise<StreamingContent[]> {
if (!state.isInitialized) {
await ensureCatalogInitialized(state);
}
return Object.values(state.library);
}
export function subscribeToLibraryUpdates(
state: CatalogLibraryState,
callback: (items: StreamingContent[]) => void
): () => void {
state.librarySubscribers.push(callback);
Promise.resolve().then(() => {
getLibraryItems(state).then(items => {
if (state.librarySubscribers.includes(callback)) {
callback(items);
}
});
});
return () => {
const index = state.librarySubscribers.indexOf(callback);
if (index > -1) {
state.librarySubscribers.splice(index, 1);
}
};
}
export function onLibraryAdd(
state: CatalogLibraryState,
listener: (item: StreamingContent) => void
): () => void {
state.libraryAddListeners.push(listener);
return () => {
state.libraryAddListeners = state.libraryAddListeners.filter(currentListener => currentListener !== listener);
};
}
export function onLibraryRemove(
state: CatalogLibraryState,
listener: (type: string, id: string) => void
): () => void {
state.libraryRemoveListeners.push(listener);
return () => {
state.libraryRemoveListeners = state.libraryRemoveListeners.filter(
currentListener => currentListener !== listener
);
};
}
export async function addToLibrary(state: CatalogLibraryState, content: StreamingContent): Promise<void> {
logger.log(`[CatalogService] addToLibrary() called for: ${content.type}:${content.id} (${content.name})`);
await ensureCatalogInitialized(state);
const key = createLibraryKey(content.type, content.id);
const itemCountBefore = Object.keys(state.library).length;
logger.log(`[CatalogService] Adding to library with key: "${key}". Current library keys: [${Object.keys(state.library).length}] items`);
state.library[key] = {
...content,
addedToLibraryAt: Date.now(),
};
const itemCountAfter = Object.keys(state.library).length;
logger.log(
`[CatalogService] Library updated: ${itemCountBefore} -> ${itemCountAfter} items. New library keys: [${Object.keys(state.library).slice(0, 5).join(', ')}${Object.keys(state.library).length > 5 ? '...' : ''}]`
);
await saveLibrary(state);
logger.log(`[CatalogService] addToLibrary() completed for: ${content.type}:${content.id}`);
notifyLibrarySubscribers(state);
try {
state.libraryAddListeners.forEach(listener => listener(content));
} catch {}
if (content.type === 'series') {
try {
await notificationService.updateNotificationsForSeries(content.id);
console.log(`[CatalogService] Auto-setup notifications for series: ${content.name}`);
} catch (error) {
console.error(`[CatalogService] Failed to setup notifications for ${content.name}:`, error);
}
}
}
export async function removeFromLibrary(
state: CatalogLibraryState,
type: string,
id: string
): Promise<void> {
logger.log(`[CatalogService] removeFromLibrary() called for: ${type}:${id}`);
await ensureCatalogInitialized(state);
const key = createLibraryKey(type, id);
const itemCountBefore = Object.keys(state.library).length;
const itemExisted = key in state.library;
logger.log(
`[CatalogService] Removing key: "${key}". Currently library has ${itemCountBefore} items with keys: [${Object.keys(state.library).slice(0, 5).join(', ')}${Object.keys(state.library).length > 5 ? '...' : ''}]`
);
delete state.library[key];
const itemCountAfter = Object.keys(state.library).length;
logger.log(`[CatalogService] Library updated: ${itemCountBefore} -> ${itemCountAfter} items (existed: ${itemExisted})`);
await saveLibrary(state);
logger.log(`[CatalogService] removeFromLibrary() completed for: ${type}:${id}`);
notifyLibrarySubscribers(state);
try {
state.libraryRemoveListeners.forEach(listener => listener(type, id));
} catch {}
if (type === 'series') {
try {
const scheduledNotifications = notificationService.getScheduledNotifications();
const seriesToCancel = scheduledNotifications.filter(notification => notification.seriesId === id);
for (const notification of seriesToCancel) {
await notificationService.cancelNotification(notification.id);
}
console.log(`[CatalogService] Cancelled ${seriesToCancel.length} notifications for removed series: ${id}`);
} catch (error) {
console.error(`[CatalogService] Failed to cancel notifications for removed series ${id}:`, error);
}
}
}
export function addToRecentContent(state: CatalogLibraryState, content: StreamingContent): void {
state.recentContent = state.recentContent.filter(item => !(item.id === content.id && item.type === content.type));
state.recentContent.unshift(content);
if (state.recentContent.length > state.MAX_RECENT_ITEMS) {
state.recentContent = state.recentContent.slice(0, state.MAX_RECENT_ITEMS);
}
void saveRecentContent(state);
}
export function getRecentContent(state: CatalogLibraryState): StreamingContent[] {
return state.recentContent;
}

View file

@ -0,0 +1,401 @@
import axios from 'axios';
import { stremioService, type Manifest } from '../stremioService';
import { logger } from '../../utils/logger';
import { createSafeAxiosConfig } from '../../utils/axiosConfig';
import { canSearchCatalog, getAllAddons } from './catalog-utils';
import { convertMetaToStreamingContent } from './content-mappers';
import type { AddonSearchResults, GroupedSearchResults, StreamingContent } from './types';
type PendingSection = {
addonId: string;
addonName: string;
sectionName: string;
catalogIndex: number;
results: StreamingContent[];
};
export async function searchContent(
library: Record<string, StreamingContent>,
query: string
): Promise<StreamingContent[]> {
if (!query || query.trim().length < 2) {
return [];
}
const addons = await getAllAddons(() => stremioService.getInstalledAddonsAsync());
const manifests = await stremioService.getInstalledAddonsAsync();
const manifestMap = new Map(manifests.map(manifest => [manifest.id, manifest]));
const results: StreamingContent[] = [];
await Promise.all(
addons.flatMap(addon =>
(addon.catalogs || [])
.filter(catalog => canSearchCatalog(catalog))
.map(async catalog => {
const manifest = manifestMap.get(addon.id);
if (!manifest) {
return;
}
try {
const metas = await stremioService.getCatalog(
manifest,
catalog.type,
catalog.id,
1,
[{ title: 'search', value: query }]
);
if (metas?.length) {
results.push(
...metas.map(meta => ({
...convertMetaToStreamingContent(meta, library),
addonId: addon.id,
}))
);
}
} catch (error) {
logger.error(`Search failed for ${catalog.id} in addon ${addon.id}:`, error);
}
})
)
);
return Array.from(new Map(results.map(item => [`${item.type}:${item.id}`, item])).values());
}
export async function searchContentCinemeta(
library: Record<string, StreamingContent>,
query: string
): Promise<GroupedSearchResults> {
if (!query) {
return { byAddon: [], allResults: [] };
}
const trimmedQuery = query.trim().toLowerCase();
logger.log('Searching across all addons for:', trimmedQuery);
const addons = await getAllAddons(() => stremioService.getInstalledAddonsAsync());
const manifests = await stremioService.getInstalledAddonsAsync();
const manifestMap = new Map(manifests.map(manifest => [manifest.id, manifest]));
const searchableAddons = addons.filter(addon => addon.catalogs.some(catalog => canSearchCatalog(catalog)));
const byAddon: AddonSearchResults[] = [];
logger.log(`Found ${searchableAddons.length} searchable addons:`, searchableAddons.map(addon => addon.name).join(', '));
for (const [addonIndex, addon] of searchableAddons.entries()) {
const manifest = manifestMap.get(addon.id);
if (!manifest) {
logger.warn(`Manifest not found for addon ${addon.name} (${addon.id})`);
continue;
}
const catalogResults = await Promise.allSettled(
addon.catalogs
.filter(catalog => canSearchCatalog(catalog))
.map(catalog => searchAddonCatalog(library, manifest, catalog.type, catalog.id, trimmedQuery))
);
const addonResults: StreamingContent[] = [];
for (const result of catalogResults) {
if (result.status === 'fulfilled' && result.value) {
addonResults.push(...result.value);
} else if (result.status === 'rejected') {
logger.error(`Search failed for ${addon.name}:`, result.reason);
}
}
if (addonResults.length > 0) {
const seen = new Set<string>();
byAddon.push({
addonId: addon.id,
addonName: addon.name,
sectionName: addon.name,
catalogIndex: addonIndex,
results: addonResults.filter(item => {
const key = `${item.type}:${item.id}`;
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
}),
});
}
}
const allResults: StreamingContent[] = [];
const globalSeen = new Set<string>();
for (const addonGroup of byAddon) {
for (const item of addonGroup.results) {
const key = `${item.type}:${item.id}`;
if (!globalSeen.has(key)) {
globalSeen.add(key);
allResults.push(item);
}
}
}
logger.log(`Search complete: ${byAddon.length} addons returned results, ${allResults.length} unique items total`);
return { byAddon, allResults };
}
export function startLiveSearch(
library: Record<string, StreamingContent>,
query: string,
onAddonResults: (section: AddonSearchResults) => void
): { cancel: () => void; done: Promise<void> } {
const controller = { cancelled: false };
const done = (async () => {
if (!query || !query.trim()) {
return;
}
const trimmedQuery = query.trim().toLowerCase();
logger.log('Live search across addons for:', trimmedQuery);
const addons = await getAllAddons(() => stremioService.getInstalledAddonsAsync());
logger.log(`Total addons available: ${addons.length}`);
const manifests = await stremioService.getInstalledAddonsAsync();
const manifestMap = new Map(manifests.map(manifest => [manifest.id, manifest]));
const searchableAddons = addons.filter(addon =>
(addon.catalogs || []).some(catalog => canSearchCatalog(catalog))
);
logger.log(
`Found ${searchableAddons.length} searchable addons:`,
searchableAddons.map(addon => `${addon.name} (${addon.id})`).join(', ')
);
if (searchableAddons.length === 0) {
logger.warn('No searchable addons found. Make sure you have addons installed that support search functionality.');
return;
}
const addonOrderRef: Record<string, number> = {};
searchableAddons.forEach((addon, index) => {
addonOrderRef[addon.id] = index;
});
const catalogTypeLabels: Record<string, string> = {
movie: 'Movies',
series: 'TV Shows',
'anime.series': 'Anime Series',
'anime.movie': 'Anime Movies',
other: 'Other',
tv: 'TV',
channel: 'Channels',
};
const genericCatalogNames = new Set(['search', 'Search']);
const allPendingSections: PendingSection[] = [];
await Promise.all(
searchableAddons.map(async addon => {
if (controller.cancelled) {
return;
}
try {
const manifest = manifestMap.get(addon.id);
if (!manifest) {
logger.warn(`Manifest not found for addon ${addon.name} (${addon.id})`);
return;
}
const searchableCatalogs = (addon.catalogs || []).filter(catalog => canSearchCatalog(catalog));
logger.log(`Searching ${addon.name} (${addon.id}) with ${searchableCatalogs.length} searchable catalogs`);
const settled = await Promise.allSettled(
searchableCatalogs.map(catalog =>
searchAddonCatalog(library, manifest, catalog.type, catalog.id, trimmedQuery)
)
);
if (controller.cancelled) {
return;
}
const addonRank = addonOrderRef[addon.id] ?? Number.MAX_SAFE_INTEGER;
if (searchableCatalogs.length > 1) {
searchableCatalogs.forEach((catalog, index) => {
const result = settled[index];
if (result.status === 'rejected' || !result.value?.length) {
if (result.status === 'rejected') {
logger.warn(`Search failed for ${catalog.id} in ${addon.name}:`, result.reason);
}
return;
}
const sectionName = buildSectionName(
addon.name,
catalog.name,
catalog.type,
genericCatalogNames,
catalogTypeLabels
);
allPendingSections.push({
addonId: `${addon.id}||${catalog.type}||${catalog.id}`,
addonName: addon.name,
sectionName,
catalogIndex: addonRank * 1000 + index,
results: dedupeAndStampResults(result.value, catalog.type),
});
});
return;
}
const result = settled[0];
const catalog = searchableCatalogs[0];
if (!result || result.status === 'rejected' || !result.value?.length) {
if (result?.status === 'rejected') {
logger.warn(`Search failed for ${addon.name}:`, result.reason);
}
return;
}
allPendingSections.push({
addonId: addon.id,
addonName: addon.name,
sectionName: addon.name,
catalogIndex: addonRank * 1000,
results: dedupeAndStampResults(result.value, catalog.type),
});
} catch (error) {
logger.error(`Error searching addon ${addon.name} (${addon.id}):`, error);
}
})
);
if (controller.cancelled) {
return;
}
allPendingSections.sort((left, right) => left.catalogIndex - right.catalogIndex);
for (const section of allPendingSections) {
if (controller.cancelled) {
return;
}
if (section.results.length > 0) {
logger.log(`Emitting ${section.results.length} results from ${section.sectionName}`);
onAddonResults(section);
}
}
})();
return {
cancel: () => {
controller.cancelled = true;
},
done,
};
}
async function searchAddonCatalog(
library: Record<string, StreamingContent>,
manifest: Manifest,
type: string,
catalogId: string,
query: string
): Promise<StreamingContent[]> {
try {
const url = buildSearchUrl(manifest, type, catalogId, query);
if (!url) {
return [];
}
logger.log(`Searching ${manifest.name} (${type}/${catalogId}):`, url);
const response = await axios.get<{ metas: any[] }>(url, createSafeAxiosConfig(10000));
const metas = response.data?.metas || [];
if (metas.length === 0) {
return [];
}
const items = metas.map(meta => {
const content = convertMetaToStreamingContent(meta, library);
content.addonId = manifest.id;
if (type && content.type !== type) {
content.type = type;
}
return content;
});
logger.log(`Found ${items.length} results from ${manifest.name}`);
return items;
} catch (error: any) {
const errorMessage = error?.response?.status
? `HTTP ${error.response.status}`
: error?.message || 'Unknown error';
const errorUrl = error?.config?.url || 'unknown URL';
logger.error(`Search failed for ${manifest.name} (${type}/${catalogId}) at ${errorUrl}: ${errorMessage}`);
if (error?.response?.data) {
logger.error('Response data:', error.response.data);
}
return [];
}
}
function buildSearchUrl(manifest: Manifest, type: string, catalogId: string, query: string): string | null {
if (manifest.id === 'com.linvo.cinemeta') {
return `https://v3-cinemeta.strem.io/catalog/${type}/${encodeURIComponent(catalogId)}/search=${encodeURIComponent(query)}.json`;
}
const chosenUrl = manifest.url || manifest.originalUrl;
if (!chosenUrl) {
logger.warn(`Addon ${manifest.name} (${manifest.id}) has no URL, skipping search`);
return null;
}
const [baseUrlPart, queryParams] = chosenUrl.split('?');
let cleanBaseUrl = baseUrlPart.replace(/manifest\.json$/, '').replace(/\/$/, '');
if (!cleanBaseUrl.startsWith('http')) {
cleanBaseUrl = `https://${cleanBaseUrl}`;
}
let url = `${cleanBaseUrl}/catalog/${type}/${encodeURIComponent(catalogId)}/search=${encodeURIComponent(query)}.json`;
if (queryParams) {
url += `?${queryParams}`;
}
return url;
}
function dedupeAndStampResults(results: StreamingContent[], catalogType: string): StreamingContent[] {
const bestById = new Map<string, StreamingContent>();
for (const item of results) {
const existing = bestById.get(item.id);
if (!existing || (!existing.type.includes('.') && item.type.includes('.'))) {
bestById.set(item.id, item);
}
}
return Array.from(bestById.values()).map(item =>
catalogType && item.type !== catalogType ? { ...item, type: catalogType } : item
);
}
function buildSectionName(
addonName: string,
catalogName: string | undefined,
catalogType: string,
genericCatalogNames: Set<string>,
catalogTypeLabels: Record<string, string>
): string {
const typeLabel = catalogTypeLabels[catalogType] ||
catalogType.replace(/[._]/g, ' ').replace(/\b\w/g, char => char.toUpperCase());
const catalogLabel = (!catalogName || genericCatalogNames.has(catalogName) || catalogName === addonName)
? typeLabel
: catalogName;
return `${addonName} - ${catalogLabel}`;
}

View file

@ -0,0 +1,154 @@
export const DATA_SOURCE_KEY = 'discover_data_source';
export enum DataSource {
STREMIO_ADDONS = 'stremio_addons',
TMDB = 'tmdb',
}
export interface StreamingCatalogExtra {
name: string;
isRequired?: boolean;
options?: string[];
optionsLimit?: number;
}
export interface StreamingCatalog {
type: string;
id: string;
name: string;
extraSupported?: string[];
extra?: StreamingCatalogExtra[];
showInHome?: boolean;
}
export interface StreamingAddon {
id: string;
name: string;
version: string;
description: string;
types: string[];
catalogs: StreamingCatalog[];
resources: {
name: string;
types: string[];
idPrefixes?: string[];
}[];
url?: string;
originalUrl?: string;
transportUrl?: string;
transportName?: string;
}
export interface StreamingContent {
id: string;
type: string;
name: string;
tmdbId?: number;
poster: string;
posterShape?: 'poster' | 'square' | 'landscape';
banner?: string;
logo?: string;
imdbRating?: string;
year?: number;
genres?: string[];
description?: string;
runtime?: string;
released?: string;
trailerStreams?: any[];
videos?: any[];
inLibrary?: boolean;
directors?: string[];
creators?: string[];
certification?: string;
country?: string;
writer?: string[];
links?: Array<{
name: string;
category: string;
url: string;
}>;
behaviorHints?: {
defaultVideoId?: string;
hasScheduledVideos?: boolean;
[key: string]: any;
};
imdb_id?: string;
mal_id?: number;
external_ids?: {
mal_id?: number;
imdb_id?: string;
tmdb_id?: number;
tvdb_id?: number;
};
slug?: string;
releaseInfo?: string;
traktSource?: 'watchlist' | 'continue-watching' | 'watched';
addonCast?: Array<{
id: number;
name: string;
character: string;
profile_path: string | null;
}>;
networks?: Array<{
id: number | string;
name: string;
logo?: string;
}>;
tvDetails?: {
status?: string;
firstAirDate?: string;
lastAirDate?: string;
numberOfSeasons?: number;
numberOfEpisodes?: number;
episodeRunTime?: number[];
type?: string;
originCountry?: string[];
originalLanguage?: string;
createdBy?: Array<{
id: number;
name: string;
profile_path?: string;
}>;
};
movieDetails?: {
status?: string;
releaseDate?: string;
runtime?: number;
budget?: number;
revenue?: number;
originalLanguage?: string;
originCountry?: string[];
tagline?: string;
};
collection?: {
id: number;
name: string;
poster_path?: string;
backdrop_path?: string;
};
addedToLibraryAt?: number;
addonId?: string;
}
export interface AddonSearchResults {
addonId: string;
addonName: string;
sectionName: string;
catalogIndex: number;
results: StreamingContent[];
}
export interface GroupedSearchResults {
byAddon: AddonSearchResults[];
allResults: StreamingContent[];
}
export interface CatalogContent {
addon: string;
type: string;
id: string;
name: string;
originalName?: string;
genre?: string;
items: StreamingContent[];
}

File diff suppressed because it is too large Load diff