mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-25 18:42:53 +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