mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-22 18:47:44 +00:00
ref: catalog service into managable chunks
This commit is contained in:
parent
0d62ad1297
commit
b15b01d1f5
8 changed files with 1922 additions and 1751 deletions
84
src/services/catalog/catalog-utils.ts
Normal file
84
src/services/catalog/catalog-utils.ts
Normal 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');
|
||||
}
|
||||
282
src/services/catalog/content-details.ts
Normal file
282
src/services/catalog/content-details.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
157
src/services/catalog/content-mappers.ts
Normal file
157
src/services/catalog/content-mappers.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
398
src/services/catalog/discovery.ts
Normal file
398
src/services/catalog/discovery.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
329
src/services/catalog/library.ts
Normal file
329
src/services/catalog/library.ts
Normal 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;
|
||||
}
|
||||
401
src/services/catalog/search.ts
Normal file
401
src/services/catalog/search.ts
Normal 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}`;
|
||||
}
|
||||
154
src/services/catalog/types.ts
Normal file
154
src/services/catalog/types.ts
Normal 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
Loading…
Reference in a new issue