From c9c1babc2acaa5d92e3230382bf3dcfca2f874d5 Mon Sep 17 00:00:00 2001 From: Viren070 Date: Sun, 13 Jul 2025 18:54:33 +0100 Subject: [PATCH] feat: add ai search addon to marketplace --- .env.sample | 3 + packages/core/src/presets/aiSearch.ts | 276 +++++++++++++++++++++ packages/core/src/presets/presetManager.ts | 4 + packages/core/src/utils/env.ts | 13 + packages/core/src/utils/startup.ts | 13 + 5 files changed, 309 insertions(+) create mode 100644 packages/core/src/presets/aiSearch.ts diff --git a/.env.sample b/.env.sample index c6ae1290..6e1e90fc 100644 --- a/.env.sample +++ b/.env.sample @@ -589,4 +589,7 @@ SUBSOURCE_URL=https://subsource.strem.top OPENSUBTITLES_V3_PLUS_URL=https://opensubtitles.stremio.homes # DEFAULT_OPENSUBTITLES_V3_PLUS_TIMEOUT= +# ---------------- AI Search ---------------------------- +AI_SEARCH_URL=https://stremio.itcon.au/aisearch + # ============================================================================== \ No newline at end of file diff --git a/packages/core/src/presets/aiSearch.ts b/packages/core/src/presets/aiSearch.ts new file mode 100644 index 00000000..d57ecacb --- /dev/null +++ b/packages/core/src/presets/aiSearch.ts @@ -0,0 +1,276 @@ +import { Addon, Option, UserData } from '../db'; +import { Preset, baseOptions } from './preset'; +import { Cache, constants, Env, makeRequest } from '../utils'; +import { z } from 'zod'; + +const configCache = Cache.getInstance('ai-search-config'); + +export class AISearchPreset extends Preset { + private static languages = [ + { label: 'Afrikaans (South Africa)', value: 'af-ZA' }, + { label: 'Arabic (UAE)', value: 'ar-AE' }, + { label: 'Arabic (Saudi Arabia)', value: 'ar-SA' }, + { label: 'Belarusian (Belarus)', value: 'be-BY' }, + { label: 'Bulgarian (Bulgaria)', value: 'bg-BG' }, + { label: 'Bengali (Bangladesh)', value: 'bn-BD' }, + { label: 'Catalan (Spain)', value: 'ca-ES' }, + { label: 'Chamorro (Guam)', value: 'ch-GU' }, + { label: 'Czech (Czech Republic)', value: 'cs-CZ' }, + { label: 'Welsh (United Kingdom)', value: 'cy-GB' }, + { label: 'Danish (Denmark)', value: 'da-DK' }, + { label: 'German (Austria)', value: 'de-AT' }, + { label: 'German (Switzerland)', value: 'de-CH' }, + { label: 'German (Germany)', value: 'de-DE' }, + { label: 'Greek (Greece)', value: 'el-GR' }, + { label: 'English (Australia)', value: 'en-AU' }, + { label: 'English (Canada)', value: 'en-CA' }, + { label: 'English (United Kingdom)', value: 'en-GB' }, + { label: 'English (Ireland)', value: 'en-IE' }, + { label: 'English (New Zealand)', value: 'en-NZ' }, + { label: 'English (United States)', value: 'en-US' }, + { label: 'Esperanto', value: 'eo-EO' }, + { label: 'Spanish (Spain)', value: 'es-ES' }, + { label: 'Spanish (Mexico)', value: 'es-MX' }, + { label: 'Estonian (Estonia)', value: 'et-EE' }, + { label: 'Basque (Spain)', value: 'eu-ES' }, + { label: 'Persian (Iran)', value: 'fa-IR' }, + { label: 'Finnish (Finland)', value: 'fi-FI' }, + { label: 'French (Canada)', value: 'fr-CA' }, + { label: 'French (France)', value: 'fr-FR' }, + { label: 'Irish (Ireland)', value: 'ga-IE' }, + { label: 'Galician (Spain)', value: 'gl-ES' }, + { label: 'Hebrew (Israel)', value: 'he-IL' }, + { label: 'Hindi (India)', value: 'hi-IN' }, + { label: 'Croatian (Croatia)', value: 'hr-HR' }, + { label: 'Hungarian (Hungary)', value: 'hu-HU' }, + { label: 'Indonesian (Indonesia)', value: 'id-ID' }, + { label: 'Icelandic (Iceland)', value: 'is-IS' }, + { label: 'Italian (Italy)', value: 'it-IT' }, + { label: 'Japanese (Japan)', value: 'ja-JP' }, + { label: 'Georgian (Georgia)', value: 'ka-GE' }, + { label: 'Kazakh (Kazakhstan)', value: 'kk-KZ' }, + { label: 'Kannada (India)', value: 'kn-IN' }, + { label: 'Korean (South Korea)', value: 'ko-KR' }, + { label: 'Kyrgyz (Kyrgyzstan)', value: 'ky-KG' }, + { label: 'Lithuanian (Lithuania)', value: 'lt-LT' }, + { label: 'Latvian (Latvia)', value: 'lv-LV' }, + { label: 'Malayalam (India)', value: 'ml-IN' }, + { label: 'Marathi (India)', value: 'mr-IN' }, + { label: 'Malay (Malaysia)', value: 'ms-MY' }, + { label: 'Norwegian Bokmål (Norway)', value: 'nb-NO' }, + { label: 'Dutch (Belgium)', value: 'nl-BE' }, + { label: 'Dutch (Netherlands)', value: 'nl-NL' }, + { label: 'Norwegian (Norway)', value: 'no-NO' }, + { label: 'Punjabi (India)', value: 'pa-IN' }, + { label: 'Polish (Poland)', value: 'pl-PL' }, + { label: 'Portuguese (Brazil)', value: 'pt-BR' }, + { label: 'Portuguese (Portugal)', value: 'pt-PT' }, + { label: 'Romanian (Romania)', value: 'ro-RO' }, + { label: 'Russian (Russia)', value: 'ru-RU' }, + { label: 'Sinhala (Sri Lanka)', value: 'si-LK' }, + { label: 'Slovak (Slovakia)', value: 'sk-SK' }, + { label: 'Slovenian (Slovenia)', value: 'sl-SI' }, + { label: 'Albanian (Albania)', value: 'sq-AL' }, + { label: 'Serbian (Serbia)', value: 'sr-RS' }, + { label: 'Swedish (Sweden)', value: 'sv-SE' }, + { label: 'Tamil (India)', value: 'ta-IN' }, + { label: 'Telugu (India)', value: 'te-IN' }, + { label: 'Thai (Thailand)', value: 'th-TH' }, + { label: 'Tagalog (Philippines)', value: 'tl-PH' }, + { label: 'Turkish (Turkey)', value: 'tr-TR' }, + { label: 'Ukrainian (Ukraine)', value: 'uk-UA' }, + { label: 'Vietnamese (Vietnam)', value: 'vi-VN' }, + { label: 'Chinese (China)', value: 'zh-CN' }, + { label: 'Chinese (Hong Kong)', value: 'zh-HK' }, + { label: 'Chinese (Taiwan)', value: 'zh-TW' }, + { label: 'Zulu (South Africa)', value: 'zu-ZA' }, + ]; + static override get METADATA() { + const supportedResources = [ + constants.CATALOG_RESOURCE, + constants.META_RESOURCE, + ]; + + const options: Option[] = [ + ...baseOptions( + 'AI Search', + supportedResources, + Env.DEFAULT_AI_SEARCH_TIMEOUT + ), + { + id: 'limitedConfig', + name: 'Limited Configuration', + description: + 'To use Trakt-based recommendations, manually configure the AI Search addon and paste its Manifest/Installation URL above. The options below will not enable this feature.', + type: 'alert', + intent: 'warning-basic', + }, + { + id: 'geminiApiKey', + name: 'Gemini API Key', + description: + 'Get a free key from [Google AI Studio](https://makersuite.google.com/app/apikey)', + type: 'password', + required: true, + constraints: { + min: 10, + }, + }, + { + id: 'tmdbApiKey', + name: 'TMDB API Key', + description: + 'Get a free key from [TMDB](https://www.themoviedb.org/settings/api)', + type: 'password', + required: true, + constraints: { + min: 10, + }, + }, + { + id: 'advancedSettingsNote', + type: 'alert', + name: 'Advanced Settings', + description: 'The below settings are for advanced users only.', + }, + { + id: 'AiResponseCaching', + name: 'AI Response Caching', + description: 'Enable AI response caching', + type: 'boolean', + default: true, + }, + { + id: 'rpdbApiKey', + name: 'RPDB API Key (Optional)', + description: 'Optionally provide an RPDB API Key to use for posters', + type: 'password', + required: false, + }, + { + id: 'language', + name: 'Language', + description: 'The language to use for AI Search', + type: 'select', + options: this.languages, + default: 'en-US', + }, + { + id: 'model', + name: 'Gemini Model Name', + description: + 'The Gemini model to use for AI Search. See available models at the [documentation](https://ai.google.dev/gemini-api/docs/models/gemini)', + type: 'string', + default: 'gemini-2.0-flash-lite', + }, + { + id: 'numberOfRecommendations', + name: 'Number of Recommendations', + description: + 'The number of recommendations to return. Higher values may increase the response time.', + type: 'number', + default: 20, + constraints: { + min: 1, + max: 30, + }, + }, + ]; + + return { + ID: 'ai-search', + NAME: 'AI Search', + LOGO: `https://raw.githubusercontent.com/itcon-pty-au/stremio-ai-search/refs/heads/main/public/logo.png`, + URL: Env.AI_SEARCH_URL, + TIMEOUT: Env.DEFAULT_AI_SEARCH_TIMEOUT || Env.DEFAULT_TIMEOUT, + USER_AGENT: Env.DEFAULT_AI_SEARCH_USER_AGENT || Env.DEFAULT_USER_AGENT, + SUPPORTED_SERVICES: [], + DESCRIPTION: + 'AI-powered movie and series recommendations. Powered by Gemini.', + OPTIONS: options, + SUPPORTED_STREAM_TYPES: [], + SUPPORTED_RESOURCES: supportedResources, + }; + } + + static async generateAddons( + userData: UserData, + options: Record + ): Promise { + return [await this.generateAddon(userData, options)]; + } + + private static async generateAddon( + userData: UserData, + options: Record + ): Promise { + return { + name: options.name || this.METADATA.NAME, + manifestUrl: await this.generateManifestUrl(options), + enabled: true, + library: false, + resources: options.resources || this.METADATA.SUPPORTED_RESOURCES, + timeout: options.timeout || this.METADATA.TIMEOUT, + presetType: this.METADATA.ID, + presetInstanceId: '', + headers: { + 'User-Agent': this.METADATA.USER_AGENT, + }, + }; + } + + private static async generateManifestUrl( + options: Record + ): Promise { + let url = (options.url || this.METADATA.URL).replace(/\/$/, ''); + if (url.endsWith('/manifest.json')) { + return url; + } + + let config = { + GeminiApiKey: options.geminiApiKey, + TmdbApiKey: options.tmdbApiKey, + RpdbApiKey: options.rpdbApiKey, + RpdbPosterType: options.rpdbApiKey ? 'poster-default' : undefined, + EnableRpdb: options.rpdbApiKey ? true : false, + EnableAiCache: options.AiResponseCaching, + TmdbLanguage: options.language, + GeminiModel: options.model, + }; + + // request to /aisearch/encrypt with cache and config as body + const cacheKey = `${JSON.stringify(config)}`; + let configId: string | undefined = configCache.get(cacheKey); + if (configId) { + console.log('CACHE HIT'); + return `${url}/aisearch/${configId}/manifest.json`; + } + let response; + try { + response = await makeRequest(`${url}/aisearch/encrypt`, { + timeout: this.METADATA.TIMEOUT, + method: 'POST', + headers: { + 'User-Agent': this.METADATA.USER_AGENT, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(config), + }); + if (!response.ok) { + throw new Error(`${response.status} - ${response.statusText}`); + } + const data = await response.json(); + const schema = z.object({ + encryptedConfig: z.string(), + }); + const result = schema.parse(data); + configId = result.encryptedConfig; + configCache.set(cacheKey, configId, 365 * 24 * 60 * 60); + return `${url}/aisearch/${configId}/manifest.json`; + } catch (error) { + throw new Error( + `Failed to generate manifest URL for AI Search: ${error}` + ); + } + } +} diff --git a/packages/core/src/presets/presetManager.ts b/packages/core/src/presets/presetManager.ts index 19e4f81d..4fec9077 100644 --- a/packages/core/src/presets/presetManager.ts +++ b/packages/core/src/presets/presetManager.ts @@ -40,6 +40,7 @@ import { ArgentinaTVPreset } from './argentinaTv'; import { OpenSubtitlesV3PlusPreset } from './opensubtitles-v3-plus'; import { SubSourcePreset } from './subsource'; import { SubDLPreset } from './subdl'; +import { AISearchPreset } from './aiSearch'; const PRESET_LIST: string[] = [ 'custom', @@ -65,6 +66,7 @@ const PRESET_LIST: string[] = [ 'argentina-tv', 'debridio-tv', 'debridio-watchtower', + 'ai-search', 'tmdb-addon', 'debridio-tmdb', 'debridio-tvdb', @@ -186,6 +188,8 @@ export class PresetManager { return SubSourcePreset; case 'subdl': return SubDLPreset; + case 'ai-search': + return AISearchPreset; default: throw new Error(`Preset ${id} not found`); } diff --git a/packages/core/src/utils/env.ts b/packages/core/src/utils/env.ts index 1fd6f5ac..ef62e855 100644 --- a/packages/core/src/utils/env.ts +++ b/packages/core/src/utils/env.ts @@ -1212,6 +1212,19 @@ export const Env = cleanEnv(process.env, { desc: 'Default OpenSubtitles V3 Plus user agent', }), + AI_SEARCH_URL: url({ + default: 'https://stremio.itcon.au', + desc: 'AI Search URL', + }), + DEFAULT_AI_SEARCH_TIMEOUT: num({ + default: undefined, + desc: 'Default AI Search timeout', + }), + DEFAULT_AI_SEARCH_USER_AGENT: userAgent({ + default: undefined, + desc: 'Default AI Search user agent', + }), + // Rate limiting settings DISABLE_RATE_LIMITS: bool({ default: false, diff --git a/packages/core/src/utils/startup.ts b/packages/core/src/utils/startup.ts index 3de0b142..4f925551 100644 --- a/packages/core/src/utils/startup.ts +++ b/packages/core/src/utils/startup.ts @@ -1155,6 +1155,19 @@ const logStartupInfo = () => { } }); + // AI Search + logKeyValue('AI Search:', Env.AI_SEARCH_URL); + if (Env.DEFAULT_AI_SEARCH_TIMEOUT) { + logKeyValue( + ' Timeout:', + formatMilliseconds(Env.DEFAULT_AI_SEARCH_TIMEOUT), + ' ' + ); + } + if (Env.DEFAULT_AI_SEARCH_USER_AGENT) { + logKeyValue(' User Agent:', Env.DEFAULT_AI_SEARCH_USER_AGENT, ' '); + } + // Additional Features const features: string[] = []; if (Env.TMDB_ACCESS_TOKEN) features.push('TMDB Integration');