Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 189 additions & 6 deletions addon/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -1919,6 +1919,139 @@ 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 items = [];
const seen = new Set();

for (const [index, item] of (catalog.items || []).entries()) {
const rank = index + 1;
try {
const resolved = await resolveAIRankedItem(item, catalogType, config);
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);
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',
});
}
}

return {
...catalog,
source: 'ai-list',
catalogType,
mediaType: catalogType === 'movie' ? 'movie' : 'tv',
params: {},
items,
};
}

addon.post("/api/ai/create-catalog", async (req, res) => {
try {
const { userUUID, password, query, provider, generationMode, geminiKey: clientGeminiKey, openrouterKey: clientOpenrouterKey } = req.body;
Expand All @@ -1929,9 +2062,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
Expand All @@ -1957,7 +2090,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;
Expand All @@ -1967,6 +2100,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 },
Expand Down Expand Up @@ -2009,6 +2145,35 @@ 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);
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 (unresolvedCount > 0) {
rankedWarnings.push(`${unresolvedCount} title${unresolvedCount === 1 ? '' : 's'} in "${catalog.name}" could not be resolved and were preserved with unresolved reasons`);
}
}

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) {
Expand Down Expand Up @@ -3842,6 +4007,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;
Expand Down Expand Up @@ -3911,12 +4094,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
Expand Down Expand Up @@ -4042,7 +4225,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);
Expand Down
77 changes: 77 additions & 0 deletions addon/lib/getCatalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -148,6 +153,78 @@ 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<any[]> {
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 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 = displayItems.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} 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}`);
return [];
}
}


/**
* Get MAL discover catalog items.
Expand Down
29 changes: 29 additions & 0 deletions addon/lib/getManifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any> {
try {
logger.debug(`Creating Simkl catalog: ${userCatalog.id} (${userCatalog.type})`);
Expand Down Expand Up @@ -991,6 +1013,9 @@ async function getManifest(config: any, opts: { tag?: string } = {}): Promise<an
if (userCatalog.id.startsWith('mal.userlist.') || userCatalog.id === 'mal.suggestions') {
return true;
}
if (userCatalog.id.startsWith('ai.list.')) {
return true;
}
if (userCatalog.id.startsWith('stremthru.')) {
return true;
}
Expand Down Expand Up @@ -1085,6 +1110,10 @@ async function getManifest(config: any, opts: { tag?: string } = {}): Promise<an
logger.debug(`Processing MAL user list catalog: ${userCatalog.id}`);
return createMalUserListCatalog(userCatalog, showPrefix, prefixName);
}
if (userCatalog.id.startsWith('ai.list.')) {
logger.debug(`Processing AI ranked list catalog: ${userCatalog.id}`);
return createAIRankedListCatalog(userCatalog, showPrefix, prefixName);
}
if (userCatalog.id.startsWith('letterboxd.')) {
logger.debug(`Processing Letterboxd catalog: ${userCatalog.id}`);
const result = await createLetterboxdCatalog(userCatalog, showPrefix, prefixName);
Expand Down
Loading
Loading