diff --git a/compose.ai.yaml b/compose.ai.yaml new file mode 100644 index 0000000000..39125106a9 --- /dev/null +++ b/compose.ai.yaml @@ -0,0 +1,35 @@ +services: + seerr: + environment: + # AI Configuration - Optional + # Uncomment and configure your preferred AI provider below + + # Option 1: OpenAI (requires API key) + # - OPENAI_API_KEY=${OPENAI_API_KEY} + # - OPENAI_BASE_URL=https://api.openai.com/v1 + # - OPENAI_MODEL=gpt-4o-mini + + # Option 2: Ollama (local, free) - Pre-configured for convenience + - OLLAMA_BASE_URL=http://ollama:11434/v1 + - OLLAMA_MODEL=mistral + + # Option 3: OpenRouter (multi-provider) + # - OPENROUTER_API_KEY=${OPENROUTER_API_KEY} + # - OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 + # - OPENROUTER_MODEL=meta-llama/llama-3-8b-instruct:free + depends_on: + - ollama + links: + - ollama + + # Ollama for local LLM support (optional, free) + # Remove this service if you prefer to use a cloud AI provider + ollama: + image: ollama/ollama:latest + ports: + - '127.0.0.1:11434:11434' + volumes: + - ollama_data:/root/.ollama + +volumes: + ollama_data: diff --git a/docs/using-seerr/settings/ai.md b/docs/using-seerr/settings/ai.md new file mode 100644 index 0000000000..d367f7c058 --- /dev/null +++ b/docs/using-seerr/settings/ai.md @@ -0,0 +1,62 @@ +--- +title: AI Settings +description: Configure AI-powered recommendations and natural-language search. +sidebar_position: 8 +--- + +# AI Settings + +AI Settings lets you opt into personalized recommendations and natural-language search, powered by a large language model. Both features are entirely optional and are **disabled** by default — Seerr continues to work exactly as it did before until you turn them on. + +The engine speaks to any OpenAI-compatible endpoint, so you can use a hosted provider such as OpenAI or OpenRouter, or run everything locally with Ollama, LM Studio, or LiteLLM. + +:::warning +When AI features are enabled, Seerr sends the requesting user's request history, watchlist, and library metadata (titles, years, genres, and ratings) to the configured AI provider in order to generate recommendations and interpret searches. **Do not enable these features unless you are comfortable with your provider receiving this data.** A self-hosted provider such as Ollama keeps this traffic entirely on your own network. +::: + +## Enable AI Features + +This is the master switch for all AI functionality. When disabled, none of the settings below take effect and no data is sent to any provider. + +This setting is **disabled** by default. + +## Provider Configuration + +Configure the OpenAI-compatible endpoint Seerr will use to generate recommendations and interpret searches. + +- **AI Provider** — Choose your provider type. Selecting **Ollama (Local)** adjusts the expected base URL and hides the API key field, since Ollama does not require authentication. +- **Base URL** — The root URL of the provider's API. For OpenAI this is `https://api.openai.com/v1`. For a local Ollama instance, use `http://localhost:11434/v1` when Seerr runs on your host, or `http://ollama:11434/v1` when Seerr runs in Docker Compose alongside the Ollama service (as in the bundled `compose.ai.yaml`). +- **Model** — The model name to use (for example, `gpt-4o-mini`, `mistral`, or whatever your local provider exposes). +- **API Key** — The key for your provider. This is not required for Ollama. Once saved, the field is masked; leave it blank on subsequent edits to keep the existing key. If you would rather provide the key out of band, Seerr falls back to the `OPENAI_API_KEY` environment variable. + +Use **Test Connection** to verify your settings before saving. It sends a minimal request to the provider and reports whether a valid response came back, along with the round-trip latency. + +## Recommendations + +When enabled, Seerr generates a "Recommended for You" slider on the Discover page and a dedicated recommendations page, personalized per user from their requests, watchlist, and available library. + +![The Recommended for You discover page](/img/seerr-ai-recommend.png) + +Recommendations are produced by a background job (the AI Recommendations Sync job, every 6 hours by default) rather than on demand. You can also trigger it manually from the Jobs & Cache settings tab to populate the slider sooner after first enabling it. + +- **Enable Recommendations** — Turns the recommendations slider and page on or off independently of the master toggle above. +- **Slider Title** — The heading shown above the slider on the Discover page. Defaults to "Recommended for You". +- **Max Results** — The maximum number of titles to generate per user, between 1 and 50. +- **Minimum Rating** — The minimum TMDB rating (0.0–10.0) a recommendation must meet; higher values surface better-rated titles at the cost of fewer results. Defaults to 7. +- **Recommendation TTL (days)** — How long a recommendation lives before it expires. Titles that are re-recommended on a later run are kept alive, while stale ones age out after this many days, so the list refreshes gradually rather than being replaced wholesale. + +## AI Search + +When enabled, an "AI Search" toggle is added to the search page. Regular keyword search remains the default and does not use the AI provider at all; switching to AI Search interprets a natural-language query ("90s psychological thrillers", "feel-good anime about friendship") using the configured model, resolves it to TMDB results, and shows an "AI interpretation" badge describing how the query was parsed. + +This setting is **disabled** by default. + +## Feedback + +On recommendation cards, each user can rate a title with three quick actions, revealed by hovering over the card: + +- 👍 **More like this** — biases future recommendations toward similar content. +- 👁️ **Already watched** — removes the card and excludes the title from future recommendations. +- 👎 **Not interested** — removes the card and excludes the title from future recommendations. + +Dislike and "already watched" take effect immediately in the current list; like is recorded quietly to refine the next generation run. diff --git a/gen-docs/static/img/seerr-ai-recommend.png b/gen-docs/static/img/seerr-ai-recommend.png new file mode 100644 index 0000000000..460ac4a827 Binary files /dev/null and b/gen-docs/static/img/seerr-ai-recommend.png differ diff --git a/package.json b/package.json index 9da491159a..c779c7834a 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "ace-builds": "1.43.6", "axios": "1.15.0", "axios-rate-limit": "1.9.0", + "openai": "^6.25.0", "bcrypt": "6.0.0", "bowser": "2.14.1", "connect-typeorm": "2.0.0", diff --git a/seerr-api.yml b/seerr-api.yml index cd0012b679..d614c6a173 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -6395,6 +6395,282 @@ paths: type: string title: type: string + /discover/ai-recommendations: + get: + summary: Get AI-powered personalized recommendations + description: | + Returns personalized media recommendations generated by the AI engine based on the + user's request history, watchlist, and available library content. + tags: + - discover + security: + - cookieAuth: [] + - apiKey: [] + parameters: + - in: query + name: page + schema: + type: number + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: AI recommendations returned + content: + application/json: + schema: + type: object + properties: + page: + type: number + totalPages: + type: number + totalResults: + type: number + results: + type: array + items: + type: object + properties: + id: + type: number + mediaType: + type: string + title: + type: string + posterPath: + type: string + nullable: true + backdropPath: + type: string + nullable: true + overview: + type: string + releaseDate: + type: string + nullable: true + voteAverage: + type: number + aiRationale: + type: string + nullable: true + aiScore: + type: number + nullable: true + '404': + description: AI recommendations are disabled + /ai/settings: + get: + summary: Get AI settings + description: Retrieves the current AI provider and feature configuration. + tags: + - ai + security: + - cookieAuth: [] + - apiKey: [] + responses: + '200': + description: AI settings returned + content: + application/json: + schema: + type: object + put: + summary: Update AI settings + description: Updates the AI provider and feature configuration. + tags: + - ai + security: + - cookieAuth: [] + - apiKey: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + '200': + description: AI settings updated + /ai/test: + post: + summary: Test AI provider connection + description: Tests connectivity to the configured AI provider. + tags: + - ai + security: + - cookieAuth: [] + - apiKey: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + '200': + description: Connection test result + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + latency: + type: number + error: + type: string + /ai/search: + post: + summary: AI-powered natural language search + description: | + Interprets a natural language query and returns personalized media results + based on the user's viewing history. + tags: + - ai + security: + - cookieAuth: [] + - apiKey: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + query: + type: string + options: + type: object + responses: + '200': + description: Search results returned + content: + application/json: + schema: + type: object + properties: + page: + type: number + totalPages: + type: number + totalResults: + type: number + results: + type: array + items: + type: object + query: + type: string + interpretation: + type: object + nullable: true + description: | + How the AI parsed the query (genres, year range, language, + min rating, keywords) — surfaced to the UI as a badge. + properties: + discover_params: + type: object + suggested_titles: + type: array + items: + type: object + /ai/feedback: + post: + summary: Submit user feedback on a recommendation + description: Records like/dislike/seen feedback for a media item. + tags: + - ai + security: + - cookieAuth: [] + - apiKey: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + tmdbId: + type: number + mediaType: + type: string + enum: [movie, tv] + feedbackType: + type: string + enum: [like, dislike, seen] + responses: + '200': + description: Feedback submitted + /ai/feedback/stats: + get: + summary: Get user feedback statistics + description: Returns counts and recent feedback for the current user. + tags: + - ai + security: + - cookieAuth: [] + - apiKey: [] + responses: + '200': + description: Feedback statistics returned + content: + application/json: + schema: + type: object + /ai/feedback/{tmdbId}: + delete: + summary: Delete user feedback + description: Removes feedback for a specific media item. + tags: + - ai + security: + - cookieAuth: [] + - apiKey: [] + parameters: + - in: path + name: tmdbId + required: true + schema: + type: number + - in: query + name: mediaType + required: true + schema: + type: string + enum: [movie, tv] + responses: + '200': + description: Feedback deleted + '404': + description: Feedback not found + /ai/regenerate: + post: + summary: Regenerate AI recommendations + description: Manually triggers recommendation generation for the current user. + tags: + - ai + security: + - cookieAuth: [] + - apiKey: [] + responses: + '200': + description: Recommendations regenerated + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + count: + type: number /request: get: summary: Get all requests diff --git a/server/api/ai/index.ts b/server/api/ai/index.ts new file mode 100644 index 0000000000..4a028d0f4b --- /dev/null +++ b/server/api/ai/index.ts @@ -0,0 +1,525 @@ +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import { OpenAI } from 'openai'; +import type { ChatCompletionMessageParam } from 'openai/resources/chat/completions'; + +export interface WatchHistoryItem { + tmdbId: number; + mediaType: 'movie' | 'tv'; + title: string; + year?: number; + rating?: number; + playCount?: number; + genres?: string[]; + overview?: string; + posterPath?: string; + backdropPath?: string; +} + +export interface TasteProfile { + profile: string; + keywords: string[]; + preferredGenres?: string[]; + preferredThemes?: string[]; + avoidedGenres?: string[]; + preferredEra?: { + from: number; + to: number; + }; +} + +export interface AIRecommendationItem { + title: string; + year: number; + tmdbId?: number; + type: 'movie' | 'tv'; + rationale: string; +} + +export interface RecommendationFilters { + genres?: number[]; + yearRange?: [number, number]; + languages?: string[]; + minRating?: number; + excludeKeywords?: string[]; + includeKeywords?: string[]; + minRuntime?: number; + studios?: number[]; + networks?: number[]; + mediaType?: 'movie' | 'tv' | 'both'; + vibePrompt?: string; +} + +export interface SearchInterpretation { + discoverParams: { + genres?: string[]; + year_from?: number; + year_to?: number; + original_language?: string; + sort_by?: string; + min_rating?: number; + keywords?: string[]; + }; + suggestedTitles: { + title: string; + year?: number; + type: 'movie' | 'tv'; + rationale: string; + }[]; +} + +export interface LLMClient { + generateTasteProfile(history: WatchHistoryItem[]): Promise; + generateRecommendations( + profile: TasteProfile, + history: WatchHistoryItem[], + filters: RecommendationFilters, + maxResults: number, + likedTitles?: { title: string; mediaType: 'movie' | 'tv' }[] + ): Promise; + interpretSearchQuery( + query: string, + history?: WatchHistoryItem[] + ): Promise; + testConnection(): Promise; +} + +export class OpenAICompatibleClient implements LLMClient { + private client: OpenAI; + private model: string; + + constructor() { + const settings = getSettings(); + const aiConfig = settings.ai; + + this.client = new OpenAI({ + apiKey: + aiConfig.provider.apiKey || + process.env.OPENAI_API_KEY || + 'sk-placeholder', + baseURL: aiConfig.provider.baseUrl || 'https://api.openai.com/v1', + }); + this.model = aiConfig.provider.model; + } + + private async callLLM( + messages: { role: 'system' | 'user' | 'assistant'; content: string }[] + ): Promise { + try { + const response = await this.client.chat.completions.create({ + model: this.model, + messages: messages as ChatCompletionMessageParam[], + temperature: 0.7, + max_tokens: 4000, + }); + + const content = this.extractContent(response.choices[0]?.message as any); + if (!content) { + throw new Error('Empty response from LLM'); + } + + return content; + } catch (error) { + logger.error('LLM API call failed:', error); + throw error; + } + } + + /** + * Pull text out of a completion message. Reasoning models (e.g. GLM, + * o-series) sometimes put the answer in `reasoning_content` and leave + * `content` empty, so prefer `content` and fall back to `reasoning_content`. + * Returns '' if neither is a usable string. + */ + private extractContent(message?: { + content?: string | null; + reasoning_content?: string | null; + }): string { + const content = message?.content ?? message?.reasoning_content; + return typeof content === 'string' ? content : ''; + } + + /** + * Tolerant JSON repair for LLM output. Small local models frequently produce + * trailing commas, truncated output (cut off by max_tokens), or unescaped + * quotes. This rewrites the most common defects before parsing. + */ + private repairJSON(raw: string): string { + let s = raw; + // Strip markdown fences + s = s.replace(/```json\s*/gi, '').replace(/```\s*/g, ''); + // Trim trailing commas before } or ] + s = s.replace(/,\s*([}\]])/g, '$1'); + // Remove stray control characters + // eslint-disable-next-line no-control-regex -- stripping stray control chars from LLM output is intentional + s = s.replace(/[\x00-\x1f]/g, (ch) => + ch === '\n' || ch === '\t' ? ch : '' + ); + return s; + } + + private extractJSON(content: string): any { + const cleaned = this.repairJSON(content); + + // Find the first complete JSON object + const firstBrace = cleaned.indexOf('{'); + const lastBrace = cleaned.lastIndexOf('}'); + + if (firstBrace === -1 || lastBrace === -1) { + throw new Error('No valid JSON found in response'); + } + + const jsonStr = cleaned.substring(firstBrace, lastBrace + 1); + + try { + return JSON.parse(jsonStr); + } catch { + // Last resort: the output was likely truncated. Try to close any + // unbalanced brackets/braces so we can salvage partial data. + const salvaged = this.closeUnbalanced(jsonStr); + return JSON.parse(salvaged); + } + } + + /** + * Best-effort truncation repair: counts open vs. closed brackets/braces and + * appends the missing closers. Used when max_tokens cuts the response short. + */ + private closeUnbalanced(s: string): string { + let openObjects = 0; + let openArrays = 0; + let inString = false; + let escape = false; + + for (let i = 0; i < s.length; i++) { + const ch = s[i]; + if (escape) { + escape = false; + continue; + } + if (ch === '\\') { + escape = true; + continue; + } + if (ch === '"') { + inString = !inString; + continue; + } + if (inString) continue; + if (ch === '{') openObjects++; + else if (ch === '}') openObjects--; + else if (ch === '[') openArrays++; + else if (ch === ']') openArrays--; + } + + // Remove a dangling trailing comma so the closers are valid + const result = s.replace(/,\s*$/, ''); + // Close any open array then object, from innermost out + let suffix = ''; + for (let i = 0; i < openArrays; i++) suffix += ']'; + for (let i = 0; i < openObjects; i++) suffix += '}'; + return result + suffix; + } + + /** + * Call the LLM and parse structured JSON, retrying once with a corrective + * system message if the first attempt yields invalid JSON. + */ + private async callForJSON( + messages: { role: 'system' | 'user' | 'assistant'; content: string }[] + ): Promise { + const maxAttempts = 2; + let lastError: Error | null = null; + let currentMessages = messages; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const raw = await this.callLLM(currentMessages); + try { + return this.extractJSON(raw); + } catch (err) { + lastError = err as Error; + logger.warn( + `JSON parse failed on attempt ${attempt}/${maxAttempts}: ${lastError.message}. Retrying with corrective prompt.` + ); + // Inject the broken output and ask the model to fix it + currentMessages = [ + ...messages, + { role: 'assistant', content: raw }, + { + role: 'system', + content: + 'Your previous response was not valid JSON — it was likely truncated or contained a syntax error. ' + + 'Reply with ONLY a single valid JSON object, no markdown, no commentary. ' + + 'If you cannot fit all items, return fewer items rather than cutting off mid-object.', + }, + ]; + } + } + throw new Error( + `LLM did not return valid JSON after ${maxAttempts} attempts: ${lastError?.message}` + ); + } + + async generateTasteProfile( + history: WatchHistoryItem[] + ): Promise { + const prompt = this.buildTasteProfilePrompt(history); + + try { + const parsed = await this.callForJSON([ + { + role: 'system', + content: + 'You are a psychological film and television analyst. You analyze viewing history and output structured taste profiles in valid JSON format.', + }, + { + role: 'user', + content: prompt, + }, + ]); + + return { + profile: parsed.profile || '', + keywords: Array.isArray(parsed.keywords) ? parsed.keywords : [], + preferredGenres: parsed.preferredGenres || [], + preferredThemes: parsed.preferredThemes || [], + avoidedGenres: parsed.avoidedGenres || [], + preferredEra: parsed.preferredEra || undefined, + }; + } catch (error) { + logger.error('Failed to generate taste profile:', error); + throw new Error(`Taste profile generation failed: ${error.message}`); + } + } + + async generateRecommendations( + profile: TasteProfile, + history: WatchHistoryItem[], + filters: RecommendationFilters, + maxResults: number, + likedTitles?: { title: string; mediaType: 'movie' | 'tv' }[] + ): Promise { + const prompt = this.buildRecommendationsPrompt( + profile, + history, + filters, + maxResults, + likedTitles + ); + + try { + const parsed = await this.callForJSON([ + { + role: 'system', + content: + 'You are an expert film and television recommendation engine. You generate personalized recommendations based on taste profiles and viewing history. Output must be valid JSON.', + }, + { + role: 'user', + content: prompt, + }, + ]); + const recommendations = Array.isArray(parsed.recommendations) + ? parsed.recommendations + : []; + + return recommendations.slice(0, maxResults); + } catch (error) { + logger.error('Failed to generate recommendations:', error); + throw new Error(`Recommendation generation failed: ${error.message}`); + } + } + + async interpretSearchQuery( + query: string, + history?: WatchHistoryItem[] + ): Promise { + const prompt = this.buildSearchPrompt(query, history); + + try { + const parsed = await this.callForJSON([ + { + role: 'system', + content: + 'You are a media search interpreter. You translate natural language queries into structured TMDB search parameters and suggest specific titles. Output must be valid JSON.', + }, + { + role: 'user', + content: prompt, + }, + ]); + + return { + discoverParams: parsed.discover_params || {}, + suggestedTitles: Array.isArray(parsed.suggested_titles) + ? parsed.suggested_titles + : [], + }; + } catch (error) { + logger.error('Failed to interpret search query:', error); + throw new Error(`Search interpretation failed: ${error.message}`); + } + } + + async testConnection(): Promise { + try { + const response = await this.client.chat.completions.create({ + model: this.model, + messages: [{ role: 'user', content: 'Say "ok"' }], + max_tokens: 5, + }); + + const content = this.extractContent(response.choices[0]?.message as any); + return content.toLowerCase().includes('ok'); + } catch (error) { + logger.error('LLM connection test failed:', error); + return false; + } + } + + private buildTasteProfilePrompt(history: WatchHistoryItem[]): string { + const historyText = history + .map( + (item) => + `- ${item.title} (${item.year || 'N/A'}) - ${item.mediaType} - Rating: ${item.rating || 'N/A'} - Plays: ${item.playCount || 0} - Genres: ${item.genres?.join(', ') || 'N/A'}` + ) + .join('\n'); + + return `Analyze this viewing history and create a psychological taste profile: + +Watch History: +${historyText} + +Response MUST be valid JSON in this exact format: +{ + "profile": "A detailed paragraph explaining the user's taste, including pacing preferences, thematic interests, preferred genres, and common tropes they enjoy", + "keywords": ["specific_keyword1", "specific_keyword2", "specific_keyword3"], + "preferredGenres": ["genre1", "genre2"], + "preferredThemes": ["theme1", "theme2"], + "avoidedGenres": ["genre1"], + "preferredEra": {"from": 1990, "to": 2000} +} + +Keywords should be highly specific niche terms (e.g., "cyberpunk", "time-loop", "noir") that capture the essence of their taste.`; + } + + private buildRecommendationsPrompt( + profile: TasteProfile, + history: WatchHistoryItem[], + filters: RecommendationFilters, + maxResults: number, + likedTitles?: { title: string; mediaType: 'movie' | 'tv' }[] + ): string { + const historyTitles = history + .map((h) => `- ${h.title} (${h.year || 'N/A'})`) + .join('\n'); + const filtersText = this.formatFilters(filters); + const likedText = + likedTitles && likedTitles.length > 0 + ? `TITLES THE USER EXPLICITLY LIKED (lean toward content similar to these):\n${likedTitles + .map((l) => `- ${l.title} (${l.mediaType})`) + .join('\n')}\n\n` + : ''; + + return `Generate personalized recommendations based on this taste profile and viewing history. + +TASTE PROFILE: +${JSON.stringify(profile, null, 2)} + +${likedText}VIEWING HISTORY (DO NOT recommend these exact titles): +${historyTitles} + +${filtersText ? `FILTER CONSTRAINTS:\n${filtersText}\n` : ''} + +Return exactly ${maxResults} recommendations (the configured amount). Focus on the accurate title and release year — these are used to look the title up, so get them right. Do NOT invent tmdb_id values; omit the field if you are not certain. If you genuinely cannot reach ${maxResults} distinct, relevant titles, return as many as you can rather than padding with weak guesses. + +Response MUST be valid JSON in this exact format: +{ + "recommendations": [ + { + "title": "Movie Title", + "year": 2024, + "type": "movie" | "tv", + "rationale": "Why this matches their taste based on profile and history..." + } + ] +}`; + } + + private buildSearchPrompt( + query: string, + history?: WatchHistoryItem[] + ): string { + const historyText = + history && history.length > 0 + ? `USER'S VIEWING HISTORY (for personalization):\n${history.map((h) => `- ${h.title}`).join('\n')}\n` + : ''; + + return `Translate this natural language query into structured TMDB search parameters and suggest specific titles. + +${historyText}USER QUERY: "${query}" + +Response MUST be valid JSON in this exact format: +{ + "discover_params": { + "genres": ["Action", "Thriller"], + "year_from": 1990, + "year_to": 1999, + "original_language": "en", + "sort_by": "vote_average.desc", + "min_rating": 7.0, + "keywords": ["keyword1", "keyword2"] + }, + "suggested_titles": [ + { + "title": "The Matrix", + "year": 1999, + "type": "movie", + "rationale": "Matches the sci-fi action theme from the query" + } + ] +} + +If the query doesn't specify a parameter, set it to null or omit it. "sort_by" should be inferred from intent: +- "best" → "vote_average.desc" +- "popular" → "popularity.desc" +- "latest" → "release_date.desc" +- "oldest" → "release_date.asc"`; + } + + private formatFilters(filters: RecommendationFilters): string { + const parts: string[] = []; + + if (filters.genres && filters.genres.length > 0) { + parts.push(`- MUST be in genres: ${filters.genres.join(', ')}`); + } + + if (filters.mediaType && filters.mediaType !== 'both') { + parts.push(`- ONLY ${filters.mediaType} content`); + } + + if (filters.yearRange) { + parts.push( + `- MUST be released between ${filters.yearRange[0]} and ${filters.yearRange[1]}` + ); + } + + if (filters.minRating) { + parts.push(`- MUST have rating >= ${filters.minRating}`); + } + + if (filters.languages && filters.languages.length > 0) { + parts.push(`- MUST be in languages: ${filters.languages.join(', ')}`); + } + + if (filters.vibePrompt) { + parts.push(`- VIBE/MOOD: ${filters.vibePrompt}`); + } + + return parts.join('\n'); + } +} + +export function createLLMClient(): LLMClient { + return new OpenAICompatibleClient(); +} diff --git a/server/constants/discover.ts b/server/constants/discover.ts index fda0682243..2b902e885a 100644 --- a/server/constants/discover.ts +++ b/server/constants/discover.ts @@ -22,6 +22,7 @@ export enum DiscoverSliderType { TMDB_NETWORK, TMDB_MOVIE_STREAMING_SERVICES, TMDB_TV_STREAMING_SERVICES, + AI_RECOMMENDATIONS, } export const defaultSliders: Partial[] = [ @@ -97,4 +98,10 @@ export const defaultSliders: Partial[] = [ isBuiltIn: true, order: 11, }, + { + type: DiscoverSliderType.AI_RECOMMENDATIONS, + enabled: true, + isBuiltIn: true, + order: 12, + }, ]; diff --git a/server/entity/AiRecommendation.ts b/server/entity/AiRecommendation.ts new file mode 100644 index 0000000000..7e4f4aa3e5 --- /dev/null +++ b/server/entity/AiRecommendation.ts @@ -0,0 +1,62 @@ +import type { MediaType } from '@server/constants/media'; +import { DbAwareColumn } from '@server/utils/DbColumnHelper'; +import { + Column, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + Unique, +} from 'typeorm'; +import { User } from './User'; + +@Entity('ai_recommendation') +@Index(['userId', 'mediaType']) +@Index(['updatedAt']) +@Unique(['userId', 'tmdbId', 'mediaType']) +export class AiRecommendation { + @PrimaryGeneratedColumn() + id: number; + + @Column({ type: 'int' }) + userId: number; + + @Column() + tmdbId: number; + + @Column({ type: 'varchar' }) + mediaType: MediaType; + + @Column({ type: 'int', nullable: true }) + tvdbId: number | null; + + @Column({ type: 'float', nullable: true }) + score: number | null; + + @Column({ type: 'text', nullable: true }) + rationale: string | null; + + @DbAwareColumn({ type: 'simple-json', nullable: true }) + metadata: { + source: 'ai' | 'tmdb' | 'hybrid'; + keywords?: string[]; + genres?: string[]; + relatedTitles?: number[]; + modelUsed: string; + generatedAt: string; + } | null; + + @DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) + createdAt: Date; + + @DbAwareColumn({ + type: 'datetime', + default: () => 'CURRENT_TIMESTAMP', + }) + updatedAt: Date; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'userId' }) + user: User | null; +} diff --git a/server/entity/UserFeedback.ts b/server/entity/UserFeedback.ts new file mode 100644 index 0000000000..396963fa4b --- /dev/null +++ b/server/entity/UserFeedback.ts @@ -0,0 +1,37 @@ +import type { MediaType } from '@server/constants/media'; +import { DbAwareColumn } from '@server/utils/DbColumnHelper'; +import { + Column, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + Unique, +} from 'typeorm'; +import { User } from './User'; + +@Entity('user_feedback') +@Unique(['userId', 'tmdbId', 'mediaType']) +export class UserFeedback { + @PrimaryGeneratedColumn() + id: number; + + @Column({ type: 'int' }) + userId: number; + + @Column() + tmdbId: number; + + @Column({ type: 'varchar' }) + mediaType: MediaType; + + @Column({ type: 'varchar' }) + feedbackType: 'like' | 'dislike' | 'seen'; + + @DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) + createdAt: Date; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'userId' }) + user: User; +} diff --git a/server/job/schedule.ts b/server/job/schedule.ts index a9afd2f4d6..8ec3995da5 100644 --- a/server/job/schedule.ts +++ b/server/job/schedule.ts @@ -1,5 +1,12 @@ import { MediaServerType } from '@server/constants/server'; +import { UserType } from '@server/constants/user'; +import { getRepository } from '@server/datasource'; +import { User } from '@server/entity/User'; import blocklistedTagsProcessor from '@server/job/blocklistedTagsProcessor'; +import { + cleanupExpiredRecommendations, + generateRecommendations, +} from '@server/lib/aiRecommendations'; import availabilitySync from '@server/lib/availabilitySync'; import downloadTracker from '@server/lib/downloadtracker'; import ImageProxy from '@server/lib/imageproxy'; @@ -16,6 +23,7 @@ import { getSettings } from '@server/lib/settings'; import watchlistSync from '@server/lib/watchlistsync'; import logger from '@server/logger'; import schedule from 'node-schedule'; +import { In } from 'typeorm'; interface ScheduledJob { id: JobId; @@ -259,5 +267,98 @@ export const startJobs = (): void => { cancelFn: () => blocklistedTagsProcessor.cancel(), }); + // AI Recommendations Sync + let aiSyncRunning = false; + if (jobs['ai-recommendations-sync']) { + scheduledJobs.push({ + id: 'ai-recommendations-sync', + name: 'AI Recommendations Sync', + type: 'process', + interval: 'hours', + cronSchedule: jobs['ai-recommendations-sync'].schedule, + job: schedule.scheduleJob( + jobs['ai-recommendations-sync'].schedule, + async () => { + const settings = getSettings(); + if (!settings.ai.enabled || !settings.ai.recommendations.enabled) { + logger.info('AI recommendations disabled, skipping job', { + label: 'Jobs', + }); + return; + } + + if (aiSyncRunning) { + logger.info('AI Recommendations Sync already running, skipping', { + label: 'Jobs', + }); + return; + } + aiSyncRunning = true; + + logger.info('Starting scheduled job: AI Recommendations Sync', { + label: 'Jobs', + }); + + try { + // Purge recommendations older than the configured TTL before + // generating fresh ones for this run. + const expired = await cleanupExpiredRecommendations(); + if (expired > 0) { + logger.info(`Expired ${expired} stale AI recommendations`, { + label: 'Jobs', + }); + } + + const userRepository = getRepository(User); + const users = await userRepository.find({ + where: { + userType: In([ + UserType.PLEX, + UserType.LOCAL, + UserType.JELLYFIN, + ]), + }, + }); + + logger.info( + `Generating AI recommendations for ${users.length} users`, + { + label: 'Jobs', + } + ); + + for (const user of users) { + try { + await generateRecommendations(user.id, { + limit: settings.ai.recommendations.maxResults, + includeTmdb: true, + }); + logger.info( + `Generated AI recommendations for user ${user.id}`, + { + label: 'Jobs', + } + ); + } catch (error) { + logger.error( + `Failed to generate AI recommendations for user ${user.id}:`, + error + ); + } + } + + logger.info('AI Recommendations Sync job completed', { + label: 'Jobs', + }); + } catch (error) { + logger.error('AI Recommendations Sync job failed:', error); + } finally { + aiSyncRunning = false; + } + } + ), + }); + } + logger.info('Scheduled jobs loaded', { label: 'Jobs' }); }; diff --git a/server/lib/aiRecommendations.ts b/server/lib/aiRecommendations.ts new file mode 100644 index 0000000000..d91c0b10e5 --- /dev/null +++ b/server/lib/aiRecommendations.ts @@ -0,0 +1,872 @@ +import type { RecommendationFilters, WatchHistoryItem } from '@server/api/ai'; +import { createLLMClient } from '@server/api/ai'; +import TheMovieDb from '@server/api/themoviedb'; +import { MediaType } from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import { AiRecommendation } from '@server/entity/AiRecommendation'; +import Media from '@server/entity/Media'; +import { MediaRequest } from '@server/entity/MediaRequest'; +import { UserFeedback } from '@server/entity/UserFeedback'; +import { Watchlist } from '@server/entity/Watchlist'; +import logger from '@server/logger'; +import { getSettings } from './settings'; + +interface GenerateOptions { + limit?: number; + filters?: RecommendationFilters; + includeTmdb?: boolean; +} + +export async function generateTasteProfile( + userId: number, + options?: { maxHistoryItems?: number } +): Promise<{ profile: string; keywords: string[] }> { + try { + // 1. Fetch user signals + const [requests, watchlist, availableMedia] = await Promise.all([ + getRepository(MediaRequest) + .createQueryBuilder('request') + .where('request.requestedById = :userId', { userId }) + .andWhere('request.status IN (:...statuses)', { + statuses: [1, 2, 5], // PENDING, APPROVED, COMPLETED + }) + .orderBy('request.createdAt', 'DESC') + .limit(50) + .leftJoin('request.media', 'media') + .addSelect(['media.tmdbId', 'media.mediaType']) + .getMany(), + getRepository(Watchlist) + .createQueryBuilder('watchlist') + .where('watchlist.requestedBy = :userId', { userId }) + .orderBy('watchlist.createdAt', 'DESC') + .limit(50) + .getMany(), + getRepository(Media) + .createQueryBuilder('media') + .where('media.status = :status', { status: 5 }) // AVAILABLE + .andWhere('media.mediaAddedAt IS NOT NULL') + .orderBy('media.mediaAddedAt', 'DESC') + .limit(50) + .getMany(), + ]); + + // 2. Score and sample items (similar to Recomendarr approach) + const scoredItems = scoreMediaItems([ + ...requests.map((r) => ({ + tmdbId: r.media?.tmdbId || 0, + mediaType: r.type, + title: '', + score: calculateRequestScore(r), + })), + ...watchlist.map((w) => ({ + tmdbId: w.tmdbId, + mediaType: w.mediaType, + title: w.title, + score: 8, // Base score for watchlist items + })), + ...availableMedia.map((m) => ({ + tmdbId: m.tmdbId, + mediaType: m.mediaType, + title: '', + score: calculateMediaScore(m), + })), + ]); + + const topItems = scoredItems.slice(0, options?.maxHistoryItems || 40); + + // 3. Fetch TMDB metadata for each item + const tmdb = new TheMovieDb(); + + const enrichedItems: WatchHistoryItem[] = []; + for (const item of topItems) { + try { + const metadata: any = + item.mediaType === 'tv' + ? await tmdb.getTvShow({ tvId: item.tmdbId }) + : await tmdb.getMovie({ movieId: item.tmdbId }); + enrichedItems.push({ + tmdbId: item.tmdbId, + mediaType: item.mediaType as 'movie' | 'tv', + title: metadata.title || metadata.name || '', + year: metadata.release_date + ? new Date(metadata.release_date).getFullYear() + : metadata.first_air_date + ? new Date(metadata.first_air_date).getFullYear() + : undefined, + genres: metadata.genres?.map((g: any) => g.name) || [], + overview: metadata.overview || undefined, + posterPath: metadata.poster_path || undefined, + backdropPath: metadata.backdrop_path || undefined, + rating: metadata.vote_average, + playCount: item.score > 10 ? Math.floor(item.score / 10) : 1, + }); + } catch (error) { + logger.warn( + `Failed to fetch TMDB metadata for tmdbId ${item.tmdbId}:`, + error + ); + } + } + + // 4. Call LLM with taste profile prompt + const llm = createLLMClient(); + const tasteProfile = await llm.generateTasteProfile(enrichedItems); + + return { + profile: tasteProfile.profile, + keywords: tasteProfile.keywords, + }; + } catch (error) { + logger.error(`Failed to generate taste profile for user ${userId}:`, error); + throw error; + } +} + +export async function generateRecommendations( + userId: number, + options?: GenerateOptions +): Promise { + const logTag = { label: 'AI' }; + try { + const settings = getSettings(); + + if (!settings.ai.enabled || !settings.ai.recommendations.enabled) { + throw new Error('AI recommendations are disabled'); + } + + // 1. Generate taste profile + logger.info( + `[user ${userId}] Step 1/8: generating taste profile...`, + logTag + ); + const { profile, keywords } = await generateTasteProfile(userId); + logger.info( + `[user ${userId}] Step 1/8 done. keywords: ${JSON.stringify(keywords)}`, + logTag + ); + + // 2. Fetch user feedback for personalization + const feedback = await getUserFeedback(userId); + + // 2. Get user signals for history + const history = await getUserSignals(userId); + logger.info( + `[user ${userId}] Step 2/8 done. ${history.length} history items, ${feedback.length} feedback`, + logTag + ); + + // 2b. Resolve titles the user has "liked" so they can positively bias the + // recommendation prompt (the LLM leans toward similar content). + const likedTitles = await getLikedTitles(feedback); + if (likedTitles.length > 0) { + logger.info( + `[user ${userId}] biasing with ${likedTitles.length} liked titles`, + logTag + ); + } + + // 3. Build filters from settings and options + const filters: RecommendationFilters = { + ...options?.filters, + mediaType: 'both', + minRating: settings.ai.recommendations.minRating, + }; + + // 4. Call LLM for recommendations + logger.info( + `[user ${userId}] Step 3/8: calling LLM for recommendations...`, + logTag + ); + const llm = createLLMClient(); + const aiRecs = await llm.generateRecommendations( + { profile: profile, keywords } as any, + history, + filters, + options?.limit || settings.ai.recommendations.maxResults, + likedTitles + ); + logger.info( + `[user ${userId}] Step 3/8 done. ${aiRecs.length} AI recs`, + logTag + ); + + // 5. Resolve AI recs to real TMDB entries (LLM titles -> tmdbId via search). + // Small models hallucinate tmdbId values, so we look them up by title/year. + logger.info( + `[user ${userId}] Step 4/8: resolving ${aiRecs.length} titles via TMDB...`, + logTag + ); + const resolvedAiRecs = await resolveRecommendationsViaTmdb(aiRecs); + logger.info( + `[user ${userId}] Step 4/8 done. ${resolvedAiRecs.length} resolved`, + logTag + ); + + // 6. If enabled, augment with TMDB recommendations using AI keywords + let tmdbRecs: any[] = []; + if (options?.includeTmdb && keywords && keywords.length > 0) { + logger.info( + `[user ${userId}] Step 5/8: TMDB keyword discovery...`, + logTag + ); + tmdbRecs = await discoverByKeywords(keywords, filters); + logger.info( + `[user ${userId}] Step 5/8 done. ${tmdbRecs.length} TMDB recs`, + logTag + ); + } + + // 7. Merge, deduplicate, and score + const merged = mergeAndScoreRecommendations( + resolvedAiRecs, + tmdbRecs, + profile, + keywords + ); + + // 8. Filter out already watched/requested/disliked + logger.info( + `[user ${userId}] Step 6/8: filtering ${merged.length} merged recs...`, + logTag + ); + const filtered = await filterExistingContent(merged, userId); + logger.info( + `[user ${userId}] Step 6/8 done. ${filtered.length} recs after filtering`, + logTag + ); + + // 9. Store in database + logger.info(`[user ${userId}] Step 7/8: storing to database...`, logTag); + await storeRecommendations(filtered, userId); + logger.info( + `[user ${userId}] Step 8/8: DONE. stored ${filtered.length} recommendations`, + logTag + ); + + return filtered; + } catch (error) { + logger.error( + `Failed to generate recommendations for user ${userId}:`, + error + ); + throw error; + } +} + +export async function aiSearch( + userId: number, + query: string, + options?: { limit?: number; includeHistory?: boolean } +): Promise<{ results: any[]; interpretation: any }> { + try { + const settings = getSettings(); + + if (!settings.ai.enabled || !settings.ai.search.enabled) { + throw new Error('AI search is disabled'); + } + + const llm = createLLMClient(); + const userHistory = options?.includeHistory + ? await getUserSignals(userId) + : undefined; + + // 1. Interpret query + const interpretation = await llm.interpretSearchQuery(query, userHistory); + + // 2. Parallel search + const [tmdbResults, suggestedTitles] = await Promise.all([ + discoverFromTmdb(interpretation.discoverParams), + searchTmdbTitles(interpretation.suggestedTitles), + ]); + + // 3. Merge and personalize + const results = mergeSearchResults(tmdbResults, suggestedTitles); + + // 4. Filter out watched/disliked + const filtered = await filterExistingContent(results, userId); + + return { + results: filtered.slice(0, options?.limit || 20), + interpretation, + }; + } catch (error) { + logger.error(`AI search failed for user ${userId}:`, error); + throw error; + } +} + +// Helper functions + +function calculateRequestScore(request: MediaRequest): number { + let score = 5; // Base score + + // Boost based on status + if (request.status === 5) + score += 5; // COMPLETED + else if (request.status === 2) + score += 3; // APPROVED + else if (request.status === 1) score += 1; // PENDING + + // Time decay (older requests get slightly lower score) + const daysSinceRequest = + (Date.now() - request.createdAt.getTime()) / (1000 * 60 * 60 * 24); + score -= Math.min(daysSinceRequest / 365, 2); // Max penalty of 2 + + return score; +} + +function calculateMediaScore(media: Media): number { + let score = 6; // Base score for available media + + // Boost for recently added + if (media.mediaAddedAt) { + const daysSinceAdded = + (Date.now() - media.mediaAddedAt.getTime()) / (1000 * 60 * 60 * 24); + score += Math.max(0, 3 - daysSinceAdded / 30); // Decay over 90 days + } + + return score; +} + +function scoreMediaItems( + items: { tmdbId: number; mediaType: string; title: string; score: number }[] +) { + return items + .map((item) => ({ + ...item, + finalScore: item.score + Math.random() * 2, // Add small random factor + })) + .sort((a, b) => b.finalScore - a.finalScore); +} + +async function getUserSignals(userId: number): Promise { + const [requests, watchlist, media] = await Promise.all([ + getRepository(MediaRequest) + .createQueryBuilder('request') + .where('request.requestedById = :userId', { userId }) + .leftJoin('request.media', 'm') + .addSelect(['m.tmdbId', 'm.mediaType']) + .limit(30) + .getMany(), + getRepository(Watchlist) + .createQueryBuilder('watchlist') + .where('watchlist.requestedBy = :userId', { userId }) + .limit(20) + .getMany(), + getRepository(Media) + .createQueryBuilder('media') + .where('media.status = :status', { status: 5 }) + .limit(30) + .getMany(), + ]); + + const signals: WatchHistoryItem[] = []; + + for (const request of requests) { + if (request.media) { + signals.push({ + tmdbId: request.media.tmdbId, + mediaType: request.type, + title: '', + playCount: 1, + }); + } + } + + for (const item of watchlist) { + signals.push({ + tmdbId: item.tmdbId, + mediaType: item.mediaType, + title: item.title, + playCount: 1, + }); + } + + for (const m of media) { + signals.push({ + tmdbId: m.tmdbId, + mediaType: m.mediaType, + title: '', + playCount: 1, + }); + } + + return signals; +} + +async function getUserFeedback(userId: number) { + return getRepository(UserFeedback) + .createQueryBuilder('feedback') + .where('feedback.userId = :userId', { userId }) + .getMany(); +} + +/** + * Resolve the titles the user has "liked" so the recommendation prompt can + * lean toward similar content. Likes are stored as (tmdbId, mediaType) only, + * so we look the names up via TMDB (capped to keep latency bounded). + */ +async function getLikedTitles( + feedback: UserFeedback[] +): Promise<{ title: string; mediaType: 'movie' | 'tv' }[]> { + const likes = feedback.filter((f) => f.feedbackType === 'like').slice(0, 10); + + if (likes.length === 0) return []; + + const tmdb = new TheMovieDb(); + const result: { title: string; mediaType: 'movie' | 'tv' }[] = []; + + for (const like of likes) { + try { + const m: any = + like.mediaType === MediaType.TV + ? await tmdb.getTvShow({ tvId: like.tmdbId }) + : await tmdb.getMovie({ movieId: like.tmdbId }); + const title = m.title || m.name; + if (title) { + result.push({ + title, + mediaType: like.mediaType === MediaType.TV ? 'tv' : 'movie', + }); + } + } catch { + // Skip unresolved titles rather than failing the whole generation. + } + } + + return result; +} + +async function discoverByKeywords( + keywords: string[], + filters: RecommendationFilters +) { + const tmdb = new TheMovieDb(); + const results: any[] = []; + + for (const keyword of keywords.slice(0, 3)) { + // Limit to 3 keywords + try { + const keywordResults = await tmdb.getDiscoverMovies({ + keywords: keyword, + voteAverageGte: String(filters.minRating || 7), + voteCountGte: '100', + }); + results.push(...keywordResults.results.slice(0, 5)); + } catch (error) { + logger.warn(`Failed to discover by keyword "${keyword}":`, error); + } + } + + return results; +} + +// TMDB genre IDs differ between the movie and TV discover endpoints (e.g. +// "Science Fiction" is 878 for movies but maps to "Sci-Fi & Fantasy" 10765 +// for TV). Map the human-readable genre names the LLM emits to the IDs each +// endpoint expects. Genres with no equivalent on one side are omitted there. +const GENRE_IDS: Record = { + action: { movie: 28, tv: 10759 }, + adventure: { movie: 12, tv: 10759 }, + animation: { movie: 16, tv: 16 }, + anime: { movie: 16, tv: 16 }, + comedy: { movie: 35, tv: 35 }, + crime: { movie: 80, tv: 80 }, + documentary: { movie: 99, tv: 99 }, + drama: { movie: 18, tv: 18 }, + family: { movie: 10751, tv: 10751 }, + fantasy: { movie: 14, tv: 10765 }, + history: { movie: 36 }, + horror: { movie: 27 }, + music: { movie: 10402 }, + musical: { movie: 10402 }, + mystery: { movie: 9648, tv: 9648 }, + reality: { tv: 10764 }, + romance: { movie: 10749 }, + 'science fiction': { movie: 878, tv: 10765 }, + 'sci-fi': { movie: 878, tv: 10765 }, + 'sci-fi & fantasy': { movie: 878, tv: 10765 }, + thriller: { movie: 53 }, + 'tv movie': { movie: 10770 }, + war: { movie: 10752, tv: 10768 }, + 'war & politics': { tv: 10768 }, + western: { movie: 37, tv: 37 }, +}; + +/** Resolve genre names to TMDB IDs for a given discover endpoint type. */ +function genreIdsFor( + names: string[] | undefined, + type: 'movie' | 'tv' +): number[] { + if (!names) return []; + const ids = new Set(); + for (const name of names) { + const id = GENRE_IDS[name.toLowerCase().trim()]?.[type]; + if (id) ids.add(id); + } + return [...ids]; +} + +async function discoverFromTmdb(params: any) { + const tmdb = new TheMovieDb(); + const results: any[] = []; + + try { + if ( + params.genres || + params.year_from || + params.year_to || + params.original_language || + params.sort_by || + params.min_rating + ) { + // Movie and TV discover take different genre IDs (see GENRE_IDS) and + // different date parameter names, so build params per type. + const buildParams = (type: 'movie' | 'tv') => { + const p: any = {}; + const genreIds = genreIdsFor(params.genres, type); + if (genreIds.length) p.genre = genreIds.join('|'); + if (params.year_from) { + const d = `${params.year_from}-01-01`; + if (type === 'movie') p.primaryReleaseDateGte = d; + else p.firstAirDateGte = d; + } + if (params.year_to) { + const d = `${params.year_to}-12-31`; + if (type === 'movie') p.primaryReleaseDateLte = d; + else p.firstAirDateLte = d; + } + if (params.min_rating) p.voteAverageGte = params.min_rating; + if (params.original_language) + p.originalLanguage = params.original_language; + if (params.sort_by) p.sortBy = params.sort_by; + return p; + }; + + const [movieResults, tvResults] = await Promise.all([ + tmdb.getDiscoverMovies(buildParams('movie')), + tmdb.getDiscoverTv(buildParams('tv')), + ]); + + results.push( + ...movieResults.results.map((r: any) => ({ + ...r, + media_type: 'movie', + })), + ...tvResults.results.map((r: any) => ({ ...r, media_type: 'tv' })) + ); + } + } catch (error) { + logger.warn('Failed to discover from TMDB:', error); + } + + return results; +} + +async function searchTmdbTitles( + titles: { title: string; year?: number; type: string; rationale?: string }[] +) { + const tmdb = new TheMovieDb(); + const results: any[] = []; + + for (const titleInfo of titles) { + try { + const searchResults = await tmdb.searchMulti({ query: titleInfo.title }); + const matched = searchResults.results.find( + (r: any) => + r.title?.toLowerCase() === titleInfo.title.toLowerCase() || + r.name?.toLowerCase() === titleInfo.title.toLowerCase() + ); + + if (matched) { + results.push({ + ...matched, + media_type: matched.media_type, + rationale: titleInfo.rationale, + }); + } + } catch (error) { + logger.warn(`Failed to search for title "${titleInfo.title}":`, error); + } + } + + return results; +} + +/** + * Resolve AI-generated recommendations to real TMDB entries by searching the + * title (and matching the year when available). Small LLMs cannot reliably + * produce correct tmdbId values, so we trust the title/year instead and look + * the ID up — the same approach SuggestArr and Recomendarr use. + * + * Returns only recommendations that resolved to a real TMDB entry. + */ +async function resolveRecommendationsViaTmdb( + recs: { + title: string; + year?: number; + type?: string; + rationale?: string; + tmdbId?: number; + }[] +): Promise { + const tmdb = new TheMovieDb(); + const resolved: any[] = []; + + for (const rec of recs) { + // Never trust a model-supplied tmdbId — small LLMs hallucinate IDs. + // Always resolve against TMDb by title (+year). + try { + const searchResults = await tmdb.searchMulti({ query: rec.title }); + if (!searchResults.results || searchResults.results.length === 0) { + continue; + } + + // Prefer results whose media_type matches and whose release year matches. + const candidates: any[] = searchResults.results.filter( + (r: any) => r.media_type === 'movie' || r.media_type === 'tv' + ); + if (candidates.length === 0) { + continue; + } + + const matched: any = + candidates.find((r: any) => { + const dateStr = r.release_date || r.first_air_date; + if (!dateStr || !rec.year) return false; + return new Date(dateStr).getFullYear() === rec.year; + }) || + candidates.find((r: any) => + rec.type + ? r.media_type === (rec.type === 'tv' ? 'tv' : 'movie') + : true + ) || + candidates[0]; + + const dateStr = matched.release_date || matched.first_air_date; + resolved.push({ + tmdbId: matched.id, + title: matched.title || matched.name || rec.title, + year: dateStr ? new Date(dateStr).getFullYear() : rec.year, + mediaType: matched.media_type === 'tv' ? 'tv' : 'movie', + rationale: rec.rationale, + }); + } catch (error) { + logger.warn( + `TMDB resolution failed for "${rec.title}": ${error.message}` + ); + } + } + + logger.info( + `TMDB resolution: ${resolved.length}/${recs.length} recommendations resolved to real entries` + ); + return resolved; +} + +function mergeAndScoreRecommendations( + aiRecs: any[], + tmdbRecs: any[], + profile: string, + keywords: string[] +) { + // Key by `${mediaType}-${tmdbId}`: TMDB IDs are only unique within a type, + // so a movie and a TV show sharing an ID must not collide. + const identityKey = (mediaType: string, tmdbId: number) => + `${mediaType}-${tmdbId}`; + const merged = new Map(); + + // Add AI recommendations with higher base score + for (const rec of aiRecs) { + if (!rec.tmdbId) continue; // skip unresolved entries + const mediaType = rec.mediaType || rec.type; + merged.set(identityKey(mediaType, rec.tmdbId), { + tmdbId: rec.tmdbId, + title: rec.title, + year: rec.year, + mediaType, + rationale: rec.rationale, + score: 0.8, // Higher score for AI recommendations + source: 'ai', + metadata: { + source: 'ai', + keywords, + generatedAt: new Date().toISOString(), + }, + }); + } + + // Add TMDB recommendations with lower base score + for (const rec of tmdbRecs) { + const id = rec.id || rec.tmdbId; + const mediaType = rec.media_type === 'tv' ? 'tv' : 'movie'; + const key = identityKey(mediaType, id); + if (!merged.has(key)) { + merged.set(key, { + tmdbId: id, + title: rec.title || rec.name, + year: rec.release_date + ? new Date(rec.release_date).getFullYear() + : rec.first_air_date + ? new Date(rec.first_air_date).getFullYear() + : undefined, + mediaType, + rationale: `Popular in genres related to your interests`, + score: 0.6, + source: 'tmdb', + metadata: { + source: 'tmdb', + keywords, + generatedAt: new Date().toISOString(), + }, + }); + } + } + + return Array.from(merged.values()); +} + +function mergeSearchResults(tmdbResults: any[], suggestedTitles: any[]) { + const identityKey = (item: any) => + `${item.media_type ?? item.mediaType ?? 'movie'}-${item.id ?? item.tmdbId}`; + const merged = new Map(); + + for (const result of tmdbResults) { + merged.set(identityKey(result), { + ...result, + matchScore: 0.7, + }); + } + + for (const title of suggestedTitles) { + const key = identityKey(title); + if (!merged.has(key)) { + merged.set(key, { + ...title, + matchScore: 0.9, + }); + } + } + + // Sort by relevance: LLM-suggested titles (0.9) above discover results (0.7). + return Array.from(merged.values()).sort( + (a, b) => (b.matchScore ?? 0) - (a.matchScore ?? 0) + ); +} + +async function filterExistingContent(recommendations: any[], userId: number) { + // Get existing media, requests, and feedback + const [existingMedia, existingRequests, feedback] = await Promise.all([ + getRepository(Media) + .createQueryBuilder('media') + .where('media.status IN (:...statuses)', { statuses: [4, 5] }) // PARTIALLY_AVAILABLE, AVAILABLE + .select(['media.tmdbId', 'media.mediaType']) + .getMany(), + getRepository(MediaRequest) + .createQueryBuilder('request') + .where('request.requestedById = :userId', { userId }) + .leftJoin('request.media', 'media') + .addSelect(['media.tmdbId', 'media.mediaType']) + .getMany(), + getRepository(UserFeedback) + .createQueryBuilder('feedback') + .where('feedback.userId = :userId', { userId }) + .andWhere('feedback.feedbackType IN (:...types)', { + types: ['dislike', 'seen'], + }) + .getMany(), + ]); + + // Key exclusions by `${tmdbId}-${mediaType}` so disliking a movie doesn't + // also exclude a TV show that happens to share its TMDB ID. + const excluded = new Set(); + + // Add existing media + for (const media of existingMedia) { + excluded.add(`${media.tmdbId}-${media.mediaType}`); + } + + // Add existing requests + for (const request of existingRequests) { + if (request.media) { + excluded.add(`${request.media.tmdbId}-${request.type}`); + } + } + + // Add disliked/seen content + for (const fb of feedback) { + excluded.add(`${fb.tmdbId}-${fb.mediaType}`); + } + + // Filter recommendations + return recommendations.filter( + (rec) => !excluded.has(`${rec.tmdbId}-${rec.mediaType}`) + ); +} + +/** + * Store recommendations using upsert semantics: + * - If a (userId, tmdbId, mediaType) row already exists, refresh its score, + * rationale, metadata, and updatedAt (this extends its TTL — a title that + * keeps getting recommended stays alive). + * - Otherwise insert a new row. + * + * This replaces the previous "delete all then insert" behaviour so that + * recommendations persist across runs and age out via TTL cleanup instead of + * vanishing on every refresh. + */ +async function storeRecommendations(recommendations: any[], userId: number) { + const repository = getRepository(AiRecommendation); + const now = new Date(); + + for (const rec of recommendations) { + const mediaType = rec.mediaType === 'tv' ? MediaType.TV : MediaType.MOVIE; + + const existing = await repository.findOne({ + where: { userId, tmdbId: rec.tmdbId, mediaType }, + }); + + if (existing) { + // Refresh: keep the original createdAt, bump updatedAt to extend TTL. + existing.score = rec.score || 0.7; + existing.rationale = rec.rationale || existing.rationale; + existing.metadata = rec.metadata ?? existing.metadata; + existing.updatedAt = now; + await repository.save(existing); + } else { + await repository.save( + repository.create({ + userId, + tmdbId: rec.tmdbId, + mediaType, + tvdbId: rec.tvdbId || null, + score: rec.score || 0.7, + rationale: rec.rationale || '', + metadata: rec.metadata, + createdAt: now, + updatedAt: now, + }) + ); + } + } +} + +/** + * Delete recommendations older than the configured TTL. A recommendation's age + * is measured from `updatedAt` (refreshed on every run that re-recommends it), + * so titles that keep matching the user's taste survive indefinitely while + * stale ones expire. + */ +export async function cleanupExpiredRecommendations(): Promise { + const settings = getSettings(); + const ttlDays = settings.ai.recommendations.ttlDays ?? 14; + + const cutoff = new Date(Date.now() - ttlDays * 24 * 60 * 60 * 1000); + const result = await getRepository(AiRecommendation) + .createQueryBuilder() + .delete() + .where('updatedAt < :cutoff', { cutoff }) + .execute(); + + return result.affected || 0; +} diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index 010c43d81d..306675e6af 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -354,6 +354,32 @@ interface JobSettings { schedule: string; } +export interface AiProviderSettings { + type: 'openai' | 'ollama' | 'openrouter' | 'custom'; + apiKey?: string; + baseUrl?: string; + model: string; +} + +export interface AiRecommendationSettings { + enabled: boolean; + sliderTitle: string; + maxResults: number; + minRating: number; + ttlDays: number; +} + +export interface AiSearchSettings { + enabled: boolean; +} + +export interface AiSettings { + enabled: boolean; + provider: AiProviderSettings; + recommendations: AiRecommendationSettings; + search: AiSearchSettings; +} + export type JobId = | 'plex-recently-added-scan' | 'plex-full-scan' @@ -367,7 +393,8 @@ export type JobId = | 'jellyfin-full-scan' | 'image-cache-cleanup' | 'availability-sync' - | 'process-blocklisted-tags'; + | 'process-blocklisted-tags' + | 'ai-recommendations-sync'; export interface AllSettings { clientId: string; @@ -385,6 +412,7 @@ export interface AllSettings { jobs: Record; network: NetworkSettings; metadataSettings: MetadataSettings; + ai: AiSettings; migrations: string[]; } @@ -454,6 +482,24 @@ class Settings { tv: MetadataProviderType.TMDB, anime: MetadataProviderType.TMDB, }, + ai: { + enabled: false, + provider: { + type: 'openai', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4o-mini', + }, + recommendations: { + enabled: false, + sliderTitle: 'Recommended for You', + maxResults: 20, + minRating: 7, + ttlDays: 14, + }, + search: { + enabled: false, + }, + }, radarr: [], sonarr: [], public: { @@ -606,6 +652,9 @@ class Settings { 'process-blocklisted-tags': { schedule: '0 30 1 */7 * *', }, + 'ai-recommendations-sync': { + schedule: '0 */6 * * *', + }, }, network: { csrfProtection: false, @@ -678,6 +727,14 @@ class Settings { ); } + get ai(): AiSettings { + return this.data.ai; + } + + set ai(data: AiSettings) { + this.data.ai = mergeSettings(this.data.ai, data); + } + get radarr(): RadarrSettings[] { return this.data.radarr; } diff --git a/server/migration/postgres/1784393737543-AddAiRecommendations.ts b/server/migration/postgres/1784393737543-AddAiRecommendations.ts new file mode 100644 index 0000000000..f34ef2965a --- /dev/null +++ b/server/migration/postgres/1784393737543-AddAiRecommendations.ts @@ -0,0 +1,48 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddAiRecommendations1784393737543 implements MigrationInterface { + name = 'AddAiRecommendations1784393737543'; + + public async up(queryRunner: QueryRunner): Promise { + // Create ai_recommendation table + await queryRunner.query( + `CREATE TABLE "ai_recommendation" ("id" SERIAL NOT NULL, "userId" integer NOT NULL, "tmdbId" integer NOT NULL, "mediaType" varchar NOT NULL, "tvdbId" integer, "score" double precision, "rationale" text, "metadata" jsonb, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_ai_recommendation" PRIMARY KEY ("id"), CONSTRAINT "FK_ai_recommendation_user" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `CREATE INDEX "IDX_AI_RECOMMENDATION_USER_TYPE" ON "ai_recommendation" ("userId", "mediaType")` + ); + await queryRunner.query( + `CREATE INDEX "IDX_AI_RECOMMENDATION_UPDATED" ON "ai_recommendation" ("updatedAt")` + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_AI_RECOMMENDATION_USER_TMDB_MEDIA" ON "ai_recommendation" ("userId", "tmdbId", "mediaType")` + ); + + // Create user_feedback table + await queryRunner.query( + `CREATE TABLE "user_feedback" ("id" SERIAL NOT NULL, "userId" integer NOT NULL, "tmdbId" integer NOT NULL, "mediaType" varchar NOT NULL, "feedbackType" varchar NOT NULL CHECK ("feedbackType" IN ('like', 'dislike', 'seen')), "createdAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_user_feedback" PRIMARY KEY ("id"), CONSTRAINT "FK_user_feedback_user" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_USER_FEEDBACK_USER_MEDIA" ON "user_feedback" ("userId", "tmdbId", "mediaType")` + ); + + // Add aiProviderConfig to user_settings + await queryRunner.query( + `ALTER TABLE "user_settings" ADD COLUMN "aiProviderConfig" jsonb` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_settings" DROP COLUMN "aiProviderConfig"` + ); + await queryRunner.query(`DROP INDEX "IDX_USER_FEEDBACK_USER_MEDIA"`); + await queryRunner.query(`DROP TABLE "user_feedback"`); + await queryRunner.query( + `DROP INDEX "IDX_AI_RECOMMENDATION_USER_TMDB_MEDIA"` + ); + await queryRunner.query(`DROP INDEX "IDX_AI_RECOMMENDATION_UPDATED"`); + await queryRunner.query(`DROP INDEX "IDX_AI_RECOMMENDATION_USER_TYPE"`); + await queryRunner.query(`DROP TABLE "ai_recommendation"`); + } +} diff --git a/server/migration/sqlite/1784393737543-AddAiRecommendations.ts b/server/migration/sqlite/1784393737543-AddAiRecommendations.ts new file mode 100644 index 0000000000..ace1383506 --- /dev/null +++ b/server/migration/sqlite/1784393737543-AddAiRecommendations.ts @@ -0,0 +1,48 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddAiRecommendations1784393737543 implements MigrationInterface { + name = 'AddAiRecommendations1784393737543'; + + public async up(queryRunner: QueryRunner): Promise { + // Create ai_recommendation table + await queryRunner.query( + `CREATE TABLE "ai_recommendation" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "userId" integer NOT NULL, "tmdbId" integer NOT NULL, "mediaType" varchar NOT NULL, "tvdbId" integer, "score" float, "rationale" text, "metadata" text, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP) NOT NULL, "updatedAt" datetime DEFAULT (CURRENT_TIMESTAMP) NOT NULL, CONSTRAINT "FK_ai_recommendation_user" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `CREATE INDEX "IDX_AI_RECOMMENDATION_USER_TYPE" ON "ai_recommendation" ("userId", "mediaType")` + ); + await queryRunner.query( + `CREATE INDEX "IDX_AI_RECOMMENDATION_UPDATED" ON "ai_recommendation" ("updatedAt")` + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_AI_RECOMMENDATION_USER_TMDB_MEDIA" ON "ai_recommendation" ("userId", "tmdbId", "mediaType")` + ); + + // Create user_feedback table + await queryRunner.query( + `CREATE TABLE "user_feedback" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "userId" integer NOT NULL, "tmdbId" integer NOT NULL, "mediaType" varchar NOT NULL, "feedbackType" varchar NOT NULL, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP) NOT NULL, CONSTRAINT "FK_user_feedback_user" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_USER_FEEDBACK_USER_MEDIA" ON "user_feedback" ("userId", "tmdbId", "mediaType")` + ); + + // Add aiProviderConfig to user_settings + await queryRunner.query( + `ALTER TABLE "user_settings" ADD COLUMN "aiProviderConfig" text` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_settings" DROP COLUMN "aiProviderConfig"` + ); + await queryRunner.query(`DROP INDEX "IDX_USER_FEEDBACK_USER_MEDIA"`); + await queryRunner.query(`DROP TABLE "user_feedback"`); + await queryRunner.query( + `DROP INDEX "IDX_AI_RECOMMENDATION_USER_TMDB_MEDIA"` + ); + await queryRunner.query(`DROP INDEX "IDX_AI_RECOMMENDATION_UPDATED"`); + await queryRunner.query(`DROP INDEX "IDX_AI_RECOMMENDATION_USER_TYPE"`); + await queryRunner.query(`DROP TABLE "ai_recommendation"`); + } +} diff --git a/server/routes/ai.ts b/server/routes/ai.ts new file mode 100644 index 0000000000..3bbbc41270 --- /dev/null +++ b/server/routes/ai.ts @@ -0,0 +1,427 @@ +import type { MediaType } from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import Media from '@server/entity/Media'; +import { UserFeedback } from '@server/entity/UserFeedback'; +import { + aiSearch, + generateRecommendations, +} from '@server/lib/aiRecommendations'; +import { Permission } from '@server/lib/permissions'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import { isAuthenticated } from '@server/middleware/auth'; +import { mapSearchResults } from '@server/models/Search'; +import { Router } from 'express'; +import { OpenAI } from 'openai'; +import { z } from 'zod'; + +const aiRoutes = Router(); + +// Administrative endpoints require an admin. Search, feedback, and +// regeneration stay at the mount's regular authenticated access. +aiRoutes.use('/settings', isAuthenticated(Permission.ADMIN)); +aiRoutes.use('/test', isAuthenticated(Permission.ADMIN)); + +// GET /api/v1/ai/settings - Get AI settings (admin only) +aiRoutes.get('/settings', (req, res, next) => { + try { + const settings = getSettings(); + + // Only expose non-sensitive settings. The API key is never returned; a + // hasApiKey flag tells the UI a key is stored so "Test Connection" can use + // it without the user re-entering it. + const publicAiSettings = { + enabled: settings.ai.enabled, + provider: { + type: settings.ai.provider.type, + baseUrl: settings.ai.provider.baseUrl, + model: settings.ai.provider.model, + hasApiKey: !!settings.ai.provider.apiKey, + }, + recommendations: { + enabled: settings.ai.recommendations.enabled, + sliderTitle: settings.ai.recommendations.sliderTitle, + maxResults: settings.ai.recommendations.maxResults, + minRating: settings.ai.recommendations.minRating, + ttlDays: settings.ai.recommendations.ttlDays, + }, + search: { + enabled: settings.ai.search.enabled, + }, + }; + + res.json(publicAiSettings); + } catch (error) { + logger.error('Get AI settings error:', error); + next(error); + } +}); + +// PUT /api/v1/ai/settings - Update AI settings (admin only) +aiRoutes.put('/settings', async (req, res, next) => { + try { + const settings = getSettings(); + + // Update settings with request body + if (req.body.enabled !== undefined) settings.ai.enabled = req.body.enabled; + if (req.body.provider) { + // If the destination (provider type or base URL) changes, the previously + // saved key was issued for a different provider and must not be reused. + const destinationChanged = + (req.body.provider.type && + req.body.provider.type !== settings.ai.provider.type) || + Boolean( + req.body.provider.baseUrl && + req.body.provider.baseUrl !== settings.ai.provider.baseUrl + ); + + if (req.body.provider.type) + settings.ai.provider.type = req.body.provider.type; + if (req.body.provider.baseUrl) + settings.ai.provider.baseUrl = req.body.provider.baseUrl; + if (req.body.provider.model) + settings.ai.provider.model = req.body.provider.model; + if (req.body.provider.apiKey) { + settings.ai.provider.apiKey = req.body.provider.apiKey; + } else if (destinationChanged) { + settings.ai.provider.apiKey = ''; + } + } + if (req.body.recommendations) { + if (req.body.recommendations.enabled !== undefined) + settings.ai.recommendations.enabled = req.body.recommendations.enabled; + if (req.body.recommendations.sliderTitle) + settings.ai.recommendations.sliderTitle = + req.body.recommendations.sliderTitle; + if (req.body.recommendations.maxResults) + settings.ai.recommendations.maxResults = + req.body.recommendations.maxResults; + if (req.body.recommendations.minRating !== undefined) + settings.ai.recommendations.minRating = + req.body.recommendations.minRating; + if (req.body.recommendations.ttlDays !== undefined) + settings.ai.recommendations.ttlDays = req.body.recommendations.ttlDays; + } + if (req.body.search) { + if (req.body.search.enabled !== undefined) + settings.ai.search.enabled = req.body.search.enabled; + } + + await settings.save(); + + res.json({ + message: 'Settings updated successfully', + settings: { + enabled: settings.ai.enabled, + provider: { + type: settings.ai.provider.type, + baseUrl: settings.ai.provider.baseUrl, + model: settings.ai.provider.model, + }, + recommendations: { + enabled: settings.ai.recommendations.enabled, + sliderTitle: settings.ai.recommendations.sliderTitle, + maxResults: settings.ai.recommendations.maxResults, + minRating: settings.ai.recommendations.minRating, + ttlDays: settings.ai.recommendations.ttlDays, + }, + search: { + enabled: settings.ai.search.enabled, + }, + }, + }); + } catch (error) { + logger.error('Update AI settings error:', error); + next(error); + } +}); + +// POST /api/v1/ai/test - Test AI provider connection +aiRoutes.post('/test', async (req, res, next) => { + try { + const { provider } = req.body; + + if (!provider) { + return res + .status(400) + .json({ message: 'Provider configuration required' }); + } + + // Build the client from the submitted form values. The API key is not + // returned by GET /settings (security), so when the form omits it we fall + // back to the stored key — but only when testing the same destination it + // was saved for. A key saved for OpenAI must not be tried against Ollama, + // etc. A newly typed key always takes precedence. + const storedSettings = getSettings(); + const sameDestination = + (!provider.type || provider.type === storedSettings.ai.provider.type) && + (!provider.baseUrl || + provider.baseUrl === storedSettings.ai.provider.baseUrl); + const apiKey = + provider.apiKey || + (sameDestination ? storedSettings.ai.provider.apiKey : ''); + const client = new OpenAI({ + // The SDK requires a non-empty key; providers that don't need one + // (e.g. Ollama) ignore it. + apiKey: apiKey || 'sk-not-required', + baseURL: + provider.baseUrl || + storedSettings.ai.provider.baseUrl || + 'https://api.openai.com/v1', + }); + const model = + provider.model || storedSettings.ai.provider.model || 'gpt-4o-mini'; + + const startTime = Date.now(); + + try { + // A connection test only needs to verify: provider reachable, auth valid, + // model exists. Any well-formed completion back = success. We deliberately + // do NOT require a specific word in the reply, and keep max_tokens modest + // but not tiny (reasoning models emit thinking tokens that count toward + // the limit). Reasoning models may put output in `reasoning_content` + // instead of `content`, so accept either. + const response = await client.chat.completions.create({ + model, + messages: [{ role: 'user', content: 'Reply with the single word: ok' }], + max_tokens: 50, + }); + + const message = response.choices[0]?.message as any; + const content = message?.content ?? message?.reasoning_content ?? ''; + const success = typeof content === 'string' && content.length > 0; + const latency = Date.now() - startTime; + + res.json({ + success, + latency, + error: success ? undefined : 'Connection test failed (empty response)', + modelEcho: response.model, + responsePreview: content ? String(content).slice(0, 80) : undefined, + }); + } catch (error) { + res.json({ + success: false, + error: error.message, + }); + } + } catch (error) { + logger.error('Test AI connection error:', error); + next(error); + } +}); + +// POST /api/v1/ai/search - AI search endpoint +aiRoutes.post('/search', async (req, res, next) => { + try { + const user = req.user?.id; + if (!user) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + const { query, options } = req.body; + + if (!query || typeof query !== 'string') { + return res.status(400).json({ message: 'Query is required' }); + } + + const { results: rawResults, interpretation } = await aiSearch( + user, + query, + options + ); + + // Map raw TMDB results to the same shape as the regular search endpoint + // (camelCase + media status) so the frontend can reuse TitleCard/ListView. + const media = await Media.getRelatedMedia( + req.user, + rawResults.map((r: any) => ({ + tmdbId: r.id, + mediaType: r.media_type, + })) + ); + + const results = mapSearchResults(rawResults as any, media); + + res.json({ + page: 1, + totalPages: 1, + totalResults: results.length, + results, + query, + interpretation, + }); + } catch (error) { + logger.error('AI search error:', error); + next(error); + } +}); + +// POST /api/v1/ai/feedback - Submit user feedback +const feedbackSchema = z.object({ + tmdbId: z.number(), + mediaType: z.enum(['movie', 'tv']), + feedbackType: z.enum(['like', 'dislike', 'seen']), +}); + +aiRoutes.post('/feedback', async (req, res, next) => { + try { + const user = req.user?.id; + if (!user) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + const { tmdbId, mediaType, feedbackType } = feedbackSchema.parse(req.body); + const mediaTypeEnum = mediaType as MediaType; + + const feedbackRepository = getRepository(UserFeedback); + + // Check if feedback already exists + const existing = await feedbackRepository.findOne({ + where: { + userId: user, + tmdbId, + mediaType: mediaTypeEnum, + }, + }); + + if (existing) { + // Update existing feedback + existing.feedbackType = feedbackType; + existing.createdAt = new Date(); + await feedbackRepository.save(existing); + } else { + // Create new feedback + const feedback = feedbackRepository.create({ + userId: user, + tmdbId, + mediaType: mediaTypeEnum, + feedbackType, + createdAt: new Date(), + }); + await feedbackRepository.save(feedback); + } + + res.json({ + success: true, + message: 'Feedback submitted successfully', + }); + } catch (error) { + logger.error('Submit feedback error:', error); + next(error); + } +}); + +// GET /api/v1/ai/feedback/stats - Get user feedback statistics +aiRoutes.get('/feedback/stats', async (req, res, next) => { + try { + const user = req.user?.id; + if (!user) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + const feedbackRepository = getRepository(UserFeedback); + + const [likeCount, dislikeCount, seenCount, recentFeedback] = + await Promise.all([ + feedbackRepository.count({ + where: { userId: user, feedbackType: 'like' }, + }), + feedbackRepository.count({ + where: { userId: user, feedbackType: 'dislike' }, + }), + feedbackRepository.count({ + where: { userId: user, feedbackType: 'seen' }, + }), + feedbackRepository.find({ + where: { userId: user }, + order: { createdAt: 'DESC' }, + take: 10, + }), + ]); + + res.json({ + likeCount, + dislikeCount, + seenCount, + recentFeedback: recentFeedback.map((fb) => ({ + tmdbId: fb.tmdbId, + mediaType: fb.mediaType, + feedbackType: fb.feedbackType, + createdAt: fb.createdAt, + })), + }); + } catch (error) { + logger.error('Get feedback stats error:', error); + next(error); + } +}); + +// DELETE /api/v1/ai/feedback/:tmdbId - Delete user feedback +aiRoutes.delete('/feedback/:tmdbId', async (req, res, next) => { + try { + const user = req.user?.id; + if (!user) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + const tmdbId = parseInt(req.params.tmdbId); + const mediaType = req.query.mediaType as string; + + if (!mediaType || !['movie', 'tv'].includes(mediaType)) { + return res.status(400).json({ message: 'Invalid mediaType' }); + } + + const feedbackRepository = getRepository(UserFeedback); + + const result = await feedbackRepository.delete({ + userId: user, + tmdbId, + mediaType: mediaType as MediaType, + }); + + if (result.affected === 0) { + return res.status(404).json({ message: 'Feedback not found' }); + } + + res.json({ + success: true, + message: 'Feedback deleted successfully', + }); + } catch (error) { + logger.error('Delete feedback error:', error); + next(error); + } +}); + +// POST /api/v1/ai/regenerate - Manually trigger recommendation regeneration +aiRoutes.post('/regenerate', async (req, res, next) => { + try { + const user = req.user?.id; + if (!user) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + const settings = getSettings(); + + if (!settings.ai.enabled || !settings.ai.recommendations.enabled) { + return res + .status(400) + .json({ message: 'AI recommendations are disabled' }); + } + + // Trigger recommendation generation + const recommendations = await generateRecommendations(user); + + res.json({ + success: true, + message: 'Recommendations regenerated successfully', + count: recommendations.length, + }); + } catch (error) { + logger.error('Regenerate recommendations error:', error); + next(error); + } +}); + +export default aiRoutes; diff --git a/server/routes/discover.ts b/server/routes/discover.ts index 7f250e6a6e..d4fc13d93e 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -4,6 +4,7 @@ import TheMovieDb from '@server/api/themoviedb'; import type { TmdbKeyword } from '@server/api/themoviedb/interfaces'; import { MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; +import { AiRecommendation } from '@server/entity/AiRecommendation'; import Media from '@server/entity/Media'; import { User } from '@server/entity/User'; import { Watchlist } from '@server/entity/Watchlist'; @@ -982,4 +983,93 @@ discoverRoutes.get, WatchlistResponse>( } ); +// AI Recommendations endpoint +discoverRoutes.get('/ai-recommendations', async (req, res, next) => { + try { + const user = req.user?.id; + if (!user) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + const settings = getSettings(); + + if (!settings.ai.enabled || !settings.ai.recommendations.enabled) { + // Return an empty (but valid) paged response when the feature is off, + // so the slider/list renders an empty state instead of erroring. + return res.json({ + page: 1, + totalPages: 1, + totalResults: 0, + results: [], + }); + } + + // Read stored recommendations from the database (generated by the + // scheduled job). We do NOT regenerate here — that would call the LLM on + // every page load. + // + // Display is decoupled from generation: `maxResults` controls only how + // many the LLM generates per run, while here we show the full pool of + // active recommendations (ordered by score), capped at a generous safety + // limit. Recommendations age out via TTL cleanup rather than being hidden. + const recommendationRepository = getRepository(AiRecommendation); + const stored = await recommendationRepository.find({ + where: { userId: user }, + order: { score: 'DESC' }, + take: 50, + }); + + // Fetch full TMDB details for each stored recommendation, in small batches + // to avoid hammering TMDB or exhausting connections on a large pool. + const tmdb = createTmdbWithRegionLanguage(req.user); + const fetchDetail = async ( + rec: AiRecommendation + ): Promise | null> => { + try { + const mediaInfo = + rec.mediaType === MediaType.TV + ? await tmdb.getTvShow({ tvId: rec.tmdbId }) + : await tmdb.getMovie({ movieId: rec.tmdbId }); + return { + id: rec.tmdbId, + mediaType: rec.mediaType === MediaType.TV ? 'tv' : 'movie', + title: (mediaInfo as any).title || (mediaInfo as any).name, + posterPath: (mediaInfo as any).poster_path, + backdropPath: (mediaInfo as any).backdrop_path, + overview: (mediaInfo as any).overview, + releaseDate: + (mediaInfo as any).release_date || + (mediaInfo as any).first_air_date, + voteAverage: (mediaInfo as any).vote_average, + // AI-specific fields + aiRationale: rec.rationale, + aiScore: rec.score, + aiMetadata: rec.metadata, + }; + } catch (error) { + logger.warn(`Failed to fetch details for tmdbId ${rec.tmdbId}:`, error); + return null; + } + }; + const detailedResults: (Record | null)[] = []; + const DETAIL_BATCH_SIZE = 5; + for (let i = 0; i < stored.length; i += DETAIL_BATCH_SIZE) { + const batch = stored.slice(i, i + DETAIL_BATCH_SIZE); + detailedResults.push(...(await Promise.all(batch.map(fetchDetail)))); + } + + const validResults = detailedResults.filter((r) => r !== null); + + res.json({ + page: 1, + totalPages: 1, + totalResults: validResults.length, + results: validResults, + }); + } catch (error) { + logger.error('AI recommendations endpoint error:', error); + next(error); + } +}); + export default discoverRoutes; diff --git a/server/routes/index.ts b/server/routes/index.ts index f701acf968..bbaf52a8ee 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -28,6 +28,7 @@ import { getAppVersion, getCommitTag } from '@server/utils/appVersion'; import restartFlag from '@server/utils/restartFlag'; import { isPerson } from '@server/utils/typeHelpers'; import { Router } from 'express'; +import aiRoutes from './ai'; import authRoutes from './auth'; import blocklistRoutes from './blocklist'; import collectionRoutes from './collection'; @@ -148,6 +149,7 @@ router.get( } ); router.use('/settings', isAuthenticated(Permission.ADMIN), settingsRoutes); +router.use('/ai', isAuthenticated(), aiRoutes); router.use('/search', isAuthenticated(), searchRoutes); router.use('/discover', isAuthenticated(), discoverRoutes); router.use('/request', isAuthenticated(), requestRoutes); diff --git a/src/components/Discover/AiRecommendationCard/index.tsx b/src/components/Discover/AiRecommendationCard/index.tsx new file mode 100644 index 0000000000..1975fa432c --- /dev/null +++ b/src/components/Discover/AiRecommendationCard/index.tsx @@ -0,0 +1,162 @@ +import Button from '@app/components/Common/Button'; +import Tooltip from '@app/components/Common/Tooltip'; +import TitleCard from '@app/components/TitleCard'; +import useAiFeedback, { type FeedbackType } from '@app/hooks/useAiFeedback'; +import useToasts from '@app/hooks/useToasts'; +import defineMessages from '@app/utils/defineMessages'; +import { + CheckIcon, + EyeIcon, + HandThumbDownIcon, + HandThumbUpIcon, + SparklesIcon, +} from '@heroicons/react/24/solid'; +import { MediaType } from '@server/constants/media'; +import type { MovieResult, TvResult } from '@server/models/Search'; +import { useState } from 'react'; +import { useIntl } from 'react-intl'; + +const messages = defineMessages('components.Discover.AiRecommendationCard', { + like: 'More like this', + dislike: 'Not interested', + seen: 'Already watched', + why: 'Why this was recommended', + feedbackError: 'Could not save feedback. Please try again.', +}); + +interface AiRecommendationCardProps { + item: MovieResult | TvResult; + rationale?: string | null; + onRemove?: (tmdbId: number) => void; +} + +/** + * Wraps a standard TitleCard and overlays small feedback buttons in the + * TitleCard's own visual language (ghost, sm icon buttons that reveal on + * hover) — the same pattern the blocklist/watchlist buttons use inside + * TitleCard. Keeps the card clean instead of a separate button row. + */ +const AiRecommendationCard = ({ + item, + rationale, + onRemove, +}: AiRecommendationCardProps) => { + const intl = useIntl(); + const { addToast } = useToasts(); + const { submit, remove } = useAiFeedback(); + const [active, setActive] = useState(null); + + // Rationale button placement — flip to true to show the ✨ button. + const SHOW_RATIONALE_BUTTON = false; + + const mediaType = item.mediaType === 'tv' ? MediaType.TV : MediaType.MOVIE; + + const handleClick = async (type: FeedbackType) => { + const previous = active; + setActive(type); + + try { + if (previous === type) { + await remove(item.id, mediaType); + setActive(null); + return; + } + await submit(item.id, mediaType, type); + // Dislike / seen remove the card from the current view since it won't be + // recommended again; like just marks it. + if ((type === 'dislike' || type === 'seen') && onRemove) { + onRemove(item.id); + } + } catch { + setActive(previous); + addToast(intl.formatMessage(messages.feedbackError), { + appearance: 'error', + autoDismiss: true, + }); + } + }; + + const iconBtn = ( + type: FeedbackType, + Icon: typeof HandThumbUpIcon, + label: string, + activeClass: string + ) => ( + + + + ); + + return ( +
+ 0} + canExpand + /> + {/* Feedback row — 👍/👁️/👎 just below the top MOVIE/blocklist row, + reveals on hover. */} +
+
+ {iconBtn( + 'like', + HandThumbUpIcon, + intl.formatMessage(messages.like), + '!bg-green-600 !text-white' + )} + {iconBtn( + 'seen', + EyeIcon, + intl.formatMessage(messages.seen), + '!bg-blue-600 !text-white' + )} + {iconBtn( + 'dislike', + HandThumbDownIcon, + intl.formatMessage(messages.dislike), + '!bg-red-600 !text-white' + )} +
+ {SHOW_RATIONALE_BUTTON && rationale && ( +
+ + + {intl.formatMessage(messages.why)} + +
{rationale}
+ + } + > + +
+
+ )} +
+
+ ); +}; + +export default AiRecommendationCard; diff --git a/src/components/Discover/AiRecommendations.tsx b/src/components/Discover/AiRecommendations.tsx new file mode 100644 index 0000000000..449fe74442 --- /dev/null +++ b/src/components/Discover/AiRecommendations.tsx @@ -0,0 +1,76 @@ +import Header from '@app/components/Common/Header'; +import LoadingSpinner from '@app/components/Common/LoadingSpinner'; +import PageTitle from '@app/components/Common/PageTitle'; +import AiRecommendationCard from '@app/components/Discover/AiRecommendationCard'; +import useDiscover from '@app/hooks/useDiscover'; +import defineMessages from '@app/utils/defineMessages'; +import type { MovieResult, TvResult } from '@server/models/Search'; +import { useMemo, useState } from 'react'; +import { useIntl } from 'react-intl'; + +const messages = defineMessages('components.Discover', { + airecommendations: 'Recommended for You', + airecommendationsDescription: + 'Personalized picks generated by AI based on your request history, watchlist, and library. Rate them to improve future suggestions.', + empty: + 'No AI recommendations yet. Recommendations are generated periodically — check back soon, or trigger them from Settings → Jobs & Cache.', +}); + +const AiRecommendations = () => { + const intl = useIntl(); + const { isLoadingInitialData, isEmpty, titles } = useDiscover< + MovieResult | TvResult + >('/api/v1/discover/ai-recommendations'); + + // Track locally-dismissed cards (dislike/seen) so they vanish immediately + // without waiting for the next generation run to filter them out. + const [removed, setRemoved] = useState>(new Set()); + + const visible = useMemo( + () => (titles ?? []).filter((t) => !removed.has(t.id)), + [titles, removed] + ); + + const handleRemove = (tmdbId: number) => { + setRemoved((prev) => { + const next = new Set(prev); + next.add(tmdbId); + return next; + }); + }; + + const showEmpty = isEmpty || (!isLoadingInitialData && visible.length === 0); + + return ( + <> + +
+
{intl.formatMessage(messages.airecommendations)}
+

+ {intl.formatMessage(messages.airecommendationsDescription)} +

+
+ {isLoadingInitialData && } + {showEmpty && !isLoadingInitialData && ( +
+ {intl.formatMessage(messages.empty)} +
+ )} + {visible.length > 0 && ( +
    + {visible.map((item) => ( +
  • + +
  • + ))} +
+ )} + + ); +}; + +export default AiRecommendations; diff --git a/src/components/Discover/DiscoverSliderEdit/index.tsx b/src/components/Discover/DiscoverSliderEdit/index.tsx index c24a0591ad..79d52db85b 100644 --- a/src/components/Discover/DiscoverSliderEdit/index.tsx +++ b/src/components/Discover/DiscoverSliderEdit/index.tsx @@ -169,6 +169,8 @@ const DiscoverSliderEdit = ({ return intl.formatMessage(sliderTitles.tmdbmoviestreamingservices); case DiscoverSliderType.TMDB_TV_STREAMING_SERVICES: return intl.formatMessage(sliderTitles.tmdbtvstreamingservices); + case DiscoverSliderType.AI_RECOMMENDATIONS: + return intl.formatMessage(sliderTitles.airecommendations); default: return 'Unknown Slider'; } diff --git a/src/components/Discover/constants.ts b/src/components/Discover/constants.ts index 4ce5e34f66..0f3b61bb18 100644 --- a/src/components/Discover/constants.ts +++ b/src/components/Discover/constants.ts @@ -88,6 +88,7 @@ export const sliderTitles = defineMessages('components.Discover', { tmdbsearch: 'TMDB Search', tmdbmoviestreamingservices: 'TMDB Movie Streaming Services', tmdbtvstreamingservices: 'TMDB TV Streaming Services', + airecommendations: 'Recommended for You', }); export const QueryFilterOptions = z.object({ diff --git a/src/components/Discover/index.tsx b/src/components/Discover/index.tsx index 5638d6fb34..12f0863b7c 100644 --- a/src/components/Discover/index.tsx +++ b/src/components/Discover/index.tsx @@ -396,6 +396,19 @@ const Discover = () => { /> ); break; + case DiscoverSliderType.AI_RECOMMENDATIONS: + sliderComponent = ( + + ); + break; } if (isEditing) { diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index e5a54180bb..0eda902299 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1,61 +1,162 @@ +import Button from '@app/components/Common/Button'; import Header from '@app/components/Common/Header'; import ListView from '@app/components/Common/ListView'; +import LoadingSpinner from '@app/components/Common/LoadingSpinner'; import PageTitle from '@app/components/Common/PageTitle'; +import useAiSearch, { + type AiSearchInterpretation, +} from '@app/hooks/useAiSearch'; import useDiscover from '@app/hooks/useDiscover'; -import ErrorPage from '@app/pages/_error'; import defineMessages from '@app/utils/defineMessages'; +import { SparklesIcon } from '@heroicons/react/24/solid'; import type { MovieResult, PersonResult, TvResult, } from '@server/models/Search'; +import ErrorPage from 'next/error'; import { useRouter } from 'next/router'; +import { useState } from 'react'; import { useIntl } from 'react-intl'; const messages = defineMessages('components.Search', { search: 'Search', searchresults: 'Search Results', + aiSearch: 'AI Search', + aiSearchTip: + 'Describe what you want to watch in natural language (e.g. "90s psychological thrillers")', + aiInterpretation: 'AI interpretation', + aiThinking: 'Asking the AI…', + aiDisabled: 'AI search is disabled. Enable it in Settings → AI Settings.', + aiError: + 'AI search failed. It may be disabled, or the AI provider is unreachable.', + aiNoResults: 'No AI results for this query. Try rephrasing.', + interpGenres: 'Genres', + interpYears: 'Years', + interpLanguage: 'Language', + interpMinRating: 'Min Rating', + interpKeywords: 'Keywords', }); +// Build a human-readable summary of how the AI parsed the query. +const formatInterpretation = ( + interp: AiSearchInterpretation | undefined, + labels: { + genres: string; + years: string; + language: string; + minRating: string; + keywords: string; + } +): string | null => { + if (!interp?.discoverParams) return null; + const p = interp.discoverParams; + const parts: string[] = []; + if (p.genres?.length) parts.push(`${labels.genres}: ${p.genres.join(', ')}`); + if (p.year_from || p.year_to) { + parts.push(`${labels.years}: ${p.year_from ?? '…'}–${p.year_to ?? '…'}`); + } + if (p.original_language) + parts.push(`${labels.language}: ${p.original_language}`); + if (p.min_rating) parts.push(`${labels.minRating}: ${p.min_rating}`); + if (p.keywords?.length) + parts.push(`${labels.keywords}: ${p.keywords.join(', ')}`); + return parts.length ? parts.join(' · ') : null; +}; + const Search = () => { const intl = useIntl(); const router = useRouter(); + const query = (router.query.query as string) ?? ''; + const [aiMode, setAiMode] = useState(false); - const { - isLoadingInitialData, - isEmpty, - isLoadingMore, - isReachingEnd, - titles, - fetchMore, - error, - } = useDiscover( - `/api/v1/search`, - { - query: router.query.query, - }, + // Regular keyword search (always wired up so toggling back is instant). + const regular = useDiscover( + '/api/v1/search', + { query }, { hideAvailable: false, hideBlocklisted: false } ); - if (error) { - return ; - } + // AI natural-language search (only fetches when AI mode is on). + const ai = useAiSearch(aiMode && query.length > 0 ? query : null); + + const interpretation = formatInterpretation(ai.data?.interpretation, { + genres: intl.formatMessage(messages.interpGenres), + years: intl.formatMessage(messages.interpYears), + language: intl.formatMessage(messages.interpLanguage), + minRating: intl.formatMessage(messages.interpMinRating), + keywords: intl.formatMessage(messages.interpKeywords), + }); return ( <> -
+
{intl.formatMessage(messages.searchresults)}
+
+ +
- 0) - } - isReachingEnd={isReachingEnd} - onScrollBottom={fetchMore} - /> + + {aiMode ? ( + <> + {ai.isLoading && ( +
+ + {intl.formatMessage(messages.aiThinking)} +
+ )} + {ai.error && !ai.isLoading && ( +
+ {intl.formatMessage(messages.aiError)} +
+ )} + {ai.data && ( + <> + {interpretation && ( +
+ + {intl.formatMessage(messages.aiInterpretation)}: + {' '} + {interpretation} +
+ )} + {}} + /> + {ai.data.results.length === 0 && ( +
+ {intl.formatMessage(messages.aiNoResults)} +
+ )} + + )} + + ) : regular.error ? ( + + ) : ( + 0) + } + isReachingEnd={regular.isReachingEnd} + onScrollBottom={regular.fetchMore} + /> + )} ); }; diff --git a/src/components/Settings/SettingsAi/index.tsx b/src/components/Settings/SettingsAi/index.tsx new file mode 100644 index 0000000000..f29eceb2e2 --- /dev/null +++ b/src/components/Settings/SettingsAi/index.tsx @@ -0,0 +1,535 @@ +import Button from '@app/components/Common/Button'; +import PageTitle from '@app/components/Common/PageTitle'; +import SensitiveInput from '@app/components/Common/SensitiveInput'; +import useToasts from '@app/hooks/useToasts'; +import globalMessages from '@app/i18n/globalMessages'; +import defineMessages from '@app/utils/defineMessages'; +import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline'; +import { ArrowPathIcon } from '@heroicons/react/24/solid'; +import axios from 'axios'; +import { Form, Formik } from 'formik'; +import { useIntl } from 'react-intl'; +import useSWR from 'swr'; +import * as Yup from 'yup'; + +interface AiSettings { + enabled: boolean; + provider: { + type: 'openai' | 'ollama' | 'openrouter' | 'custom'; + apiKey?: string; + hasApiKey?: boolean; + baseUrl?: string; + model: string; + }; + recommendations: { + enabled: boolean; + sliderTitle: string; + maxResults: number; + minRating: number; + ttlDays: number; + }; + search: { + enabled: boolean; + }; +} + +const messages = defineMessages('components.Settings.SettingsAi', { + aiSettings: 'AI Settings', + aiSettingsDescription: + 'Configure AI-powered recommendations and search features.', + loading: 'Loading…', + providerConfiguration: 'Provider Configuration', + enabled: 'Enable AI Features', + enabledTip: 'Enable AI-powered recommendations and search', + providerType: 'AI Provider', + providerTypeTip: + 'Choose your AI provider (OpenAI, Ollama, OpenRouter, or custom)', + apiKey: 'API Key', + apiKeyTip: 'API key for your chosen provider (not required for Ollama)', + apiKeySet: 'An API key is saved — leave blank to keep the current one', + baseUrl: 'Base URL', + baseUrlTip: 'Base URL for your AI provider API', + model: 'Model', + modelTip: 'AI model to use for recommendations (e.g., gpt-4o-mini, mistral)', + testConnection: 'Test Connection', + testing: 'Testing…', + connectionSuccess: 'Connection test successful!', + connectionFailure: 'Connection test failed!', + recommendations: 'Recommendations', + recommendationsEnabled: 'Enable Recommendations', + recommendationsEnabledTip: 'Enable AI-powered personalized recommendations', + sliderTitle: 'Slider Title', + sliderTitleTip: 'Title for the recommendations slider on the discover page', + maxResults: 'Max Results', + maxResultsTip: 'Maximum number of recommendations to generate per user', + minRating: 'Minimum Rating', + minRatingTip: + 'Minimum TMDB rating (0.0–10.0) a recommendation must meet; higher values surface better-rated titles', + ttlDays: 'Recommendation TTL (days)', + ttlDaysTip: + 'How long a recommendation lives. Re-recommended titles stay alive; stale ones expire after this many days', + validationTtlDays: 'TTL must be between 1 and 365 days', + search: 'AI Search', + searchEnabled: 'Enable AI Search', + searchEnabledTip: 'Enable natural language search using AI', + toastSettingsSuccess: 'AI settings saved successfully!', + toastSettingsFailure: 'Something went wrong while saving AI settings.', + validationModelRequired: 'You must provide a model name', + validationMaxResults: 'Max results must be between 1 and 50', + validationMinRating: 'Minimum rating must be between 0 and 10', + testingConnection: 'Testing AI connection…', + connectionTestSuccess: 'Connection successful! Latency: {latency}ms', + connectionTestFailure: 'Connection failed: {error}', +}); + +const SettingsAi = () => { + const { addToast } = useToasts(); + const intl = useIntl(); + + const { data, mutate } = useSWR('/api/v1/ai/settings'); + + const AiSettingsSchema = Yup.object().shape({ + provider: Yup.object().shape({ + model: Yup.string().required( + intl.formatMessage(messages.validationModelRequired) + ), + }), + recommendations: Yup.object().shape({ + maxResults: Yup.number() + .min(1, intl.formatMessage(messages.validationMaxResults)) + .max(50, intl.formatMessage(messages.validationMaxResults)) + .required(), + minRating: Yup.number() + .min(0, intl.formatMessage(messages.validationMinRating)) + .max(10, intl.formatMessage(messages.validationMinRating)) + .required(), + ttlDays: Yup.number() + .min(1, intl.formatMessage(messages.validationTtlDays)) + .max(365, intl.formatMessage(messages.validationTtlDays)) + .required(), + }), + }); + + const testConnection = async (provider: AiSettings['provider']) => { + addToast(intl.formatMessage(messages.testingConnection), { + appearance: 'info', + autoDismiss: true, + }); + + try { + const response = await axios.post('/api/v1/ai/test', { provider }); + + if (response.data.success) { + addToast( + intl.formatMessage(messages.connectionTestSuccess, { + latency: response.data.latency, + }), + { appearance: 'success', autoDismiss: true } + ); + } else { + addToast( + intl.formatMessage(messages.connectionTestFailure, { + error: response.data.error, + }), + { appearance: 'error', autoDismiss: true } + ); + } + } catch { + addToast(intl.formatMessage(messages.connectionFailure), { + appearance: 'error', + autoDismiss: true, + }); + } + }; + + if (!data) { + return ( +
+ {intl.formatMessage(messages.loading)} +
+ ); + } + + return ( +
+ + + { + try { + await axios.put('/api/v1/ai/settings', values); + mutate(); + addToast(intl.formatMessage(messages.toastSettingsSuccess), { + appearance: 'success', + autoDismiss: true, + }); + } catch { + addToast(intl.formatMessage(messages.toastSettingsFailure), { + appearance: 'error', + autoDismiss: true, + }); + } finally { + setSubmitting(false); + } + }} + > + {({ values, errors, touched, isSubmitting, handleChange }) => ( +
+
+
+
+

+ {intl.formatMessage(messages.aiSettings)} +

+

+ {intl.formatMessage(messages.aiSettingsDescription)} +

+
+
+
+ +
+ {/* Enable AI Features */} +
+
+
+ +
+ +
+
+ + {/* Provider Configuration */} +
+

+ {intl.formatMessage(messages.providerConfiguration)} +

+ +
+
+ + +

+ {intl.formatMessage(messages.providerTypeTip)} +

+
+ +
+ + +

+ {intl.formatMessage(messages.baseUrlTip)} +

+
+ +
+ + +

+ {intl.formatMessage(messages.modelTip)} +

+ {errors.provider?.model && touched.provider?.model && ( +
+ {errors.provider.model} +
+ )} +
+ + {values.provider.type !== 'ollama' && ( +
+ + +

+ {intl.formatMessage(messages.apiKeyTip)} +

+ {data?.provider?.hasApiKey && + !values.provider.apiKey && + values.provider.type === data.provider.type && + (values.provider.baseUrl ?? '') === + (data.provider.baseUrl ?? '') && ( +

+ {intl.formatMessage(messages.apiKeySet)} +

+ )} +
+ )} + + +
+
+ + {/* Recommendations Settings */} +
+

+ {intl.formatMessage(messages.recommendations)} +

+ +
+
+
+
+ +
+ +
+
+ +
+ + +

+ {intl.formatMessage(messages.sliderTitleTip)} +

+
+ +
+
+ + +

+ {intl.formatMessage(messages.maxResultsTip)} +

+ {errors.recommendations?.maxResults && + touched.recommendations?.maxResults && ( +
+ {errors.recommendations.maxResults} +
+ )} +
+ +
+ + +

+ {intl.formatMessage(messages.minRatingTip)} +

+ {errors.recommendations?.minRating && + touched.recommendations?.minRating && ( +
+ {errors.recommendations.minRating} +
+ )} +
+ +
+ + +

+ {intl.formatMessage(messages.ttlDaysTip)} +

+ {errors.recommendations?.ttlDays && + touched.recommendations?.ttlDays && ( +
+ {errors.recommendations.ttlDays} +
+ )} +
+
+
+
+ + {/* Search Settings */} +
+

+ {intl.formatMessage(messages.search)} +

+ +
+
+
+ +
+ +
+
+
+ + {/* Save Button */} +
+
+ +
+
+
+
+ )} +
+
+ ); +}; + +export default SettingsAi; diff --git a/src/components/Settings/SettingsJobsCache/index.tsx b/src/components/Settings/SettingsJobsCache/index.tsx index ee8a419889..2ce418f33a 100644 --- a/src/components/Settings/SettingsJobsCache/index.tsx +++ b/src/components/Settings/SettingsJobsCache/index.tsx @@ -90,6 +90,7 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages( 'download-sync-reset': 'Download Sync Reset', 'image-cache-cleanup': 'Image Cache Cleanup', 'process-blocklisted-tags': 'Process Blocklisted Tags', + 'ai-recommendations-sync': 'AI Recommendations Sync', editJobSchedule: 'Modify Job', jobScheduleEditSaved: 'Job edited successfully!', jobScheduleEditFailed: 'Something went wrong while saving the job.', diff --git a/src/components/Settings/SettingsLayout.tsx b/src/components/Settings/SettingsLayout.tsx index f01a5685a5..1426bee98a 100644 --- a/src/components/Settings/SettingsLayout.tsx +++ b/src/components/Settings/SettingsLayout.tsx @@ -19,6 +19,7 @@ const messages = defineMessages('components.Settings', { menuJobs: 'Jobs & Cache', menuAbout: 'About', menuMetadataProviders: 'Metadata Providers', + menuAiSettings: 'AI Settings', }); type SettingsLayoutProps = { @@ -55,6 +56,11 @@ const SettingsLayout = ({ children }: SettingsLayoutProps) => { route: '/settings/services', regex: /^\/settings\/services/, }, + { + text: intl.formatMessage(messages.menuAiSettings), + route: '/settings/ai', + regex: /^\/settings\/ai/, + }, { text: intl.formatMessage(messages.menuNetwork), route: '/settings/network', diff --git a/src/components/TitleCard/index.tsx b/src/components/TitleCard/index.tsx index 7f420047ff..c06a60ef57 100644 --- a/src/components/TitleCard/index.tsx +++ b/src/components/TitleCard/index.tsx @@ -427,14 +427,20 @@ const TitleCard = ({ currentStatus !== MediaStatus.AVAILABLE && currentStatus !== MediaStatus.PARTIALLY_AVAILABLE && currentStatus !== MediaStatus.PENDING && ( - + + )}
)} diff --git a/src/hooks/useAiFeedback.ts b/src/hooks/useAiFeedback.ts new file mode 100644 index 0000000000..55138c0018 --- /dev/null +++ b/src/hooks/useAiFeedback.ts @@ -0,0 +1,33 @@ +import { MediaType } from '@server/constants/media'; +import axios from 'axios'; + +export type FeedbackType = 'like' | 'dislike' | 'seen'; + +const useAiFeedback = () => { + const submit = async ( + tmdbId: number, + mediaType: MediaType, + feedbackType: FeedbackType + ): Promise => { + await axios.post('/api/v1/ai/feedback', { + tmdbId, + mediaType: mediaType === MediaType.TV ? 'tv' : 'movie', + feedbackType, + }); + }; + + const remove = async ( + tmdbId: number, + mediaType: MediaType + ): Promise => { + await axios.delete( + `/api/v1/ai/feedback/${tmdbId}?mediaType=${ + mediaType === MediaType.TV ? 'tv' : 'movie' + }` + ); + }; + + return { submit, remove }; +}; + +export default useAiFeedback; diff --git a/src/hooks/useAiSearch.ts b/src/hooks/useAiSearch.ts new file mode 100644 index 0000000000..3dfcf14ac1 --- /dev/null +++ b/src/hooks/useAiSearch.ts @@ -0,0 +1,74 @@ +import { useUser } from '@app/hooks/useUser'; +import type { + MovieResult, + PersonResult, + TvResult, +} from '@server/models/Search'; +import axios from 'axios'; +import useSWR from 'swr'; + +export interface AiSearchInterpretation { + discoverParams: { + genres?: string[]; + year_from?: number; + year_to?: number; + original_language?: string; + sort_by?: string; + min_rating?: number; + keywords?: string[]; + }; + suggestedTitles: { + title: string; + year?: number; + type: string; + rationale: string; + }[]; +} + +interface AiSearchResponse { + page: number; + totalPages: number; + totalResults: number; + results: (MovieResult | TvResult | PersonResult)[]; + query: string; + interpretation?: AiSearchInterpretation; +} + +/** + * Fetch AI-powered natural-language search results. Pass `null` as the query + * to disable fetching (e.g. when AI mode is off). + * + * Unlike useDiscover, this is a single non-paginated POST (the LLM interprets + * the whole query in one shot), so there is no infinite-scroll loading. + * + * The SWR key is a plain string (cache-scoped per query); the query is sent + * via the POST body through a closure, avoiding SWR array-key serialization. + */ +const useAiSearch = (query: string | null) => { + // Include the user id in the cache key so history-aware results aren't + // shared across accounts. + const { user } = useUser(); + const { data, error, isValidating } = useSWR( + query ? `ai-search-${user?.id ?? 'anon'}-${query}` : null, + async () => { + const response = await axios.post('/api/v1/ai/search', { + query, + options: { includeHistory: true }, + }); + return response.data; + }, + { + revalidateOnFocus: false, + shouldRetryOnError: false, + } + ); + + return { + data, + error, + isLoading: !!query && !data && !error, + isValidating, + }; +}; + +export default useAiSearch; diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 2a880430fc..c959c5edb8 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -26,6 +26,11 @@ "components.Common.QuickConnectModal.expiredMessage": "This Quick Connect code has expired. Please try again.", "components.Common.QuickConnectModal.tryAgain": "Try Again", "components.Common.QuickConnectModal.waitingForAuth": "Waiting for authorization...", + "components.Discover.AiRecommendationCard.dislike": "Not interested", + "components.Discover.AiRecommendationCard.feedbackError": "Could not save feedback. Please try again.", + "components.Discover.AiRecommendationCard.like": "More like this", + "components.Discover.AiRecommendationCard.seen": "Already watched", + "components.Discover.AiRecommendationCard.why": "Why this was recommended", "components.Discover.CreateSlider.addSlider": "Add Slider", "components.Discover.CreateSlider.addcustomslider": "Create Custom Slider", "components.Discover.CreateSlider.addfail": "Failed to create new slider.", @@ -112,9 +117,12 @@ "components.Discover.StudioSlider.studios": "Studios", "components.Discover.TvGenreList.seriesgenres": "Series Genres", "components.Discover.TvGenreSlider.tvgenres": "Series Genres", + "components.Discover.airecommendations": "Recommended for You", + "components.Discover.airecommendationsDescription": "Personalized picks generated by AI based on your request history, watchlist, and library. Rate them to improve future suggestions.", "components.Discover.createnewslider": "Create New Slider", "components.Discover.customizediscover": "Customize Discover", "components.Discover.discover": "Discover", + "components.Discover.empty": "No AI recommendations yet. Recommendations are generated periodically — check back soon, or trigger them from Settings → Jobs & Cache.", "components.Discover.emptywatchlist": "Media added to your Plex Watchlist will appear here.", "components.Discover.moviegenres": "Movie Genres", "components.Discover.networks": "Networks", @@ -613,6 +621,18 @@ "components.ResetPassword.validationpasswordmatch": "Passwords must match", "components.ResetPassword.validationpasswordminchars": "Password is too short; should be a minimum of 8 characters", "components.ResetPassword.validationpasswordrequired": "You must provide a password", + "components.Search.aiDisabled": "AI search is disabled. Enable it in Settings → AI Settings.", + "components.Search.aiError": "AI search failed. It may be disabled, or the AI provider is unreachable.", + "components.Search.aiInterpretation": "AI interpretation", + "components.Search.aiNoResults": "No AI results for this query. Try rephrasing.", + "components.Search.aiSearch": "AI Search", + "components.Search.aiSearchTip": "Describe what you want to watch in natural language (e.g. \"90s psychological thrillers\")", + "components.Search.aiThinking": "Asking the AI…", + "components.Search.interpGenres": "Genres", + "components.Search.interpKeywords": "Keywords", + "components.Search.interpLanguage": "Language", + "components.Search.interpMinRating": "Min Rating", + "components.Search.interpYears": "Years", "components.Search.search": "Search", "components.Search.searchresults": "Search Results", "components.Selector.CertificationSelector.errorLoading": "Failed to load certifications", @@ -920,6 +940,49 @@ "components.Settings.SettingsAbout.totalrequests": "Total Requests", "components.Settings.SettingsAbout.uptodate": "Up to Date", "components.Settings.SettingsAbout.version": "Version", + "components.Settings.SettingsAi.aiSettings": "AI Settings", + "components.Settings.SettingsAi.aiSettingsDescription": "Configure AI-powered recommendations and search features.", + "components.Settings.SettingsAi.apiKey": "API Key", + "components.Settings.SettingsAi.apiKeySet": "An API key is saved — leave blank to keep the current one", + "components.Settings.SettingsAi.apiKeyTip": "API key for your chosen provider (not required for Ollama)", + "components.Settings.SettingsAi.baseUrl": "Base URL", + "components.Settings.SettingsAi.baseUrlTip": "Base URL for your AI provider API", + "components.Settings.SettingsAi.connectionFailure": "Connection test failed!", + "components.Settings.SettingsAi.connectionSuccess": "Connection test successful!", + "components.Settings.SettingsAi.connectionTestFailure": "Connection failed: {error}", + "components.Settings.SettingsAi.connectionTestSuccess": "Connection successful! Latency: {latency}ms", + "components.Settings.SettingsAi.enabled": "Enable AI Features", + "components.Settings.SettingsAi.enabledTip": "Enable AI-powered recommendations and search", + "components.Settings.SettingsAi.loading": "Loading…", + "components.Settings.SettingsAi.maxResults": "Max Results", + "components.Settings.SettingsAi.maxResultsTip": "Maximum number of recommendations to generate per user", + "components.Settings.SettingsAi.minRating": "Minimum Rating", + "components.Settings.SettingsAi.minRatingTip": "Minimum TMDB rating (0.0–10.0) a recommendation must meet; higher values surface better-rated titles", + "components.Settings.SettingsAi.model": "Model", + "components.Settings.SettingsAi.modelTip": "AI model to use for recommendations (e.g., gpt-4o-mini, mistral)", + "components.Settings.SettingsAi.providerConfiguration": "Provider Configuration", + "components.Settings.SettingsAi.providerType": "AI Provider", + "components.Settings.SettingsAi.providerTypeTip": "Choose your AI provider (OpenAI, Ollama, OpenRouter, or custom)", + "components.Settings.SettingsAi.recommendations": "Recommendations", + "components.Settings.SettingsAi.recommendationsEnabled": "Enable Recommendations", + "components.Settings.SettingsAi.recommendationsEnabledTip": "Enable AI-powered personalized recommendations", + "components.Settings.SettingsAi.search": "AI Search", + "components.Settings.SettingsAi.searchEnabled": "Enable AI Search", + "components.Settings.SettingsAi.searchEnabledTip": "Enable natural language search using AI", + "components.Settings.SettingsAi.sliderTitle": "Slider Title", + "components.Settings.SettingsAi.sliderTitleTip": "Title for the recommendations slider on the discover page", + "components.Settings.SettingsAi.testConnection": "Test Connection", + "components.Settings.SettingsAi.testing": "Testing…", + "components.Settings.SettingsAi.testingConnection": "Testing AI connection…", + "components.Settings.SettingsAi.toastSettingsFailure": "Something went wrong while saving AI settings.", + "components.Settings.SettingsAi.toastSettingsSuccess": "AI settings saved successfully!", + "components.Settings.SettingsAi.ttlDays": "Recommendation TTL (days)", + "components.Settings.SettingsAi.ttlDaysTip": "How long a recommendation lives. Re-recommended titles stay alive; stale ones expire after this many days", + "components.Settings.SettingsAi.validationMaxResults": "Max results must be between 1 and 50", + "components.Settings.SettingsAi.validationMinRating": "Minimum rating must be between 0 and 10", + "components.Settings.SettingsAi.validationModelRequired": "You must provide a model name", + "components.Settings.SettingsAi.validationTtlDays": "TTL must be between 1 and 365 days", + "components.Settings.SettingsJobsCache.ai-recommendations-sync": "AI Recommendations Sync", "components.Settings.SettingsJobsCache.availability-sync": "Media Availability Sync", "components.Settings.SettingsJobsCache.cache": "Cache", "components.Settings.SettingsJobsCache.cacheDescription": "Seerr caches requests to external API endpoints to optimize performance and avoid making unnecessary API calls.", @@ -1222,6 +1285,7 @@ "components.Settings.mediaTypeMovie": "movie", "components.Settings.mediaTypeSeries": "series", "components.Settings.menuAbout": "About", + "components.Settings.menuAiSettings": "AI Settings", "components.Settings.menuGeneralSettings": "General", "components.Settings.menuJellyfinSettings": "{mediaServerName}", "components.Settings.menuJobs": "Jobs & Cache", diff --git a/src/pages/discover/ai-recommendations.tsx b/src/pages/discover/ai-recommendations.tsx new file mode 100644 index 0000000000..e5ae8e35fa --- /dev/null +++ b/src/pages/discover/ai-recommendations.tsx @@ -0,0 +1,8 @@ +import AiRecommendations from '@app/components/Discover/AiRecommendations'; +import type { NextPage } from 'next'; + +const AiRecommendationsPage: NextPage = () => { + return ; +}; + +export default AiRecommendationsPage; diff --git a/src/pages/settings/ai.tsx b/src/pages/settings/ai.tsx new file mode 100644 index 0000000000..05c5b4f8fe --- /dev/null +++ b/src/pages/settings/ai.tsx @@ -0,0 +1,16 @@ +import SettingsAi from '@app/components/Settings/SettingsAi'; +import SettingsLayout from '@app/components/Settings/SettingsLayout'; +import useRouteGuard from '@app/hooks/useRouteGuard'; +import { Permission } from '@app/hooks/useUser'; +import type { NextPage } from 'next'; + +const SettingsAiPage: NextPage = () => { + useRouteGuard(Permission.ADMIN); + return ( + + + + ); +}; + +export default SettingsAiPage;