From 53d22f6dcffd5f64934b411dce4244a1d06b87d8 Mon Sep 17 00:00:00 2001 From: valfar Date: Tue, 7 Jul 2026 23:24:23 +0200 Subject: [PATCH 1/2] ai-catalog: add ranked list mode --- addon/index.js | 165 +++++++++++++++++- addon/lib/getCatalog.ts | 69 ++++++++ addon/lib/getManifest.ts | 29 +++ addon/utils/ai-catalog-config-builder.ts | 41 +++++ addon/utils/ai-catalog-generation.ts | 66 ++++++- addon/utils/ai-catalog-schema.ts | 27 ++- addon/utils/ai-catalog-service.ts | 4 +- configure/src/components/AICatalogDialog.tsx | 12 +- .../components/sections/CatalogsSettings.tsx | 2 + configure/src/contexts/config.ts | 15 +- 10 files changed, 416 insertions(+), 14 deletions(-) diff --git a/addon/index.js b/addon/index.js index fb519d36..2ad77530 100644 --- a/addon/index.js +++ b/addon/index.js @@ -1919,6 +1919,111 @@ addon.get("/api/tvdb/discover/search/:entity", async (req, res) => { // AI-powered catalog creation const aiCatalogRateLimit = new Map(); + +function normalizeAICatalogTitle(value) { + return String(value || '') + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]+/g, ' ') + .trim(); +} + +function getAICatalogYear(value) { + const match = String(value || '').match(/\d{4}/); + return match ? Number(match[0]) : null; +} + +function scoreTmdbTitleMatch(result, requestedTitle, requestedYear, catalogType) { + const resultTitle = catalogType === 'movie' + ? (result.title || result.original_title || '') + : (result.name || result.original_name || ''); + const resultDate = catalogType === 'movie' ? result.release_date : result.first_air_date; + const resultYear = getAICatalogYear(resultDate); + const normalizedRequested = normalizeAICatalogTitle(requestedTitle); + const normalizedResult = normalizeAICatalogTitle(resultTitle); + let score = 0; + + if (normalizedRequested && normalizedRequested === normalizedResult) score += 100; + else if (normalizedRequested && normalizedResult.includes(normalizedRequested)) score += 45; + else if (normalizedRequested && normalizedRequested.includes(normalizedResult)) score += 25; + + if (requestedYear && resultYear) { + const delta = Math.abs(requestedYear - resultYear); + if (delta === 0) score += 40; + else if (delta === 1) score += 15; + else if (delta > 3) score -= 30; + } + + score += Math.min(Number(result.vote_count || 0), 5000) / 500; + score += Math.min(Number(result.popularity || 0), 500) / 100; + return score; +} + +async function resolveAIRankedItem(item, catalogType, config) { + if (item.stremioId) return item; + if (item.imdbId && /^tt\d+$/i.test(item.imdbId)) { + return { ...item, stremioId: item.imdbId }; + } + if (item.tmdbId) { + return { ...item, stremioId: `tmdb:${item.tmdbId}` }; + } + + const moviedb = require('./lib/getTmdb'); + const requestedYear = getAICatalogYear(item.year); + const searchParams = { + query: item.title, + language: config.language || DEFAULT_LANGUAGE, + include_adult: config.includeAdult || false, + page: 1, + }; + const response = catalogType === 'movie' + ? await moviedb.searchMovie(searchParams, config) + : await moviedb.searchTv(searchParams, config); + const results = Array.isArray(response?.results) ? response.results : []; + if (!results.length) return null; + + const best = results + .map(result => ({ + result, + score: scoreTmdbTitleMatch(result, item.title, requestedYear, catalogType), + })) + .sort((a, b) => b.score - a.score)[0]?.result; + + if (!best?.id) return null; + return { + ...item, + tmdbId: best.id, + stremioId: `tmdb:${best.id}`, + }; +} + +async function resolveAIRankedCatalog(catalog, config) { + const catalogType = catalog.catalogType === 'movie' ? 'movie' : 'series'; + const resolvedItems = []; + const seen = new Set(); + + for (const item of catalog.items || []) { + try { + const resolved = await resolveAIRankedItem(item, catalogType, config); + if (!resolved?.stremioId || seen.has(resolved.stremioId)) continue; + seen.add(resolved.stremioId); + resolvedItems.push(resolved); + } catch (error) { + aiCatalogLogger.warn(`Failed to resolve ranked item "${item.title}": ${error.message}`); + } + } + + return { + ...catalog, + source: 'ai-list', + catalogType, + mediaType: catalogType === 'movie' ? 'movie' : 'tv', + params: {}, + items: resolvedItems, + }; +} + addon.post("/api/ai/create-catalog", async (req, res) => { try { const { userUUID, password, query, provider, generationMode, geminiKey: clientGeminiKey, openrouterKey: clientOpenrouterKey } = req.body; @@ -1929,9 +2034,9 @@ addon.post("/api/ai/create-catalog", async (req, res) => { if (!query || typeof query !== 'string' || !query.trim()) { return res.status(400).json({ error: 'Query is required' }); } - const allowedGenerationModes = new Set(['auto', 'tmdb', 'anilist', 'mal', 'tvdb', 'simkl']); + const allowedGenerationModes = new Set(['auto', 'tmdb', 'anilist', 'mal', 'tvdb', 'simkl', 'ranked']); if (!allowedGenerationModes.has(generationMode)) { - return res.status(400).json({ error: 'generationMode must be one of: auto, tmdb, anilist, mal, tvdb' }); + return res.status(400).json({ error: 'generationMode must be one of: auto, tmdb, anilist, mal, tvdb, simkl, ranked' }); } // Rate limit: 5 requests per minute per user @@ -1957,7 +2062,7 @@ addon.post("/api/ai/create-catalog", async (req, res) => { return res.status(400).json({ error: 'No AI API key configured. Add an OpenRouter or Gemini key in your settings.' }); } - const { buildCatalogCreationPrompt, parseCatalogAIResponse, normalizeCatalog, validateCatalogParams, resolveEntities, buildCatalogConfigs } = require('./utils/ai-catalog-service'); + const { buildCatalogCreationPrompt, parseCatalogAIResponse, normalizeCatalog, validateCatalogParams, resolveEntities, buildCatalogConfigs, buildRankedCatalogConfigs } = require('./utils/ai-catalog-service'); const hasTmdb = !!(config.apiKeys?.tmdb || process.env.TMDB_API_KEY || process.env.TMDB_API || process.env.BUILT_IN_TMDB_API_KEY); const hasTvdb = !!(config.apiKeys?.tvdb || process.env.TVDB_API_KEY || process.env.BUILT_IN_TVDB_API_KEY); const hasSimkl = !!process.env.SIMKL_CLIENT_ID; @@ -1967,6 +2072,9 @@ addon.post("/api/ai/create-catalog", async (req, res) => { if (generationMode === 'tvdb' && !hasTvdb) { return res.status(400).json({ error: 'TVDB catalog generation requires a TVDB API key.' }); } + if (generationMode === 'ranked' && !hasTmdb) { + return res.status(400).json({ error: 'Ranked AI catalog generation requires a TMDB API key for title resolution.' }); + } const { systemPrompt, userPrompt } = buildCatalogCreationPrompt(query.trim(), { mode: generationMode, keys: { tmdb: hasTmdb, tvdb: hasTvdb, simkl: hasSimkl }, @@ -2009,6 +2117,33 @@ addon.post("/api/ai/create-catalog", async (req, res) => { if (!parsed || !parsed.catalogs.length) { return res.status(422).json({ error: 'AI returned an invalid response. Try again or rephrase your request.' }); } + if (generationMode === 'ranked') { + const rankedCatalogs = parsed.catalogs.filter(catalog => catalog.source === 'ai-list'); + if (rankedCatalogs.length === 0) { + return res.status(422).json({ error: 'AI did not return a ranked list catalog. Try again or switch to Auto.' }); + } + + const resolvedRankedCatalogs = []; + const rankedWarnings = [...(parsed.warnings || [])]; + for (const catalog of rankedCatalogs) { + const resolvedCatalog = await resolveAIRankedCatalog(catalog, config); + if (resolvedCatalog.items.length === 0) { + rankedWarnings.push(`Skipped "${catalog.name || 'unnamed'}": no titles could be resolved`); + continue; + } + resolvedRankedCatalogs.push(resolvedCatalog); + if ((catalog.items || []).length !== resolvedCatalog.items.length) { + rankedWarnings.push(`Some titles in "${catalog.name}" could not be resolved and were omitted`); + } + } + + if (resolvedRankedCatalogs.length === 0) { + return res.status(422).json({ error: 'No ranked titles could be resolved. Try adding years or using more specific titles.', warnings: rankedWarnings }); + } + + const catalogConfigs = buildRankedCatalogConfigs(resolvedRankedCatalogs, query.trim()); + return res.json({ catalogs: catalogConfigs, warnings: rankedWarnings.length ? rankedWarnings.slice(0, 3) : undefined }); + } if (generationMode !== 'auto') { const filteredCatalogs = parsed.catalogs.filter(catalog => catalog.source === generationMode); if (filteredCatalogs.length === 0) { @@ -3842,6 +3977,24 @@ addon.get("/stremio/:userUUID/catalog/:type/:id{/:extra}.json", async function ( extraArgs.discoverSig = discoverSignature; } } + // AI ranked lists use stored item IDs; hash them so edits invalidate catalog cache. + else if (cleanId.startsWith('ai.list.')) { + const aiListItems = catalogConfig?.metadata?.aiList?.items || []; + if (Array.isArray(aiListItems)) { + const aiListSignature = crypto + .createHash('md5') + .update(stableStringify(aiListItems.map(item => ({ + stremioId: item?.stremioId, + tmdbId: item?.tmdbId, + imdbId: item?.imdbId, + title: item?.title, + year: item?.year, + })))) + .digest('hex') + .substring(0, 8); + extraArgs.aiListSig = aiListSignature; + } + } // AniList uses: sort, sortDirection else if (cleanId.startsWith('anilist.')) { if (catalogConfig?.sort) extraArgs.sort = catalogConfig.sort; @@ -3911,12 +4064,12 @@ addon.get("/stremio/:userUUID/catalog/:type/:id{/:extra}.json", async function ( catalogPageSize = parseInt(process.env.MAL_PAGE_SIZE || '25'); } else if (cleanId === 'anilist.trending' || cleanId.startsWith('anilist.discover')) { catalogPageSize = 50; - } else if (cleanId.startsWith('simkl.watchlist.') || cleanId.startsWith('simkl.dvd.') || cleanId.startsWith('simkl.trending.') || cleanId.startsWith('simkl.recipe.') || cleanId.startsWith('stremthru.') || cleanId.startsWith('mdblist.') || cleanId.startsWith('custom.') || cleanId.startsWith('trakt.') || cleanId.startsWith('anilist.') || cleanId.startsWith('letterboxd.') || (cleanId.startsWith('tvdb.') && !cleanId.startsWith('tvdb.collection.'))) { + } else if (cleanId.startsWith('simkl.watchlist.') || cleanId.startsWith('simkl.dvd.') || cleanId.startsWith('simkl.trending.') || cleanId.startsWith('simkl.recipe.') || cleanId.startsWith('stremthru.') || cleanId.startsWith('mdblist.') || cleanId.startsWith('custom.') || cleanId.startsWith('ai.list.') || cleanId.startsWith('trakt.') || cleanId.startsWith('anilist.') || cleanId.startsWith('letterboxd.') || (cleanId.startsWith('tvdb.') && !cleanId.startsWith('tvdb.collection.'))) { catalogPageSize = parseInt(process.env.CATALOG_LIST_ITEMS_SIZE || '20'); } else { catalogPageSize = 20; } - const isOffsetBased = cleanId.startsWith('stremthru.') || cleanId.startsWith('custom.'); + const isOffsetBased = cleanId.startsWith('stremthru.') || cleanId.startsWith('custom.') || cleanId.startsWith('ai.list.'); const catalogPage = extraArgs.skip ? (isOffsetBased ? Math.floor(parseInt(extraArgs.skip) / catalogPageSize) + 1 @@ -4042,7 +4195,7 @@ addon.get("/stremio/:userUUID/catalog/:type/:id{/:extra}.json", async function ( const searchResult = await getSearch(cleanId, searchType, language, searchExtraArgs, config); return { metas: searchResult.metas || [] }; }, searchEngine, cacheOptions); - } else if (cleanId.startsWith('custom.') || cleanId.startsWith('stremthru.')) { + } else if (cleanId.startsWith('custom.') || cleanId.startsWith('stremthru.') || cleanId.startsWith('ai.list.')) { const { genre: genreName } = extraArgs; const skipValue = extraArgs.skip !== undefined ? parseInt(extraArgs.skip) : 0; const result = await getCatalog(actualType, language, catalogPage, cleanId, genreName, config, userUUID, false, skipValue); diff --git a/addon/lib/getCatalog.ts b/addon/lib/getCatalog.ts index 7915cc3a..be5aefeb 100644 --- a/addon/lib/getCatalog.ts +++ b/addon/lib/getCatalog.ts @@ -67,6 +67,11 @@ async function getCatalog(type: string, language: string, page: number, id: stri const stremthruResults = await getExternalAddonCatalog(type, id, genre, page, language, config, userUUID, includeVideos, skip); return { metas: stremthruResults }; } + else if (id.startsWith('ai.list.')) { + logger.debug(`Routing to AI ranked list catalog handler for id: ${id}`); + const aiListResults = await getAIRankedListCatalog(type, id, genre, page, language, config, userUUID, includeVideos, skip); + return { metas: aiListResults }; + } else if (id.startsWith('custom.')) { logger.debug(`Routing to External Addon catalog handler for id: ${id}`); const customResults = await getExternalAddonCatalog(type, id, genre, page, language, config, userUUID, includeVideos, skip); @@ -148,6 +153,70 @@ async function getCatalog(type: string, language: string, page: number, id: stri } } +async function getAIRankedListCatalog( + type: string, + catalogId: string, + genreName: string | null, + page: number, + language: string, + config: any, + userUUID: string, + includeVideos: boolean = false, + skip?: number +): Promise { + try { + const catalogConfig = config.catalogs?.find((c: any) => c.id === catalogId && (c.type === type || c.displayType === type)) + || config.catalogs?.find((c: any) => c.id === catalogId); + const aiListItems = Array.isArray(catalogConfig?.metadata?.aiList?.items) + ? catalogConfig.metadata.aiList.items + : []; + + if (aiListItems.length === 0) { + logger.warn(`[AI List] No stored ranked items found for ${catalogId}`); + return []; + } + + const pageSize = parseInt(process.env.CATALOG_LIST_ITEMS_SIZE as string) || 20; + const offset = typeof skip === 'number' && Number.isFinite(skip) + ? Math.max(0, skip) + : Math.max(0, (Math.max(1, page) - 1) * pageSize); + const pageItems = aiListItems.slice(offset, offset + pageSize); + + const metas = await mapWithLimit(pageItems, async (item: any) => { + const stremioId = item?.stremioId + || item?.imdbId + || (item?.tmdbId ? `tmdb:${item.tmdbId}` : null); + if (!stremioId) return null; + + const result = await cacheWrapMetaSmart( + userUUID, + stremioId, + async () => getMeta(type, language, stremioId, config, userUUID, includeVideos), + undefined, + { + enableErrorCaching: true, + maxRetries: 2, + config, + }, + type as any, + includeVideos + ); + return result?.meta || null; + }); + + let validMetas = metas.filter(Boolean); + if (genreName && genreName !== 'None') { + validMetas = filterMetasByGenre(validMetas, genreName); + } + + logger.success(`[AI List] Processed ${validMetas.length}/${pageItems.length} items for ${catalogId}`); + return validMetas; + } catch (error: any) { + logger.error(`[AI List] Error processing catalog ${catalogId}: ${error.message}`); + return []; + } +} + /** * Get MAL discover catalog items. diff --git a/addon/lib/getManifest.ts b/addon/lib/getManifest.ts index a9d0b06a..9479334a 100644 --- a/addon/lib/getManifest.ts +++ b/addon/lib/getManifest.ts @@ -680,6 +680,28 @@ function createMergedCatalog( } } +function createAIRankedListCatalog(userCatalog: any, showPrefix: boolean = false, prefixName: string = "AIOMetadata"): any { + try { + logger.debug(`Creating AI ranked list catalog: ${userCatalog.id} (${userCatalog.type})`); + const catalogType = userCatalog.displayType || userCatalog.type; + const catalogName = userCatalog.name || 'AI Ranked List'; + + return { + id: userCatalog.id, + type: catalogType, + name: `${showPrefix ? `${prefixName} - ` : ""}${catalogName}`, + pageSize: parseInt(process.env.CATALOG_LIST_ITEMS_SIZE as string) || 20, + extra: [ + { name: "skip" }, + ], + showInHome: userCatalog.showInHome + }; + } catch (error: any) { + logger.error(`Error creating AI ranked list catalog ${userCatalog.id}:`, error.message); + return null; + } +} + async function createSimklCatalog(userCatalog: any, showPrefix: boolean = false, prefixName: string = "AIOMetadata"): Promise { try { logger.debug(`Creating Simkl catalog: ${userCatalog.id} (${userCatalog.type})`); @@ -991,6 +1013,9 @@ async function getManifest(config: any, opts: { tag?: string } = {}): Promise { + const sanitizedName = catalog.name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 40) || 'ai_ranked'; + const uniqueSuffix = (Date.now() + i).toString(36); + const catalogType = catalog.catalogType === 'movie' ? 'movie' : 'series'; + const catalogId = `ai.list.${catalogType}.${sanitizedName}.${uniqueSuffix}`; + const items = (catalog.items || []) + .filter(item => item?.stremioId) + .map(item => ({ + title: item.title, + ...(item.year !== undefined ? { year: item.year } : {}), + stremioId: item.stremioId as string, + ...(item.tmdbId !== undefined ? { tmdbId: item.tmdbId } : {}), + ...(item.imdbId ? { imdbId: item.imdbId } : {}), + ...(item.reason ? { reason: item.reason } : {}), + })); + + return { + id: catalogId, + type: catalogType, + name: catalog.name, + enabled: true, + showInHome: true, + source: 'ai-list', + metadata: { + description: `AI Ranked List (${catalogType})`, + aiList: { + version: 1, + source: 'tmdb', + originalQuery, + items, + }, + }, + } as CatalogConfig; + }); +} diff --git a/addon/utils/ai-catalog-generation.ts b/addon/utils/ai-catalog-generation.ts index bb0f4c22..17b9f109 100644 --- a/addon/utils/ai-catalog-generation.ts +++ b/addon/utils/ai-catalog-generation.ts @@ -194,13 +194,50 @@ Dynamic (put in "resolve", backend resolves names to IDs): `; -type PromptSource = Exclude | 'simkl'; +type PromptSource = Exclude | 'simkl'; interface BuildCatalogCreationPromptOptions { mode: AICatalogGenerationMode; keys: AvailableKeys; } +const RANKED_LIST_PROMPT = `You create AI-ranked Stremio catalog lists from natural language. + +=== HARD RULES === +- Return valid JSON only: { "catalogs": [...] }. +- Return exactly 1 catalog unless the user explicitly asks for multiple. Max 5. +- Each catalog source MUST be "ai-list". +- catalogType MUST be "movie" or "series". +- mediaType MUST be "movie" for movies or "tv" for series. +- items MUST be an ordered array of 10 to 30 titles. +- Preserve your ranking order. The app will resolve titles and keep this order. +- Include year when you know it. Use first-air year for series. +- Do not include explanations outside JSON. +- Current date: {{CURRENT_DATE}}. + +=== OUTPUT CONTRACT === +{ + "catalogs": [ + { + "source": "ai-list", + "catalogType": "series", + "name": "Best Sci-Fi Shows", + "mediaType": "tv", + "params": {}, + "items": [ + { "title": "The Expanse", "year": 2015, "reason": "Hard sci-fi worldbuilding" }, + { "title": "Battlestar Galactica", "year": 2004, "reason": "Influential serialized space opera" } + ] + } + ] +} + +Rules: +- For subjective prompts like best, greatest, essential, underrated, cult, or all-time, rank titles directly instead of creating filters. +- Avoid popularity-only lists. Balance influence, writing, originality, cultural impact, and rewatchability. +- If the user asks for sci-fi, avoid pure fantasy unless the title is strongly accepted as sci-fi/fantasy crossover. +- Return ONLY valid JSON. No markdown, explanations, or code fences.`; + const SOURCE_SECTIONS: Record = { tmdb: SOURCE_TMDB, anilist: SOURCE_ANILIST, @@ -293,6 +330,9 @@ function getPromptSources(mode: AICatalogGenerationMode, keys: AvailableKeys): P ...(keys.tvdb ? ['tvdb' as const] : []), ]; } + if (mode === 'ranked') { + return []; + } if (mode === 'tmdb' && !keys.tmdb) { throw new Error('TMDB catalog generation requires a TMDB API key'); @@ -314,6 +354,13 @@ function getDecisionPolicy(mode: AICatalogGenerationMode, sources: PromptSource[ } export function buildCatalogCreationPrompt(query: string, options: BuildCatalogCreationPromptOptions): { systemPrompt: string; userPrompt: string } { + if (options.mode === 'ranked') { + return { + systemPrompt: RANKED_LIST_PROMPT.replace('{{CURRENT_DATE}}', formatPromptDate()), + userPrompt: query, + }; + } + const sources = getPromptSources(options.mode, options.keys); const sections = [PROMPT_HEADER]; sections.push(getDecisionPolicy(options.mode, sources)); @@ -360,6 +407,22 @@ function coerceAICatalogOutput(rawCatalog: any): AICatalogOutput | null { ? rawCatalog.mediaType.trim().toLowerCase() : catalogType; const resolve = coerceResolve(rawCatalog.resolve); + const items: AICatalogOutput['items'] | undefined = Array.isArray(rawCatalog.items) + ? rawCatalog.items + .map((item: any): NonNullable[number] | null => { + if (!isPlainObject(item)) return null; + const title = typeof item.title === 'string' ? item.title.trim() : ''; + if (!title) return null; + const out: NonNullable[number] = { title }; + if (typeof item.year === 'string' || typeof item.year === 'number') out.year = item.year; + if (typeof item.imdbId === 'string' && item.imdbId.trim()) out.imdbId = item.imdbId.trim(); + if (typeof item.tmdbId === 'string' || typeof item.tmdbId === 'number') out.tmdbId = item.tmdbId; + if (typeof item.stremioId === 'string' && item.stremioId.trim()) out.stremioId = item.stremioId.trim(); + if (typeof item.reason === 'string' && item.reason.trim()) out.reason = item.reason.trim(); + return out; + }) + .filter((item): item is NonNullable[number] => item !== null) + : undefined; return { source, @@ -368,6 +431,7 @@ function coerceAICatalogOutput(rawCatalog: any): AICatalogOutput | null { mediaType, params: { ...(rawCatalog.params || {}) }, ...(resolve ? { resolve } : {}), + ...(items?.length ? { items } : {}), }; } diff --git a/addon/utils/ai-catalog-schema.ts b/addon/utils/ai-catalog-schema.ts index 08c19938..e1c65077 100644 --- a/addon/utils/ai-catalog-schema.ts +++ b/addon/utils/ai-catalog-schema.ts @@ -1,5 +1,5 @@ -const VALID_SOURCES = ['tmdb', 'tvdb', 'anilist', 'mal', 'simkl'] as const; -export const AI_CATALOG_GENERATION_MODES = ['auto', 'tmdb', 'anilist', 'mal', 'tvdb', 'simkl'] as const; +const VALID_SOURCES = ['tmdb', 'tvdb', 'anilist', 'mal', 'simkl', 'ai-list'] as const; +export const AI_CATALOG_GENERATION_MODES = ['auto', 'tmdb', 'anilist', 'mal', 'tvdb', 'simkl', 'ranked'] as const; type AICatalogGenerationMode = typeof AI_CATALOG_GENERATION_MODES[number]; @@ -10,6 +10,14 @@ interface AICatalogOutput { mediaType: string; params: Record; resolve?: Record; + items?: Array<{ + title: string; + year?: number | string; + imdbId?: string; + tmdbId?: number | string; + stremioId?: string; + reason?: string; + }>; } interface ParsedAIResponse { @@ -54,7 +62,20 @@ interface CatalogConfig { cacheTTL?: number; metadata: { description: string; - discover: { + aiList?: { + version: number; + source: string; + originalQuery: string; + items: Array<{ + title: string; + year?: number | string; + stremioId: string; + tmdbId?: number | string; + imdbId?: string; + reason?: string; + }>; + }; + discover?: { version: number; source: string; mediaType: string; diff --git a/addon/utils/ai-catalog-service.ts b/addon/utils/ai-catalog-service.ts index a664fdc6..79985a9c 100644 --- a/addon/utils/ai-catalog-service.ts +++ b/addon/utils/ai-catalog-service.ts @@ -1,4 +1,4 @@ -import { buildCatalogConfigs } from './ai-catalog-config-builder'; +import { buildCatalogConfigs, buildRankedCatalogConfigs } from './ai-catalog-config-builder'; import { resolveEntities } from './ai-catalog-entity-resolver'; import { buildCatalogCreationPrompt, parseCatalogAIResponse } from './ai-catalog-generation'; import { normalizeCatalog, normalizeCatalogMediaTypes, stripUnknownParams, validateCatalogParams } from './ai-catalog-sanitizer'; @@ -12,6 +12,7 @@ export { validateCatalogParams, resolveEntities, buildCatalogConfigs, + buildRankedCatalogConfigs, }; module.exports = { @@ -23,4 +24,5 @@ module.exports = { validateCatalogParams, resolveEntities, buildCatalogConfigs, + buildRankedCatalogConfigs, }; diff --git a/configure/src/components/AICatalogDialog.tsx b/configure/src/components/AICatalogDialog.tsx index a460b07c..9f74da2b 100644 --- a/configure/src/components/AICatalogDialog.tsx +++ b/configure/src/components/AICatalogDialog.tsx @@ -15,10 +15,11 @@ interface AICatalogDialogProps { onCatalogsCreated?: (catalogs: CatalogConfig[]) => void; } -type AICatalogGenerationMode = 'auto' | 'tmdb' | 'anilist' | 'mal' | 'tvdb' | 'simkl'; +type AICatalogGenerationMode = 'auto' | 'ranked' | 'tmdb' | 'anilist' | 'mal' | 'tvdb' | 'simkl'; const GENERATION_MODE_OPTIONS: Array<{ value: AICatalogGenerationMode; label: string }> = [ { value: 'auto', label: 'Auto' }, + { value: 'ranked', label: 'Ranked List' }, { value: 'tmdb', label: 'TMDB' }, { value: 'anilist', label: 'AniList' }, { value: 'mal', label: 'MAL' }, @@ -37,6 +38,13 @@ const EXAMPLE_PROMPTS: Record = { "Top rated anime movies on MAL", "A24 movies sorted by popularity", ], + ranked: [ + "Best sci-fi shows of all time", + "Greatest cyberpunk movies ever made", + "Most influential space opera series", + "Best slow-burn mystery shows", + "Top animated movies for adults", + ], tmdb: [ "Top Pixar movies sorted by rating", "Trending horror movies from 2020 onwards", @@ -90,7 +98,7 @@ export function AICatalogDialog({ isOpen, onClose, embedded, onCatalogsCreated } useEffect(() => { if (isOpen) { - setExampleIndex(Math.floor(Math.random() * EXAMPLE_PROMPTS.tmdb.length)); + setExampleIndex(Math.floor(Math.random() * EXAMPLE_PROMPTS.auto.length)); setState('idle'); setError(null); setCreatedCatalogs([]); diff --git a/configure/src/components/sections/CatalogsSettings.tsx b/configure/src/components/sections/CatalogsSettings.tsx index e7592613..576a2b1c 100644 --- a/configure/src/components/sections/CatalogsSettings.tsx +++ b/configure/src/components/sections/CatalogsSettings.tsx @@ -344,10 +344,12 @@ const sourceBadgeStyles = { anilist: "bg-cyan-800/80 text-cyan-200 border-cyan-600/50 hover:bg-cyan-800", flixpatrol: "bg-emerald-800/80 text-emerald-200 border-emerald-600/50 hover:bg-emerald-800", merged: "bg-violet-800/80 text-violet-200 border-violet-600/50 hover:bg-violet-800", + 'ai-list': "bg-slate-800/80 text-slate-200 border-slate-600/50 hover:bg-slate-800", }; const sourceBadgeLabels: Record = { flixpatrol: 'TOP 10', + 'ai-list': 'AI LIST', }; diff --git a/configure/src/contexts/config.ts b/configure/src/contexts/config.ts index 93a4cd3a..789e6fd7 100644 --- a/configure/src/contexts/config.ts +++ b/configure/src/contexts/config.ts @@ -15,7 +15,7 @@ export interface CatalogConfig { type: 'movie' | 'series' | 'anime' | 'all'; enabled: boolean; tags?: string[]; - source: 'tmdb' | 'tvdb' | 'mal' | 'tvmaze' | 'mdblist' | 'trakt' | 'streaming' | 'stremthru' | 'custom' | 'anilist' | 'letterboxd' | 'simkl' | 'flixpatrol' | 'publicmetadb' | 'merged'; // Keep source as the display label + source: 'tmdb' | 'tvdb' | 'mal' | 'tvmaze' | 'mdblist' | 'trakt' | 'streaming' | 'stremthru' | 'custom' | 'anilist' | 'letterboxd' | 'simkl' | 'flixpatrol' | 'publicmetadb' | 'merged' | 'ai-list'; // Keep source as the display label sourceUrl?: string; // Store the actual URL for StremThru and custom catalogs showInHome: boolean; genres?: string[]; // Optional genres array for catalogs that support genre filtering @@ -80,6 +80,19 @@ export interface CatalogConfig { formState?: Record; }; discoverParams?: Record; + aiList?: { + version?: number; + source?: string; + originalQuery?: string; + items?: Array<{ + title: string; + year?: string | number; + stremioId: string; + tmdbId?: string | number; + imdbId?: string; + reason?: string; + }>; + }; // Simkl-specific metadata interval?: 'today' | 'week' | 'month'; pageSize?: number; // Results per page for Simkl trending and watchlist catalogs (default: 50) From 0d02fe1bc03f3c211a68065bd146f817c6fea532 Mon Sep 17 00:00:00 2001 From: valfar Date: Wed, 8 Jul 2026 20:40:42 +0200 Subject: [PATCH 2/2] ai-catalog: preserve unresolved ranked items --- addon/index.js | 46 +++++++++++++++++++----- addon/lib/getCatalog.ts | 12 +++++-- addon/utils/ai-catalog-config-builder.ts | 8 +++-- addon/utils/ai-catalog-schema.ts | 8 ++++- configure/src/contexts/config.ts | 5 ++- 5 files changed, 64 insertions(+), 15 deletions(-) diff --git a/addon/index.js b/addon/index.js index 2ad77530..62897c60 100644 --- a/addon/index.js +++ b/addon/index.js @@ -2000,17 +2000,45 @@ async function resolveAIRankedItem(item, catalogType, config) { async function resolveAIRankedCatalog(catalog, config) { const catalogType = catalog.catalogType === 'movie' ? 'movie' : 'series'; - const resolvedItems = []; + const items = []; const seen = new Set(); - for (const item of catalog.items || []) { + for (const [index, item] of (catalog.items || []).entries()) { + const rank = index + 1; try { const resolved = await resolveAIRankedItem(item, catalogType, config); - if (!resolved?.stremioId || seen.has(resolved.stremioId)) continue; + if (!resolved?.stremioId) { + items.push({ + ...item, + rank, + resolved: false, + unresolvedReason: 'No provider metadata match found', + }); + continue; + } + if (seen.has(resolved.stremioId)) { + items.push({ + ...resolved, + rank, + resolved: false, + unresolvedReason: `Duplicate resolved ID: ${resolved.stremioId}`, + }); + continue; + } seen.add(resolved.stremioId); - resolvedItems.push(resolved); + items.push({ + ...resolved, + rank, + resolved: true, + }); } catch (error) { aiCatalogLogger.warn(`Failed to resolve ranked item "${item.title}": ${error.message}`); + items.push({ + ...item, + rank, + resolved: false, + unresolvedReason: error.message || 'Resolution failed', + }); } } @@ -2020,7 +2048,7 @@ async function resolveAIRankedCatalog(catalog, config) { catalogType, mediaType: catalogType === 'movie' ? 'movie' : 'tv', params: {}, - items: resolvedItems, + items, }; } @@ -2127,13 +2155,15 @@ addon.post("/api/ai/create-catalog", async (req, res) => { const rankedWarnings = [...(parsed.warnings || [])]; for (const catalog of rankedCatalogs) { const resolvedCatalog = await resolveAIRankedCatalog(catalog, config); - if (resolvedCatalog.items.length === 0) { + const resolvedCount = resolvedCatalog.items.filter(item => item.resolved !== false && item.stremioId).length; + const unresolvedCount = resolvedCatalog.items.length - resolvedCount; + if (resolvedCount === 0) { rankedWarnings.push(`Skipped "${catalog.name || 'unnamed'}": no titles could be resolved`); continue; } resolvedRankedCatalogs.push(resolvedCatalog); - if ((catalog.items || []).length !== resolvedCatalog.items.length) { - rankedWarnings.push(`Some titles in "${catalog.name}" could not be resolved and were omitted`); + if (unresolvedCount > 0) { + rankedWarnings.push(`${unresolvedCount} title${unresolvedCount === 1 ? '' : 's'} in "${catalog.name}" could not be resolved and were preserved with unresolved reasons`); } } diff --git a/addon/lib/getCatalog.ts b/addon/lib/getCatalog.ts index be5aefeb..07f12e31 100644 --- a/addon/lib/getCatalog.ts +++ b/addon/lib/getCatalog.ts @@ -176,11 +176,19 @@ async function getAIRankedListCatalog( return []; } + const displayItems = aiListItems.filter((item: any) => + item?.resolved !== false && (item?.stremioId || item?.imdbId || item?.tmdbId) + ); + if (displayItems.length === 0) { + logger.warn(`[AI List] No resolved ranked items found for ${catalogId}`); + return []; + } + const pageSize = parseInt(process.env.CATALOG_LIST_ITEMS_SIZE as string) || 20; const offset = typeof skip === 'number' && Number.isFinite(skip) ? Math.max(0, skip) : Math.max(0, (Math.max(1, page) - 1) * pageSize); - const pageItems = aiListItems.slice(offset, offset + pageSize); + const pageItems = displayItems.slice(offset, offset + pageSize); const metas = await mapWithLimit(pageItems, async (item: any) => { const stremioId = item?.stremioId @@ -209,7 +217,7 @@ async function getAIRankedListCatalog( validMetas = filterMetasByGenre(validMetas, genreName); } - logger.success(`[AI List] Processed ${validMetas.length}/${pageItems.length} items for ${catalogId}`); + logger.success(`[AI List] Processed ${validMetas.length}/${pageItems.length} resolved items for ${catalogId} (${aiListItems.length - displayItems.length} unresolved stored)`); return validMetas; } catch (error: any) { logger.error(`[AI List] Error processing catalog ${catalogId}: ${error.message}`); diff --git a/addon/utils/ai-catalog-config-builder.ts b/addon/utils/ai-catalog-config-builder.ts index 0b7997fb..44957685 100644 --- a/addon/utils/ai-catalog-config-builder.ts +++ b/addon/utils/ai-catalog-config-builder.ts @@ -228,14 +228,16 @@ export function buildRankedCatalogConfigs(catalogs: AICatalogOutput[], originalQ const catalogType = catalog.catalogType === 'movie' ? 'movie' : 'series'; const catalogId = `ai.list.${catalogType}.${sanitizedName}.${uniqueSuffix}`; const items = (catalog.items || []) - .filter(item => item?.stremioId) .map(item => ({ + ...(item.rank !== undefined ? { rank: item.rank } : {}), title: item.title, ...(item.year !== undefined ? { year: item.year } : {}), - stremioId: item.stremioId as string, + ...(item.stremioId ? { stremioId: item.stremioId as string } : {}), ...(item.tmdbId !== undefined ? { tmdbId: item.tmdbId } : {}), ...(item.imdbId ? { imdbId: item.imdbId } : {}), ...(item.reason ? { reason: item.reason } : {}), + ...(item.resolved !== undefined ? { resolved: item.resolved } : {}), + ...(item.unresolvedReason ? { unresolvedReason: item.unresolvedReason } : {}), })); return { @@ -248,7 +250,7 @@ export function buildRankedCatalogConfigs(catalogs: AICatalogOutput[], originalQ metadata: { description: `AI Ranked List (${catalogType})`, aiList: { - version: 1, + version: 2, source: 'tmdb', originalQuery, items, diff --git a/addon/utils/ai-catalog-schema.ts b/addon/utils/ai-catalog-schema.ts index e1c65077..6cd277ca 100644 --- a/addon/utils/ai-catalog-schema.ts +++ b/addon/utils/ai-catalog-schema.ts @@ -11,12 +11,15 @@ interface AICatalogOutput { params: Record; resolve?: Record; items?: Array<{ + rank?: number; title: string; year?: number | string; imdbId?: string; tmdbId?: number | string; stremioId?: string; reason?: string; + resolved?: boolean; + unresolvedReason?: string; }>; } @@ -67,12 +70,15 @@ interface CatalogConfig { source: string; originalQuery: string; items: Array<{ + rank?: number; title: string; year?: number | string; - stremioId: string; + stremioId?: string; tmdbId?: number | string; imdbId?: string; reason?: string; + resolved?: boolean; + unresolvedReason?: string; }>; }; discover?: { diff --git a/configure/src/contexts/config.ts b/configure/src/contexts/config.ts index 789e6fd7..743c5e16 100644 --- a/configure/src/contexts/config.ts +++ b/configure/src/contexts/config.ts @@ -85,12 +85,15 @@ export interface CatalogConfig { source?: string; originalQuery?: string; items?: Array<{ + rank?: number; title: string; year?: string | number; - stremioId: string; + stremioId?: string; tmdbId?: string | number; imdbId?: string; reason?: string; + resolved?: boolean; + unresolvedReason?: string; }>; }; // Simkl-specific metadata