From c6db0bebb5e5501622bf68ac7e4cc2eeb3b391b8 Mon Sep 17 00:00:00 2001
From: Richard Holmboe
Date: Sat, 18 Jul 2026 21:57:40 +0200
Subject: [PATCH 01/13] feat(ai): add AI-powered personalized recommendations
Adds an AI recommendation engine that generates personalized movie/TV
suggestions based on a user's request history, watchlist, and available
library content, inspired by SuggestArr and Recomendarr.
Backend:
- Two-phase LLM pipeline: taste profile generation -> recommendations
- Multi-provider support via OpenAI-compatible API (OpenAI, Ollama,
OpenRouter, custom), with robust JSON repair + retry on parse failure
- LLM titles resolved to real TMDb entries via search (small models
hallucinate tmdbId values), so only valid titles are stored
- TTL + upsert lifecycle: recommendations persist across runs, are
refreshed when re-recommended, and age out via configurable TTL cleanup
- Scheduled job (every 6h) generates recommendations for all users
- API endpoints: /discover/ai-recommendations, /ai/settings, /ai/test,
/ai/search, /ai/feedback, /ai/regenerate
- New entities: AiRecommendation, UserFeedback (+ SQLite/Postgres
migrations)
- AI settings block (provider, recommendations, search) in global settings
Frontend:
- "Recommended for You" discover slider + dedicated /discover/ai-recommendations page
- AI Settings tab (provider config, feature toggles, maxResults, minScore, TTL)
- AI Recommendations Sync job registered in Jobs & Cache UI
- All AI endpoints declared in the OpenAPI spec (seerr-api.yml)
Composable dev setup via compose.ai.yaml (optional Ollama service).
Co-Authored-By: Claude Fable 5
---
README.AI.md | 264 +++++++
compose.ai.yaml | 35 +
next-env.d.ts | 2 +-
package.json | 1 +
seerr-api.yml | 250 +++++++
server/api/ai/index.ts | 478 ++++++++++++
server/constants/discover.ts | 8 +
server/entity/AiRecommendation.ts | 60 ++
server/entity/UserFeedback.ts | 37 +
server/job/schedule.ts | 75 ++
server/lib/aiRecommendations.ts | 704 ++++++++++++++++++
server/lib/settings/index.ts | 59 +-
.../1784393737543-AddAiRecommendations.ts | 40 +
.../1784393737543-AddAiRecommendations.ts | 40 +
server/routes/ai.ts | 340 +++++++++
server/routes/discover.ts | 85 +++
server/routes/index.ts | 2 +
src/components/Discover/AiRecommendations.tsx | 52 ++
src/components/Discover/constants.ts | 2 +
src/components/Discover/index.tsx | 20 +
src/components/Settings/SettingsAi/index.tsx | 491 ++++++++++++
.../Settings/SettingsJobsCache/index.tsx | 1 +
src/components/Settings/SettingsLayout.tsx | 6 +
src/pages/discover/ai-recommendations.tsx | 8 +
src/pages/settings/ai.tsx | 16 +
25 files changed, 3074 insertions(+), 2 deletions(-)
create mode 100644 README.AI.md
create mode 100644 compose.ai.yaml
create mode 100644 server/api/ai/index.ts
create mode 100644 server/entity/AiRecommendation.ts
create mode 100644 server/entity/UserFeedback.ts
create mode 100644 server/lib/aiRecommendations.ts
create mode 100644 server/migration/postgres/1784393737543-AddAiRecommendations.ts
create mode 100644 server/migration/sqlite/1784393737543-AddAiRecommendations.ts
create mode 100644 server/routes/ai.ts
create mode 100644 src/components/Discover/AiRecommendations.tsx
create mode 100644 src/components/Settings/SettingsAi/index.tsx
create mode 100644 src/pages/discover/ai-recommendations.tsx
create mode 100644 src/pages/settings/ai.tsx
diff --git a/README.AI.md b/README.AI.md
new file mode 100644
index 0000000000..b65cdc24cc
--- /dev/null
+++ b/README.AI.md
@@ -0,0 +1,264 @@
+# AI Recommendations Development Setup
+
+This guide covers setting up the AI recommendations feature in a development environment.
+
+## Quick Start
+
+### 1. Start Development Environment
+
+```bash
+# Start Seerr with Ollama for local LLM testing
+docker compose -f compose.ai.yaml -f compose.yaml up -d
+
+# Or use with PostgreSQL for production-like testing
+docker compose -f compose.ai.yaml -f compose.postgres.yaml -f compose.yaml up -d
+
+# To use a cloud AI provider instead, edit compose.ai.yaml and comment out ollama service
+```
+
+### 2. Configure AI Provider
+
+1. Open http://localhost:3000
+2. Go to Settings → AI Settings
+3. Configure your AI provider:
+
+#### Option A: OpenAI (Requires API Key)
+- **Provider Type**: OpenAI
+- **Base URL**: `https://api.openai.com/v1`
+- **Model**: `gpt-4o-mini`
+- **API Key**: Your OpenAI API key
+
+#### Option B: Ollama (Free, Local)
+- **Provider Type**: Ollama
+- **Base URL**: `http://ollama:11434/v1`
+- **Model**: `mistral` (pull first: `docker compose exec ollama ollama pull mistral`)
+- **API Key**: Leave empty
+
+#### Option C: OpenRouter (Multi-provider)
+- **Provider Type**: OpenRouter
+- **Base URL**: `https://openrouter.ai/api/v1`
+- **Model**: `meta-llama/llama-3-8b-instruct:free`
+- **API Key**: Your OpenRouter API key
+
+### 3. Test Connection
+
+Click "Test Connection" to verify your AI provider is working.
+
+### 4. Enable Features
+
+- **Enable AI Features**: Toggle on
+- **Enable Recommendations**: Toggle on (adds slider to Discover page)
+- **Enable AI Search**: Toggle on (optional)
+
+### 5. Manual Testing
+
+#### Test Recommendations:
+1. Go to Discover page
+2. Look for "Recommended for You" slider
+3. Click "Regenerate" button in AI Settings to force refresh
+
+#### Test AI Search:
+1. Go to Search page
+2. Try natural language queries:
+ - "90s psychological thrillers with twist endings"
+ - "Feel-good anime with strong friendships"
+ - "Dark sci-fi like Blade Runner"
+
+## Architecture Overview
+
+```
+┌─────────────────────────────────────────────────────────┐
+│ Frontend (Next.js) │
+│ ┌──────────────────┐ ┌─────────────────────┐ │
+│ │ Discover Page │ │ Settings AI │ │
+│ │ - AI Slider │ │ - Provider Config │ │
+│ │ - Search Page │ │ - Feature Toggles │ │
+│ └──────────────────┘ └─────────────────────┘ │
+└─────────────────────────────────────────────────────────┘
+ │
+ ▼
+┌─────────────────────────────────────────────────────────┐
+│ Backend (Express) │
+│ ┌──────────────────┐ ┌─────────────────────┐ │
+│ │ /api/v1/discover │ │ /api/v1/ai │ │
+│ │ - ai-recommend │ │ - settings │ │
+│ │ │ │ - search │ │
+│ │ │ │ - feedback │ │
+│ └──────────────────┘ └─────────────────────┘ │
+│ │ │
+│ ▼ │
+│ ┌───────────────────────────────────────────────┐ │
+│ │ AI Recommendation Engine │ │
+│ │ - Taste Profile Generation │ │
+│ │ - Recommendation Generation │ │
+│ │ - Search Query Interpretation │ │
+│ │ - TMDb Integration │ │
+│ └───────────────────────────────────────────────┘ │
+│ │ │
+│ ▼ │
+│ ┌───────────────────────────────────────────────┐ │
+│ │ LLM Client │ │
+│ │ - OpenAI-compatible interface │ │
+│ │ - Multi-provider support │ │
+│ │ - Structured output validation │ │
+│ └───────────────────────────────────────────────┘ │
+└─────────────────────────────────────────────────────────┘
+```
+
+## Data Flow
+
+### Recommendation Generation:
+```
+User Request History + Watchlist + Library
+ ↓
+Score & Sample (top 40 items)
+ ↓
+Fetch TMDb Metadata
+ ↓
+LLM: Generate Taste Profile (keywords, genres, themes)
+ ↓
+LLM: Generate Recommendations (with rationales)
+ ↓
+Merge with TMDb Keyword Discovery
+ ↓
+Filter: Remove watched/requested/disliked
+ ↓
+Store in Database (ai_recommendations table)
+ ↓
+Return to Frontend
+```
+
+### AI Search:
+```
+Natural Language Query
+ ↓
+LLM: Interpret Query (→ TMDb filters + specific titles)
+ ↓
+Parallel Search:
+ - TMDb Discover (with filters)
+ - TMDb Search (specific titles)
+ ↓
+Merge Results
+ ↓
+Add AI Rationales (optional)
+ ↓
+Filter Existing Content
+ ↓
+Return to Frontend
+```
+
+## Database Schema
+
+### ai_recommendation
+- `id`: Primary key
+- `userId`: User ID (NULL = global recommendations)
+- `tmdbId`: TMDb ID
+- `mediaType`: 'movie' | 'tv'
+- `tvdbId`: TVDB ID (optional)
+- `score`: AI confidence score (0-1)
+- `rationale`: "Why you might like this"
+- `metadata`: JSON (source, keywords, model, timestamps)
+- `createdAt`/`updatedAt`: Timestamps
+
+### user_feedback
+- `id`: Primary key
+- `userId`: User ID
+- `tmdbId`: TMDb ID
+- `mediaType`: 'movie' | 'tv'
+- `feedbackType`: 'like' | 'dislike' | 'seen'
+- `createdAt`: Timestamp
+
+## API Endpoints
+
+### Discover
+- `GET /api/v1/discover/ai-recommendations` - Get personalized recommendations
+
+### AI Settings
+- `GET /api/v1/ai/settings` - Get AI settings
+- `PUT /api/v1/ai/settings` - Update AI settings
+- `POST /api/v1/ai/test` - Test AI provider connection
+
+### AI Search
+- `POST /api/v1/ai/search` - Natural language search
+
+### Feedback
+- `POST /api/v1/ai/feedback` - Submit user feedback
+- `GET /api/v1/ai/feedback/stats` - Get feedback statistics
+- `DELETE /api/v1/ai/feedback/:tmdbId` - Delete feedback
+
+### Management
+- `POST /api/v1/ai/regenerate` - Manually trigger regeneration
+
+## Scheduled Jobs
+
+### AI Recommendations Sync
+- **Schedule**: Every 6 hours (`0 */6 * * *`)
+- **Function**: Generates recommendations for all active users
+- **Status**: Can be enabled/disabled in Settings
+
+## Troubleshooting
+
+### No recommendations appearing
+1. Check AI provider is configured correctly
+2. Verify test connection succeeds
+3. Ensure recommendations are enabled in settings
+4. Check logs: `docker-compose -f docker-compose.dev.yml logs -f seerr`
+
+### "AI recommendations disabled" message
+- Enable AI features in Settings → AI Settings
+- Enable recommendations specifically
+
+### Ollama connection fails
+- Ensure Ollama container is running: `docker compose -f compose.ai.yaml -f compose.yaml ps`
+- Pull model: `docker compose -f compose.ai.yaml -f compose.yaml exec ollama ollama pull mistral`
+- Check connection: `curl http://localhost:11434/api/tags`
+
+### OpenAI rate limits
+- Reduce `maxResults` in settings
+- Increase job interval (Settings → Jobs & Cache)
+- Consider using Ollama (free, unlimited)
+
+## Testing Checklist
+
+### Manual Testing Steps:
+- [ ] Configure AI provider successfully
+- [ ] Test connection passes
+- [ ] Enable AI recommendations
+- [ ] AI slider appears on Discover page
+- [ ] Click AI recommendations slider → results load
+- [ ] Enable AI search
+- [ ] Try natural language search → relevant results
+- [ ] Submit feedback (like/dislike)
+- [ ] Regenerate recommendations → new results appear
+- [ ] Check job schedule in Settings → Jobs & Cache
+
+### Performance Validation:
+- [ ] Taste profile generation < 10 seconds
+- [ ] Recommendation generation < 15 seconds
+- [ ] AI search response < 8 seconds
+- [ ] Database queries are optimized
+- [ ] No memory leaks over multiple runs
+
+## Cost Estimation
+
+### OpenAI (gpt-4o-mini)
+- Per user per cycle: ~$0.0004
+- 10 users, 4x/day: ~$0.48/month
+- 100 users, 4x/day: ~$4.80/month
+
+### Ollama (Local)
+- **Cost**: Free
+- **Hardware**: 8GB VRAM GPU recommended
+- **Models**: 4-8GB disk space per model
+
+### Recommendations
+- **Development**: Use Ollama (free)
+- **Small deployments** (<100 users): OpenAI gpt-4o-mini
+- **Large deployments** (100+ users): Ollama or monitor OpenAI costs
+
+## Next Steps
+
+1. **Start Dev Container**: `docker-compose -f docker-compose.dev.yml up -d`
+2. **Configure AI Provider**: Settings → AI Settings
+3. **Test Features**: Try recommendations and search
+4. **Provide Feedback**: Report issues or suggestions
diff --git a/compose.ai.yaml b/compose.ai.yaml
new file mode 100644
index 0000000000..91a1aa4c93
--- /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:
+ - "11434:11434"
+ volumes:
+ - ollama_data:/root/.ollama
+
+volumes:
+ ollama_data:
diff --git a/next-env.d.ts b/next-env.d.ts
index 19709046af..7996d352f4 100644
--- a/next-env.d.ts
+++ b/next-env.d.ts
@@ -1,6 +1,6 @@
///
///
-import "./.next/types/routes.d.ts";
+import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
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..d8046a90de 100644
--- a/seerr-api.yml
+++ b/seerr-api.yml
@@ -6395,6 +6395,256 @@ 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
+ /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..4a6048ec05
--- /dev/null
+++ b/server/api/ai/index.ts
@@ -0,0 +1,478 @@
+import { OpenAI } from 'openai';
+import type { ChatCompletionMessageParam } from 'openai/resources/chat/completions';
+import { getSettings } from '../../lib/settings';
+import logger from '../../logger';
+
+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: Array<{
+ 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
+ ): Promise;
+ interpretSearchQuery(
+ query: string,
+ history?: WatchHistoryItem[]
+ ): Promise;
+ testConnection(): Promise;
+}
+
+export class OpenAICompatibleClient implements LLMClient {
+ private client: OpenAI;
+ private model: string;
+
+ constructor(userId?: number) {
+ const settings = getSettings();
+ const aiConfig = settings.ai;
+
+ // For per-user config, we would extend this to check user settings
+ // For now, using global config
+ 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: Array<{ 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 = response.choices[0]?.message?.content;
+ if (!content) {
+ throw new Error('Empty response from LLM');
+ }
+
+ return content;
+ } catch (error) {
+ logger.error('LLM API call failed:', error);
+ throw error;
+ }
+ }
+
+ /**
+ * 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
+ 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 (err) {
+ // 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
+ let 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: Array<{ 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
+ ): Promise {
+ const prompt = this.buildRecommendationsPrompt(profile, history, filters, maxResults);
+
+ 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 = 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,
+ });
+
+ return response.choices[0]?.message?.content?.toLowerCase().includes('ok') || false;
+ } 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
+ ): string {
+ const historyTitles = history.map((h) => `- ${h.title} (${h.year || 'N/A'})`).join('\n');
+ const filtersText = this.formatFilters(filters);
+
+ return `Generate personalized recommendations based on this taste profile and viewing history.
+
+TASTE PROFILE:
+${JSON.stringify(profile, null, 2)}
+
+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(userId?: number): LLMClient {
+ return new OpenAICompatibleClient(userId);
+}
diff --git a/server/constants/discover.ts b/server/constants/discover.ts
index fda0682243..a820a90ed2 100644
--- a/server/constants/discover.ts
+++ b/server/constants/discover.ts
@@ -22,6 +22,8 @@ export enum DiscoverSliderType {
TMDB_NETWORK,
TMDB_MOVIE_STREAMING_SERVICES,
TMDB_TV_STREAMING_SERVICES,
+ AI_RECOMMENDATIONS,
+ AI_SEARCH,
}
export const defaultSliders: Partial[] = [
@@ -97,4 +99,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..875ed35da9
--- /dev/null
+++ b/server/entity/AiRecommendation.ts
@@ -0,0 +1,60 @@
+import {
+ Entity,
+ PrimaryGeneratedColumn,
+ Column,
+ Index,
+ ManyToOne,
+ JoinColumn,
+} from 'typeorm';
+import { User } from './User';
+import { MediaType } from '../constants/media';
+import { DbAwareColumn } from '../utils/DbColumnHelper';
+
+@Entity('ai_recommendation')
+@Index(['userId', 'mediaType'])
+@Index(['createdAt'])
+export class AiRecommendation {
+ @PrimaryGeneratedColumn()
+ id: number;
+
+ @Column({ type: 'int', nullable: true })
+ userId: number | null;
+
+ @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..e1a56796af
--- /dev/null
+++ b/server/entity/UserFeedback.ts
@@ -0,0 +1,37 @@
+import {
+ Entity,
+ PrimaryGeneratedColumn,
+ Column,
+ ManyToOne,
+ JoinColumn,
+ Unique,
+} from 'typeorm';
+import { User } from './User';
+import { MediaType } from '../constants/media';
+import { DbAwareColumn } from '../utils/DbColumnHelper';
+
+@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..fe9a6deca7 100644
--- a/server/job/schedule.ts
+++ b/server/job/schedule.ts
@@ -1,5 +1,13 @@
import { MediaServerType } from '@server/constants/server';
+import { UserType } from '@server/constants/user';
+import { getRepository } from '@server/datasource';
+import { User } from '@server/entity/User';
+import { In } from 'typeorm';
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';
@@ -259,5 +267,72 @@ export const startJobs = (): void => {
cancelFn: () => blocklistedTagsProcessor.cancel(),
});
+ // AI Recommendations Sync
+ 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;
+ }
+
+ 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);
+ }
+ }),
+ });
+ }
+
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..af40c23bbe
--- /dev/null
+++ b/server/lib/aiRecommendations.ts
@@ -0,0 +1,704 @@
+import { getRepository } from '../datasource';
+import { MediaRequest } from '../entity/MediaRequest';
+import { Watchlist } from '../entity/Watchlist';
+import Media from '../entity/Media';
+import { AiRecommendation } from '../entity/AiRecommendation';
+import { UserFeedback } from '../entity/UserFeedback';
+import { User } from '../entity/User';
+import { MediaType } from '../constants/media';
+import { createLLMClient, WatchHistoryItem, RecommendationFilters } from '../api/ai';
+import TheMovieDb from '../api/themoviedb';
+import { getSettings } from './settings';
+import logger from '../logger';
+
+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 settings = getSettings();
+ 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(userId);
+ 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
+ );
+
+ // 3. Build filters from settings and options
+ const filters: RecommendationFilters = {
+ ...options?.filters,
+ mediaType: 'both',
+ minRating: settings.ai.recommendations.minScore,
+ };
+
+ // 4. Call LLM for recommendations
+ logger.info(`[user ${userId}] Step 3/8: calling LLM for recommendations...`, logTag);
+ const llm = createLLMClient(userId);
+ const aiRecs = await llm.generateRecommendations(
+ { profile: profile, keywords } as any,
+ history,
+ filters,
+ options?.limit || settings.ai.recommendations.maxResults
+ );
+ 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 {
+ try {
+ const settings = getSettings();
+
+ if (!settings.ai.enabled || !settings.ai.search.enabled) {
+ throw new Error('AI search is disabled');
+ }
+
+ const llm = createLLMClient(userId);
+ 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 filtered.slice(0, options?.limit || 20);
+ } 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: Array<{ 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();
+}
+
+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;
+}
+
+async function discoverFromTmdb(params: any) {
+ const tmdb = new TheMovieDb();
+ const results: any[] = [];
+
+ try {
+ if (params.genres || params.year_from || params.min_rating) {
+ const baseParams: any = {};
+ if (params.genres) baseParams.genre = params.genres.join('|');
+ if (params.year_from) baseParams.primaryReleaseDateGte = `${params.year_from}-01-01`;
+ if (params.year_to) baseParams.primaryReleaseDateLte = `${params.year_to}-12-31`;
+ if (params.min_rating) baseParams.voteAverageGte = params.min_rating;
+ if (params.original_language) baseParams.originalLanguage = params.original_language;
+ if (params.sort_by) baseParams.sortBy = params.sort_by;
+
+ const [movieResults, tvResults] = await Promise.all([
+ tmdb.getDiscoverMovies(baseParams),
+ tmdb.getDiscoverTv(baseParams),
+ ]);
+
+ 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: Array<{ 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: Array<{
+ title: string;
+ year?: number;
+ type?: string;
+ rationale?: string;
+ tmdbId?: number;
+ }>
+): Promise {
+ const tmdb = new TheMovieDb();
+ const resolved: any[] = [];
+
+ for (const rec of recs) {
+ // If the LLM already gave a plausible tmdbId, keep it.
+ if (rec.tmdbId && rec.tmdbId > 0) {
+ resolved.push(rec);
+ continue;
+ }
+
+ 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[]) {
+ const merged = new Map();
+
+ // Add AI recommendations with higher base score
+ for (const rec of aiRecs) {
+ if (!rec.tmdbId) continue; // skip unresolved entries
+ merged.set(rec.tmdbId, {
+ tmdbId: rec.tmdbId,
+ title: rec.title,
+ year: rec.year,
+ mediaType: rec.mediaType || rec.type,
+ 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;
+ if (!merged.has(id)) {
+ merged.set(id, {
+ 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: rec.media_type === 'tv' ? 'tv' : 'movie',
+ 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 merged = new Map();
+
+ for (const result of tmdbResults) {
+ const id = result.id;
+ merged.set(id, {
+ ...result,
+ matchScore: 0.7,
+ });
+ }
+
+ for (const title of suggestedTitles) {
+ const id = title.id || title.tmdbId;
+ if (!merged.has(id)) {
+ merged.set(id, {
+ ...title,
+ matchScore: 0.9,
+ });
+ }
+ }
+
+ return Array.from(merged.values());
+}
+
+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(),
+ ]);
+
+ const excludedTmdbIds = new Set();
+ const excludedWithTypes = new Set();
+
+ // Add existing media
+ for (const media of existingMedia) {
+ excludedWithTypes.add(`${media.tmdbId}-${media.mediaType}`);
+ }
+
+ // Add existing requests
+ for (const request of existingRequests) {
+ if (request.media) {
+ excludedWithTypes.add(`${request.media.tmdbId}-${request.type}`);
+ }
+ }
+
+ // Add disliked/seen content
+ for (const fb of feedback) {
+ excludedTmdbIds.add(fb.tmdbId);
+ excludedWithTypes.add(`${fb.tmdbId}-${fb.mediaType}`);
+ }
+
+ // Filter recommendations
+ return recommendations.filter((rec) => {
+ const key = `${rec.tmdbId}-${rec.mediaType}`;
+ return !excludedWithTypes.has(key) && !excludedTmdbIds.has(rec.tmdbId);
+ });
+}
+
+/**
+ * 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..617fd91ba8 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;
+ minScore: 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,
+ minScore: 0.5,
+ 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..36d4d96ea9
--- /dev/null
+++ b/server/migration/postgres/1784393737543-AddAiRecommendations.ts
@@ -0,0 +1,40 @@
+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, "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_CREATED" ON "ai_recommendation" ("createdAt")`
+ );
+
+ // 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_CREATED"`);
+ 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..6919dbb49d
--- /dev/null
+++ b/server/migration/sqlite/1784393737543-AddAiRecommendations.ts
@@ -0,0 +1,40 @@
+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, "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_CREATED" ON "ai_recommendation" ("createdAt")`
+ );
+
+ // 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_CREATED"`);
+ 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..4c31889898
--- /dev/null
+++ b/server/routes/ai.ts
@@ -0,0 +1,340 @@
+import { getRepository } from '../datasource';
+import { OpenAI } from 'openai';
+import { User } from '../entity/User';
+import { UserFeedback } from '../entity/UserFeedback';
+import { AiRecommendation } from '../entity/AiRecommendation';
+import { MediaType } from '../constants/media';
+import { getSettings } from '../lib/settings';
+import { createLLMClient } from '../api/ai';
+import { aiSearch, generateRecommendations } from '../lib/aiRecommendations';
+import logger from '../logger';
+import { Router } from 'express';
+import { z } from 'zod';
+
+const aiRoutes = Router();
+
+// 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
+ const publicAiSettings = {
+ 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,
+ minScore: settings.ai.recommendations.minScore,
+ 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 (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;
+ }
+ 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.minScore)
+ settings.ai.recommendations.minScore = req.body.recommendations.minScore;
+ 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,
+ minScore: settings.ai.recommendations.minScore,
+ 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 directly from the submitted form values so "Test Connection"
+ // validates what the user entered, regardless of what's saved globally.
+ // For local providers (Ollama/LM Studio) an API key is not required, so we
+ // fall back to a harmless placeholder to satisfy the OpenAI SDK.
+ const client = new OpenAI({
+ apiKey: provider.apiKey || 'sk-not-required',
+ baseURL: provider.baseUrl || 'https://api.openai.com/v1',
+ });
+ const model = provider.model || 'gpt-4o-mini';
+
+ const startTime = Date.now();
+
+ try {
+ const response = await client.chat.completions.create({
+ model,
+ messages: [{ role: 'user', content: 'Say "ok"' }],
+ max_tokens: 5,
+ });
+
+ const success =
+ response.choices[0]?.message?.content?.toLowerCase().includes('ok') ||
+ false;
+ const latency = Date.now() - startTime;
+
+ res.json({
+ success,
+ latency: success ? latency : undefined,
+ error: success ? undefined : 'Connection test failed (no valid response)',
+ });
+ } 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 = await aiSearch(user, query, options);
+
+ res.json({
+ results,
+ query,
+ });
+ } 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..3a4f106bd6 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,88 @@ 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
+ const tmdb = createTmdbWithRegionLanguage(req.user);
+ const detailedResults = await Promise.all(
+ stored.map(async (rec) => {
+ 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 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..63afe0322b 100644
--- a/server/routes/index.ts
+++ b/server/routes/index.ts
@@ -19,6 +19,7 @@ import { mapWatchProviderDetails } from '@server/models/common';
import overrideRuleRoutes from '@server/routes/overrideRule';
import settingsRoutes from '@server/routes/settings';
import watchlistRoutes from '@server/routes/watchlist';
+import aiRoutes from './ai';
import {
appDataPath,
appDataPermissions,
@@ -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/AiRecommendations.tsx b/src/components/Discover/AiRecommendations.tsx
new file mode 100644
index 0000000000..25f0648b6a
--- /dev/null
+++ b/src/components/Discover/AiRecommendations.tsx
@@ -0,0 +1,52 @@
+import Header from '@app/components/Common/Header';
+import ListView from '@app/components/Common/ListView';
+import PageTitle from '@app/components/Common/PageTitle';
+import useDiscover from '@app/hooks/useDiscover';
+import defineMessages from '@app/utils/defineMessages';
+import type { MovieResult, TvResult } from '@server/models/Search';
+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.',
+ 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,
+ isLoadingMore,
+ isReachingEnd,
+ titles,
+ fetchMore,
+ } = useDiscover(
+ '/api/v1/discover/ai-recommendations'
+ );
+
+ return (
+ <>
+
+
+
{intl.formatMessage(messages.airecommendations)}
+
+ {intl.formatMessage(messages.airecommendationsDescription)}
+
+
+
+ >
+ );
+};
+
+export default AiRecommendations;
diff --git a/src/components/Discover/constants.ts b/src/components/Discover/constants.ts
index 4ce5e34f66..5a93b73fe6 100644
--- a/src/components/Discover/constants.ts
+++ b/src/components/Discover/constants.ts
@@ -88,6 +88,8 @@ export const sliderTitles = defineMessages('components.Discover', {
tmdbsearch: 'TMDB Search',
tmdbmoviestreamingservices: 'TMDB Movie Streaming Services',
tmdbtvstreamingservices: 'TMDB TV Streaming Services',
+ airecommendations: 'Recommended for You',
+ aisearch: 'AI Search',
});
export const QueryFilterOptions = z.object({
diff --git a/src/components/Discover/index.tsx b/src/components/Discover/index.tsx
index 5638d6fb34..200ba851dd 100644
--- a/src/components/Discover/index.tsx
+++ b/src/components/Discover/index.tsx
@@ -396,6 +396,26 @@ const Discover = () => {
/>
);
break;
+ case DiscoverSliderType.AI_RECOMMENDATIONS:
+ sliderComponent = (
+
+ );
+ break;
+ case DiscoverSliderType.AI_SEARCH:
+ sliderComponent = (
+
+ );
+ break;
}
if (isEditing) {
diff --git a/src/components/Settings/SettingsAi/index.tsx b/src/components/Settings/SettingsAi/index.tsx
new file mode 100644
index 0000000000..8ee458f89e
--- /dev/null
+++ b/src/components/Settings/SettingsAi/index.tsx
@@ -0,0 +1,491 @@
+import Button from '@app/components/Common/Button';
+import PageTitle from '@app/components/Common/PageTitle';
+import SensitiveInput from '@app/components/Common/SensitiveInput';
+import SettingsBadge from '@app/components/Settings/SettingsBadge';
+import useToasts from '@app/hooks/useToasts';
+import { useUser } from '@app/hooks/useUser';
+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, { mutate } from 'swr';
+import * as Yup from 'yup';
+
+interface AiSettings {
+ enabled: boolean;
+ provider: {
+ type: 'openai' | 'ollama' | 'openrouter' | 'custom';
+ apiKey?: string;
+ baseUrl?: string;
+ model: string;
+ };
+ recommendations: {
+ enabled: boolean;
+ sliderTitle: string;
+ maxResults: number;
+ minScore: number;
+ ttlDays: number;
+ };
+ search: {
+ enabled: boolean;
+ };
+}
+
+const messages = defineMessages('components.Settings.SettingsAi', {
+ aiSettings: 'AI Settings',
+ aiSettingsDescription: 'Configure AI-powered recommendations and search features.',
+ 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)',
+ 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',
+ minScore: 'Minimum Score',
+ minScoreTip: 'Minimum confidence score for recommendations (0.0 - 1.0)',
+ 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',
+ validationMinScore: 'Minimum score must be between 0 and 1',
+ testingConnection: 'Testing AI connection...',
+ connectionTestSuccess: 'Connection successful! Latency: {latency}ms',
+ connectionTestFailure: 'Connection failed: {error}',
+});
+
+const SettingsAi = () => {
+ const { addToast } = useToasts();
+ const { hasPermission } = useUser();
+ const intl = useIntl();
+
+ const { data, error, 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(),
+ minScore: Yup.number()
+ .min(0, intl.formatMessage(messages.validationMinScore))
+ .max(1, intl.formatMessage(messages.validationMinScore))
+ .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 (error) {
+ addToast(intl.formatMessage(messages.connectionFailure), {
+ appearance: 'error',
+ autoDismiss: true,
+ });
+ }
+ };
+
+ if (!data) {
+ return 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,
+ handleSubmit,
+ handleChange,
+ setFieldValue,
+ }) => (
+
+ )}
+
+
+ );
+};
+
+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/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..3508e85a62
--- /dev/null
+++ b/src/pages/settings/ai.tsx
@@ -0,0 +1,16 @@
+import SettingsLayout from '@app/components/Settings/SettingsLayout';
+import SettingsAi from '@app/components/Settings/SettingsAi';
+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;
From 39e520a9440c98d4554a4aac9c2c48bf4bee9081 Mon Sep 17 00:00:00 2001
From: Richard Holmboe
Date: Sat, 18 Jul 2026 22:12:07 +0200
Subject: [PATCH 02/13] feat(ai): add natural-language AI search with
search-page toggle
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds an "AI Search" mode to the existing search page so users can describe
what they want to watch in natural language ("90s psychological thrillers"),
alongside the regular keyword search.
Backend:
- aiSearch() now returns { results, interpretation } so the UI can show how
the query was parsed
- POST /ai/search maps results through mapSearchResults + Media status
(same shape as the regular /search endpoint) so TitleCard/ListView render
unchanged in either mode
Frontend:
- useAiSearch hook (SWR, single non-paginated POST via closure-keyed fetcher)
- Search component gains an "AI Search" toggle; in AI mode it shows a
loading state, an interpretation badge (genres, years, language, min
rating, keywords), and the result grid
- Same search box as before — toggle is the only entry point, so the common
title-lookup case stays fast (no LLM call) while NL queries opt in
OpenAPI spec updated for the /ai/search response (incl. interpretation).
Co-Authored-By: Claude Fable 5
---
seerr-api.yml | 26 ++++++
server/lib/aiRecommendations.ts | 7 +-
server/routes/ai.ts | 24 +++++-
src/components/Search/index.tsx | 135 +++++++++++++++++++++++++-------
src/hooks/useAiSearch.ts | 70 +++++++++++++++++
5 files changed, 231 insertions(+), 31 deletions(-)
create mode 100644 src/hooks/useAiSearch.ts
diff --git a/seerr-api.yml b/seerr-api.yml
index d8046a90de..d614c6a173 100644
--- a/seerr-api.yml
+++ b/seerr-api.yml
@@ -6555,6 +6555,32 @@ paths:
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
diff --git a/server/lib/aiRecommendations.ts b/server/lib/aiRecommendations.ts
index af40c23bbe..6ab74e0564 100644
--- a/server/lib/aiRecommendations.ts
+++ b/server/lib/aiRecommendations.ts
@@ -221,7 +221,7 @@ export async function aiSearch(
userId: number,
query: string,
options?: { limit?: number; includeHistory?: boolean }
-): Promise {
+): Promise<{ results: any[]; interpretation: any }> {
try {
const settings = getSettings();
@@ -247,7 +247,10 @@ export async function aiSearch(
// 4. Filter out watched/disliked
const filtered = await filterExistingContent(results, userId);
- return filtered.slice(0, options?.limit || 20);
+ return {
+ results: filtered.slice(0, options?.limit || 20),
+ interpretation,
+ };
} catch (error) {
logger.error(`AI search failed for user ${userId}:`, error);
throw error;
diff --git a/server/routes/ai.ts b/server/routes/ai.ts
index 4c31889898..8dddba0575 100644
--- a/server/routes/ai.ts
+++ b/server/routes/ai.ts
@@ -3,10 +3,12 @@ import { OpenAI } from 'openai';
import { User } from '../entity/User';
import { UserFeedback } from '../entity/UserFeedback';
import { AiRecommendation } from '../entity/AiRecommendation';
+import Media from '../entity/Media';
import { MediaType } from '../constants/media';
import { getSettings } from '../lib/settings';
import { createLLMClient } from '../api/ai';
import { aiSearch, generateRecommendations } from '../lib/aiRecommendations';
+import { mapSearchResults } from '../models/Search';
import logger from '../logger';
import { Router } from 'express';
import { z } from 'zod';
@@ -167,11 +169,31 @@ aiRoutes.post('/search', async (req, res, next) => {
return res.status(400).json({ message: 'Query is required' });
}
- const results = await aiSearch(user, query, options);
+ 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);
diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx
index e5a54180bb..7fb46bd896 100644
--- a/src/components/Search/index.tsx
+++ b/src/components/Search/index.tsx
@@ -1,61 +1,140 @@
+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 { useRouter } from 'next/router';
-import { useIntl } from 'react-intl';
+import { useState } from 'react';
+import { useIntl, type IntlShape } 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.',
});
+// Build a human-readable summary of how the AI parsed the query.
+const formatInterpretation = (
+ interp: AiSearchInterpretation | undefined,
+ _intl: IntlShape
+): string | null => {
+ if (!interp?.discoverParams) return null;
+ const p = interp.discoverParams;
+ const parts: string[] = [];
+ if (p.genres?.length) parts.push(`Genres: ${p.genres.join(', ')}`);
+ if (p.year_from || p.year_to) {
+ parts.push(
+ `Years: ${p.year_from ?? '…'}–${p.year_to ?? '…'}`
+ );
+ }
+ if (p.original_language) parts.push(`Language: ${p.original_language}`);
+ if (p.min_rating) parts.push(`Min rating: ${p.min_rating}`);
+ if (p.keywords?.length) parts.push(`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, intl);
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)}
+
+ )}
+ >
+ )}
+ >
+ ) : (
+ 0)
+ }
+ isReachingEnd={regular.isReachingEnd}
+ onScrollBottom={regular.fetchMore}
+ />
+ )}
>
);
};
diff --git a/src/hooks/useAiSearch.ts b/src/hooks/useAiSearch.ts
new file mode 100644
index 0000000000..36453dc666
--- /dev/null
+++ b/src/hooks/useAiSearch.ts
@@ -0,0 +1,70 @@
+import type {
+ MovieResult,
+ TvResult,
+ PersonResult,
+} 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: Array<{
+ 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) => {
+ const { data, error, isValidating } = useSWR(
+ query ? `ai-search-${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;
From 3cffaefdc18b7e055a2c0c107d40d20158734985 Mon Sep 17 00:00:00 2001
From: Richard Holmboe
Date: Sat, 18 Jul 2026 23:04:53 +0200
Subject: [PATCH 03/13] feat(ai): improve search volume/relevance and harden
provider handling
Search quality:
- Map LLM genre names to TMDb IDs per endpoint type (movie vs TV have
different IDs), so /discover actually returns results instead of matching
zero. A query like "dark dystopian sci-fi" now returns ~20 results
(was ~5, all from suggested titles only).
- Run movie/TV discover with their correct date params and genre IDs.
- Sort merged search results by matchScore so LLM-suggested titles surface
above discover results.
Provider robustness (helps search + recommendations):
- callLLM falls back to reasoning_content when content is empty, so
reasoning models (GLM, o-series) work.
- Connection test uses the OpenAI SDK, tolerates reasoning-model replies,
and surfaces the provider's real error on failure.
- GET /ai/settings returns hasApiKey (never the raw key); the test endpoint
falls back to the stored key so users can re-test without re-entering it.
Frontend shows a "key is saved" hint.
Co-Authored-By: Claude Fable 5
---
server/api/ai/index.ts | 6 +-
server/lib/aiRecommendations.ts | 83 +++++++++++++++++---
server/routes/ai.ts | 44 +++++++----
src/components/Settings/SettingsAi/index.tsx | 7 ++
4 files changed, 114 insertions(+), 26 deletions(-)
diff --git a/server/api/ai/index.ts b/server/api/ai/index.ts
index 4a6048ec05..eb5369bf94 100644
--- a/server/api/ai/index.ts
+++ b/server/api/ai/index.ts
@@ -111,7 +111,11 @@ export class OpenAICompatibleClient implements LLMClient {
max_tokens: 4000,
});
- const content = response.choices[0]?.message?.content;
+ // Reasoning models (e.g. GLM, o-series) may return their answer in
+ // `reasoning_content` with an empty `content`. Prefer `content`, fall
+ // back to `reasoning_content`.
+ const message = response.choices[0]?.message as any;
+ const content = message?.content ?? message?.reasoning_content;
if (!content) {
throw new Error('Empty response from LLM');
}
diff --git a/server/lib/aiRecommendations.ts b/server/lib/aiRecommendations.ts
index 6ab74e0564..76a71ab0d8 100644
--- a/server/lib/aiRecommendations.ts
+++ b/server/lib/aiRecommendations.ts
@@ -378,23 +378,83 @@ async function discoverByKeywords(keywords: string[], filters: RecommendationFil
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.min_rating) {
- const baseParams: any = {};
- if (params.genres) baseParams.genre = params.genres.join('|');
- if (params.year_from) baseParams.primaryReleaseDateGte = `${params.year_from}-01-01`;
- if (params.year_to) baseParams.primaryReleaseDateLte = `${params.year_to}-12-31`;
- if (params.min_rating) baseParams.voteAverageGte = params.min_rating;
- if (params.original_language) baseParams.originalLanguage = params.original_language;
- if (params.sort_by) baseParams.sortBy = params.sort_by;
+ // 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(baseParams),
- tmdb.getDiscoverTv(baseParams),
+ tmdb.getDiscoverMovies(buildParams('movie')),
+ tmdb.getDiscoverTv(buildParams('tv')),
]);
results.push(
@@ -586,7 +646,10 @@ function mergeSearchResults(tmdbResults: any[], suggestedTitles: any[]) {
}
}
- return Array.from(merged.values());
+ // 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) {
diff --git a/server/routes/ai.ts b/server/routes/ai.ts
index 8dddba0575..29bd688cde 100644
--- a/server/routes/ai.ts
+++ b/server/routes/ai.ts
@@ -20,13 +20,16 @@ aiRoutes.get('/settings', (req, res, next) => {
try {
const settings = getSettings();
- // Only expose non-sensitive settings
+ // 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,
@@ -114,34 +117,45 @@ aiRoutes.post('/test', async (req, res, next) => {
return res.status(400).json({ message: 'Provider configuration required' });
}
- // Build the client directly from the submitted form values so "Test Connection"
- // validates what the user entered, regardless of what's saved globally.
- // For local providers (Ollama/LM Studio) an API key is not required, so we
- // fall back to a harmless placeholder to satisfy the OpenAI SDK.
+ // 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 — this lets the user click "Test Connection"
+ // again without re-entering the key. A newly typed key takes precedence.
+ const storedSettings = getSettings();
+ const apiKey =
+ provider.apiKey || storedSettings.ai.provider.apiKey || 'sk-not-required';
const client = new OpenAI({
- apiKey: provider.apiKey || 'sk-not-required',
- baseURL: provider.baseUrl || 'https://api.openai.com/v1',
+ apiKey,
+ baseURL: provider.baseUrl || storedSettings.ai.provider.baseUrl || 'https://api.openai.com/v1',
});
- const model = provider.model || 'gpt-4o-mini';
+ 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: 'Say "ok"' }],
- max_tokens: 5,
+ messages: [{ role: 'user', content: 'Reply with the single word: ok' }],
+ max_tokens: 50,
});
- const success =
- response.choices[0]?.message?.content?.toLowerCase().includes('ok') ||
- false;
+ 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: success ? latency : undefined,
- error: success ? undefined : 'Connection test failed (no valid response)',
+ latency,
+ error: success ? undefined : 'Connection test failed (empty response)',
+ modelEcho: response.model,
+ responsePreview: content ? String(content).slice(0, 80) : undefined,
});
} catch (error) {
res.json({
diff --git a/src/components/Settings/SettingsAi/index.tsx b/src/components/Settings/SettingsAi/index.tsx
index 8ee458f89e..52a739ab2a 100644
--- a/src/components/Settings/SettingsAi/index.tsx
+++ b/src/components/Settings/SettingsAi/index.tsx
@@ -19,6 +19,7 @@ interface AiSettings {
provider: {
type: 'openai' | 'ollama' | 'openrouter' | 'custom';
apiKey?: string;
+ hasApiKey?: boolean;
baseUrl?: string;
model: string;
};
@@ -43,6 +44,7 @@ const messages = defineMessages('components.Settings.SettingsAi', {
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',
@@ -301,6 +303,11 @@ const SettingsAi = () => {
{intl.formatMessage(messages.apiKeyTip)}
+ {data?.provider?.hasApiKey && !values.provider.apiKey && (
+
+ {intl.formatMessage(messages.apiKeySet)}
+
+ )}
)}
From cbca46c4f55fedafbbfaad97538c7fd8b4efcc60 Mon Sep 17 00:00:00 2001
From: Richard Holmboe
Date: Sat, 18 Jul 2026 23:54:11 +0200
Subject: [PATCH 04/13] feat(ai): add recommendation feedback
(like/seen/dislike) and UI polish
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Feedback loop (frontend + backend):
- AiRecommendationCard wraps TitleCard and overlays 👍/👁️/👎 buttons in
TitleCard's own visual language (ghost, sm icon buttons, hover-reveal),
placed just below the MOVIE/blocklist row.
- useAiFeedback hook submits/deletes feedback; dislike and seen remove the
card optimistically, like marks it; toggle removes; errors roll back.
- /discover/ai-recommendations renders a grid of these cards (replacing the
generic ListView) so feedback is isolated to the AI page.
- Like-biasing: generateRecommendations resolves liked titles and injects a
"TITLES THE USER EXPLICITLY LIKED" section into the recommendation prompt,
so likes now influence future generation (dislike/seen were already filtered).
UI polish:
- Fix "Unknown Slider" in the Discover edit view by mapping AI_RECOMMENDATIONS
and AI_SEARCH in DiscoverSliderEdit.
- Add a hover tooltip ("Add to Blocklist") to the shared TitleCard blocklist
button, matching the remove-from-blocklist button.
Co-Authored-By: Claude Fable 5
---
server/api/ai/index.ts | 25 ++-
server/lib/aiRecommendations.ts | 51 +++++-
.../Discover/AiRecommendationCard/index.tsx | 163 ++++++++++++++++++
src/components/Discover/AiRecommendations.tsx | 66 ++++---
.../Discover/DiscoverSliderEdit/index.tsx | 4 +
src/components/TitleCard/index.tsx | 18 +-
src/hooks/useAiFeedback.ts | 33 ++++
7 files changed, 327 insertions(+), 33 deletions(-)
create mode 100644 src/components/Discover/AiRecommendationCard/index.tsx
create mode 100644 src/hooks/useAiFeedback.ts
diff --git a/server/api/ai/index.ts b/server/api/ai/index.ts
index eb5369bf94..41f330ff95 100644
--- a/server/api/ai/index.ts
+++ b/server/api/ai/index.ts
@@ -74,7 +74,8 @@ export interface LLMClient {
profile: TasteProfile,
history: WatchHistoryItem[],
filters: RecommendationFilters,
- maxResults: number
+ maxResults: number,
+ likedTitles?: { title: string; mediaType: 'movie' | 'tv' }[]
): Promise;
interpretSearchQuery(
query: string,
@@ -277,9 +278,16 @@ export class OpenAICompatibleClient implements LLMClient {
profile: TasteProfile,
history: WatchHistoryItem[],
filters: RecommendationFilters,
- maxResults: number
+ maxResults: number,
+ likedTitles?: { title: string; mediaType: 'movie' | 'tv' }[]
): Promise {
- const prompt = this.buildRecommendationsPrompt(profile, history, filters, maxResults);
+ const prompt = this.buildRecommendationsPrompt(
+ profile,
+ history,
+ filters,
+ maxResults,
+ likedTitles
+ );
try {
const parsed = await this.callForJSON([
@@ -378,17 +386,24 @@ Keywords should be highly specific niche terms (e.g., "cyberpunk", "time-loop",
profile: TasteProfile,
history: WatchHistoryItem[],
filters: RecommendationFilters,
- maxResults: number
+ 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)}
-VIEWING HISTORY (DO NOT recommend these exact titles):
+${likedText}VIEWING HISTORY (DO NOT recommend these exact titles):
${historyTitles}
${filtersText ? `FILTER CONSTRAINTS:\n${filtersText}\n` : ''}
diff --git a/server/lib/aiRecommendations.ts b/server/lib/aiRecommendations.ts
index 76a71ab0d8..3dc98ef995 100644
--- a/server/lib/aiRecommendations.ts
+++ b/server/lib/aiRecommendations.ts
@@ -150,6 +150,16 @@ export async function generateRecommendations(
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,
@@ -164,7 +174,8 @@ export async function generateRecommendations(
{ profile: profile, keywords } as any,
history,
filters,
- options?.limit || settings.ai.recommendations.maxResults
+ options?.limit || settings.ai.recommendations.maxResults,
+ likedTitles
);
logger.info(`[user ${userId}] Step 3/8 done. ${aiRecs.length} AI recs`, logTag);
@@ -357,6 +368,44 @@ async function getUserFeedback(userId: number) {
.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[] = [];
diff --git a/src/components/Discover/AiRecommendationCard/index.tsx b/src/components/Discover/AiRecommendationCard/index.tsx
new file mode 100644
index 0000000000..4ff1394a5d
--- /dev/null
+++ b/src/components/Discover/AiRecommendationCard/index.tsx
@@ -0,0 +1,163 @@
+import Button from '@app/components/Common/Button';
+import TitleCard from '@app/components/TitleCard';
+import Tooltip from '@app/components/Common/Tooltip';
+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
index 25f0648b6a..e5138b7f86 100644
--- a/src/components/Discover/AiRecommendations.tsx
+++ b/src/components/Discover/AiRecommendations.tsx
@@ -1,32 +1,46 @@
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 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.',
+ '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,
- isLoadingMore,
- isReachingEnd,
- titles,
- fetchMore,
- } = useDiscover(
- '/api/v1/discover/ai-recommendations'
+ 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 (
<>
@@ -36,15 +50,27 @@ const AiRecommendations = () => {
{intl.formatMessage(messages.airecommendationsDescription)}
-
+ {isLoadingInitialData && (
+
+ )}
+ {showEmpty && !isLoadingInitialData && (
+
+ {intl.formatMessage(messages.empty)}
+
+ )}
+ {visible.length > 0 && (
+
+ {visible.map((item) => (
+ -
+
+
+ ))}
+
+ )}
>
);
};
diff --git a/src/components/Discover/DiscoverSliderEdit/index.tsx b/src/components/Discover/DiscoverSliderEdit/index.tsx
index c24a0591ad..240de1471c 100644
--- a/src/components/Discover/DiscoverSliderEdit/index.tsx
+++ b/src/components/Discover/DiscoverSliderEdit/index.tsx
@@ -169,6 +169,10 @@ 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);
+ case DiscoverSliderType.AI_SEARCH:
+ return intl.formatMessage(sliderTitles.aisearch);
default:
return 'Unknown Slider';
}
diff --git a/src/components/TitleCard/index.tsx b/src/components/TitleCard/index.tsx
index 7f420047ff..bf81145908 100644
--- a/src/components/TitleCard/index.tsx
+++ b/src/components/TitleCard/index.tsx
@@ -427,14 +427,18 @@ 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;
From 2508f4fadcf004f836222abb9db6f636ff25491b Mon Sep 17 00:00:00 2001
From: Richard Holmboe
Date: Sun, 19 Jul 2026 00:53:07 +0200
Subject: [PATCH 05/13] fix(ai): rename minScore to minRating, extract i18n,
and resolve lint
Pre-PR cleanup of the AI feature:
- minScore -> minRating: the setting is wired to TMDb vote_average.gte, so
relabel and rescale it as a minimum rating (0-10, default 7) instead of a
0-1 "confidence score" that did nothing at the default. Updated across the
settings interface, route, UI, and docs. Also fixed the PUT handler to use
`!== undefined` so a 0 floor actually saves (the old truthiness check was
falsy for 0).
- i18n: run `pnpm i18n:extract` (the step skipped when the feature landed) so
all AI strings -- including the AI Recommendations Sync job label -- are in
en.json and exposed to Weblate. 58 keys added, none removed.
- Lint (52 -> 0 errors): convert relative imports to @server/@app aliases,
add `import type` where type-only, remove unused imports/vars, drop the
speculative userId scaffolding from the LLM client, restructure the three
SettingsAi toggles to the htmlFor+id a11y pattern, and disable
no-control-regex at the intentional control-char strip in JSON repair.
Verified: pnpm lint (0 errors), tsc --noEmit (clean), pnpm format:check (clean).
Co-Authored-By: Claude Fable 5
---
compose.ai.yaml | 2 +-
server/api/ai/index.ts | 60 ++++---
server/entity/AiRecommendation.ts | 10 +-
server/entity/UserFeedback.ts | 10 +-
server/job/schedule.ts | 105 ++++++------
server/lib/aiRecommendations.ts | 141 ++++++++++------
server/lib/settings/index.ts | 4 +-
.../1784393737543-AddAiRecommendations.ts | 4 +-
.../1784393737543-AddAiRecommendations.ts | 4 +-
server/routes/ai.ts | 97 ++++++-----
server/routes/discover.ts | 151 +++++++++---------
server/routes/index.ts | 2 +-
.../Discover/AiRecommendationCard/index.tsx | 5 +-
src/components/Discover/AiRecommendations.tsx | 4 +-
src/components/Discover/index.tsx | 9 +-
src/components/Search/index.tsx | 21 ++-
src/components/Settings/SettingsAi/index.tsx | 151 +++++++++++-------
src/components/TitleCard/index.tsx | 4 +-
src/hooks/useAiSearch.ts | 6 +-
src/i18n/locale/en.json | 58 +++++++
src/pages/settings/ai.tsx | 2 +-
21 files changed, 525 insertions(+), 325 deletions(-)
diff --git a/compose.ai.yaml b/compose.ai.yaml
index 91a1aa4c93..880657909a 100644
--- a/compose.ai.yaml
+++ b/compose.ai.yaml
@@ -27,7 +27,7 @@ services:
ollama:
image: ollama/ollama:latest
ports:
- - "11434:11434"
+ - '11434:11434'
volumes:
- ollama_data:/root/.ollama
diff --git a/server/api/ai/index.ts b/server/api/ai/index.ts
index 41f330ff95..2df575ba15 100644
--- a/server/api/ai/index.ts
+++ b/server/api/ai/index.ts
@@ -1,7 +1,7 @@
+import { getSettings } from '@server/lib/settings';
+import logger from '@server/logger';
import { OpenAI } from 'openai';
import type { ChatCompletionMessageParam } from 'openai/resources/chat/completions';
-import { getSettings } from '../../lib/settings';
-import logger from '../../logger';
export interface WatchHistoryItem {
tmdbId: number;
@@ -60,12 +60,12 @@ export interface SearchInterpretation {
min_rating?: number;
keywords?: string[];
};
- suggestedTitles: Array<{
+ suggestedTitles: {
title: string;
year?: number;
type: 'movie' | 'tv';
rationale: string;
- }>;
+ }[];
}
export interface LLMClient {
@@ -88,21 +88,22 @@ export class OpenAICompatibleClient implements LLMClient {
private client: OpenAI;
private model: string;
- constructor(userId?: number) {
+ constructor() {
const settings = getSettings();
const aiConfig = settings.ai;
- // For per-user config, we would extend this to check user settings
- // For now, using global config
this.client = new OpenAI({
- apiKey: aiConfig.provider.apiKey || process.env.OPENAI_API_KEY || 'sk-placeholder',
+ 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: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>
+ messages: { role: 'system' | 'user' | 'assistant'; content: string }[]
): Promise {
try {
const response = await this.client.chat.completions.create({
@@ -140,7 +141,10 @@ export class OpenAICompatibleClient implements LLMClient {
// Trim trailing commas before } or ]
s = s.replace(/,\s*([}\]])/g, '$1');
// Remove stray control characters
- s = s.replace(/[\x00-\x1f]/g, (ch) => (ch === '\n' || ch === '\t' ? ch : ''));
+ // 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;
}
@@ -159,7 +163,7 @@ export class OpenAICompatibleClient implements LLMClient {
try {
return JSON.parse(jsonStr);
- } catch (err) {
+ } 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);
@@ -199,7 +203,7 @@ export class OpenAICompatibleClient implements LLMClient {
}
// Remove a dangling trailing comma so the closers are valid
- let result = s.replace(/,\s*$/, '');
+ const result = s.replace(/,\s*$/, '');
// Close any open array then object, from innermost out
let suffix = '';
for (let i = 0; i < openArrays; i++) suffix += ']';
@@ -212,7 +216,7 @@ export class OpenAICompatibleClient implements LLMClient {
* system message if the first attempt yields invalid JSON.
*/
private async callForJSON(
- messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>
+ messages: { role: 'system' | 'user' | 'assistant'; content: string }[]
): Promise {
const maxAttempts = 2;
let lastError: Error | null = null;
@@ -241,10 +245,14 @@ export class OpenAICompatibleClient implements LLMClient {
];
}
}
- throw new Error(`LLM did not return valid JSON after ${maxAttempts} attempts: ${lastError?.message}`);
+ throw new Error(
+ `LLM did not return valid JSON after ${maxAttempts} attempts: ${lastError?.message}`
+ );
}
- async generateTasteProfile(history: WatchHistoryItem[]): Promise {
+ async generateTasteProfile(
+ history: WatchHistoryItem[]
+ ): Promise {
const prompt = this.buildTasteProfilePrompt(history);
try {
@@ -349,7 +357,10 @@ export class OpenAICompatibleClient implements LLMClient {
max_tokens: 5,
});
- return response.choices[0]?.message?.content?.toLowerCase().includes('ok') || false;
+ return (
+ response.choices[0]?.message?.content?.toLowerCase().includes('ok') ||
+ false
+ );
} catch (error) {
logger.error('LLM connection test failed:', error);
return false;
@@ -389,7 +400,9 @@ Keywords should be highly specific niche terms (e.g., "cyberpunk", "time-loop",
maxResults: number,
likedTitles?: { title: string; mediaType: 'movie' | 'tv' }[]
): string {
- const historyTitles = history.map((h) => `- ${h.title} (${h.year || 'N/A'})`).join('\n');
+ const historyTitles = history
+ .map((h) => `- ${h.title} (${h.year || 'N/A'})`)
+ .join('\n');
const filtersText = this.formatFilters(filters);
const likedText =
likedTitles && likedTitles.length > 0
@@ -423,7 +436,10 @@ Response MUST be valid JSON in this exact format:
}`;
}
- private buildSearchPrompt(query: string, history?: WatchHistoryItem[]): string {
+ 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`
@@ -473,7 +489,9 @@ If the query doesn't specify a parameter, set it to null or omit it. "sort_by" s
}
if (filters.yearRange) {
- parts.push(`- MUST be released between ${filters.yearRange[0]} and ${filters.yearRange[1]}`);
+ parts.push(
+ `- MUST be released between ${filters.yearRange[0]} and ${filters.yearRange[1]}`
+ );
}
if (filters.minRating) {
@@ -492,6 +510,6 @@ If the query doesn't specify a parameter, set it to null or omit it. "sort_by" s
}
}
-export function createLLMClient(userId?: number): LLMClient {
- return new OpenAICompatibleClient(userId);
+export function createLLMClient(): LLMClient {
+ return new OpenAICompatibleClient();
}
diff --git a/server/entity/AiRecommendation.ts b/server/entity/AiRecommendation.ts
index 875ed35da9..2e29ddde0e 100644
--- a/server/entity/AiRecommendation.ts
+++ b/server/entity/AiRecommendation.ts
@@ -1,14 +1,14 @@
+import type { MediaType } from '@server/constants/media';
+import { DbAwareColumn } from '@server/utils/DbColumnHelper';
import {
- Entity,
- PrimaryGeneratedColumn,
Column,
+ Entity,
Index,
- ManyToOne,
JoinColumn,
+ ManyToOne,
+ PrimaryGeneratedColumn,
} from 'typeorm';
import { User } from './User';
-import { MediaType } from '../constants/media';
-import { DbAwareColumn } from '../utils/DbColumnHelper';
@Entity('ai_recommendation')
@Index(['userId', 'mediaType'])
diff --git a/server/entity/UserFeedback.ts b/server/entity/UserFeedback.ts
index e1a56796af..396963fa4b 100644
--- a/server/entity/UserFeedback.ts
+++ b/server/entity/UserFeedback.ts
@@ -1,14 +1,14 @@
+import type { MediaType } from '@server/constants/media';
+import { DbAwareColumn } from '@server/utils/DbColumnHelper';
import {
- Entity,
- PrimaryGeneratedColumn,
Column,
- ManyToOne,
+ Entity,
JoinColumn,
+ ManyToOne,
+ PrimaryGeneratedColumn,
Unique,
} from 'typeorm';
import { User } from './User';
-import { MediaType } from '../constants/media';
-import { DbAwareColumn } from '../utils/DbColumnHelper';
@Entity('user_feedback')
@Unique(['userId', 'tmdbId', 'mediaType'])
diff --git a/server/job/schedule.ts b/server/job/schedule.ts
index fe9a6deca7..a86c6f11e6 100644
--- a/server/job/schedule.ts
+++ b/server/job/schedule.ts
@@ -2,7 +2,6 @@ import { MediaServerType } from '@server/constants/server';
import { UserType } from '@server/constants/user';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
-import { In } from 'typeorm';
import blocklistedTagsProcessor from '@server/job/blocklistedTagsProcessor';
import {
cleanupExpiredRecommendations,
@@ -24,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;
@@ -275,62 +275,77 @@ export const startJobs = (): void => {
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;
- }
-
- 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`, {
+ 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;
}
- 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`, {
+ logger.info('Starting scheduled job: AI Recommendations Sync', {
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}`, {
+ 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',
});
- } 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);
+ 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);
+ }
}
- }),
+ ),
});
}
diff --git a/server/lib/aiRecommendations.ts b/server/lib/aiRecommendations.ts
index 3dc98ef995..c628dc87a5 100644
--- a/server/lib/aiRecommendations.ts
+++ b/server/lib/aiRecommendations.ts
@@ -1,15 +1,15 @@
-import { getRepository } from '../datasource';
-import { MediaRequest } from '../entity/MediaRequest';
-import { Watchlist } from '../entity/Watchlist';
-import Media from '../entity/Media';
-import { AiRecommendation } from '../entity/AiRecommendation';
-import { UserFeedback } from '../entity/UserFeedback';
-import { User } from '../entity/User';
-import { MediaType } from '../constants/media';
-import { createLLMClient, WatchHistoryItem, RecommendationFilters } from '../api/ai';
-import TheMovieDb from '../api/themoviedb';
+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';
-import logger from '../logger';
interface GenerateOptions {
limit?: number;
@@ -75,7 +75,6 @@ export async function generateTasteProfile(
const topItems = scoredItems.slice(0, options?.maxHistoryItems || 40);
// 3. Fetch TMDb metadata for each item
- const settings = getSettings();
const tmdb = new TheMovieDb();
const enrichedItems: WatchHistoryItem[] = [];
@@ -92,8 +91,8 @@ export async function generateTasteProfile(
year: metadata.release_date
? new Date(metadata.release_date).getFullYear()
: metadata.first_air_date
- ? new Date(metadata.first_air_date).getFullYear()
- : undefined,
+ ? 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,
@@ -102,12 +101,15 @@ export async function generateTasteProfile(
playCount: item.score > 10 ? Math.floor(item.score / 10) : 1,
});
} catch (error) {
- logger.warn(`Failed to fetch TMDb metadata for tmdbId ${item.tmdbId}:`, error);
+ logger.warn(
+ `Failed to fetch TMDb metadata for tmdbId ${item.tmdbId}:`,
+ error
+ );
}
}
// 4. Call LLM with taste profile prompt
- const llm = createLLMClient(userId);
+ const llm = createLLMClient();
const tasteProfile = await llm.generateTasteProfile(enrichedItems);
return {
@@ -133,7 +135,10 @@ export async function generateRecommendations(
}
// 1. Generate taste profile
- logger.info(`[user ${userId}] Step 1/8: generating taste profile...`, logTag);
+ 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)}`,
@@ -164,12 +169,15 @@ export async function generateRecommendations(
const filters: RecommendationFilters = {
...options?.filters,
mediaType: 'both',
- minRating: settings.ai.recommendations.minScore,
+ 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(userId);
+ 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,
@@ -177,7 +185,10 @@ export async function generateRecommendations(
options?.limit || settings.ai.recommendations.maxResults,
likedTitles
);
- logger.info(`[user ${userId}] Step 3/8 done. ${aiRecs.length} AI recs`, logTag);
+ 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.
@@ -194,13 +205,24 @@ export async function generateRecommendations(
// 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);
+ 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);
+ 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);
+ const merged = mergeAndScoreRecommendations(
+ resolvedAiRecs,
+ tmdbRecs,
+ profile,
+ keywords
+ );
// 8. Filter out already watched/requested/disliked
logger.info(
@@ -223,7 +245,10 @@ export async function generateRecommendations(
return filtered;
} catch (error) {
- logger.error(`Failed to generate recommendations for user ${userId}:`, error);
+ logger.error(
+ `Failed to generate recommendations for user ${userId}:`,
+ error
+ );
throw error;
}
}
@@ -240,8 +265,10 @@ export async function aiSearch(
throw new Error('AI search is disabled');
}
- const llm = createLLMClient(userId);
- const userHistory = options?.includeHistory ? await getUserSignals(userId) : undefined;
+ const llm = createLLMClient();
+ const userHistory = options?.includeHistory
+ ? await getUserSignals(userId)
+ : undefined;
// 1. Interpret query
const interpretation = await llm.interpretSearchQuery(query, userHistory);
@@ -274,12 +301,15 @@ 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
+ 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);
+ const daysSinceRequest =
+ (Date.now() - request.createdAt.getTime()) / (1000 * 60 * 60 * 24);
score -= Math.min(daysSinceRequest / 365, 2); // Max penalty of 2
return score;
@@ -290,14 +320,17 @@ function calculateMediaScore(media: Media): number {
// Boost for recently added
if (media.mediaAddedAt) {
- const daysSinceAdded = (Date.now() - media.mediaAddedAt.getTime()) / (1000 * 60 * 60 * 24);
+ 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: Array<{ tmdbId: number; mediaType: string; title: string; score: number }>) {
+function scoreMediaItems(
+ items: { tmdbId: number; mediaType: string; title: string; score: number }[]
+) {
return items
.map((item) => ({
...item,
@@ -376,9 +409,7 @@ async function getUserFeedback(userId: number) {
async function getLikedTitles(
feedback: UserFeedback[]
): Promise<{ title: string; mediaType: 'movie' | 'tv' }[]> {
- const likes = feedback
- .filter((f) => f.feedbackType === 'like')
- .slice(0, 10);
+ const likes = feedback.filter((f) => f.feedbackType === 'like').slice(0, 10);
if (likes.length === 0) return [];
@@ -406,7 +437,10 @@ async function getLikedTitles(
return result;
}
-async function discoverByKeywords(keywords: string[], filters: RecommendationFilters) {
+async function discoverByKeywords(
+ keywords: string[],
+ filters: RecommendationFilters
+) {
const tmdb = new TheMovieDb();
const results: any[] = [];
@@ -496,7 +530,8 @@ async function discoverFromTmdb(params: any) {
else p.firstAirDateLte = d;
}
if (params.min_rating) p.voteAverageGte = params.min_rating;
- if (params.original_language) p.originalLanguage = params.original_language;
+ if (params.original_language)
+ p.originalLanguage = params.original_language;
if (params.sort_by) p.sortBy = params.sort_by;
return p;
};
@@ -507,7 +542,10 @@ async function discoverFromTmdb(params: any) {
]);
results.push(
- ...movieResults.results.map((r: any) => ({ ...r, media_type: 'movie' })),
+ ...movieResults.results.map((r: any) => ({
+ ...r,
+ media_type: 'movie',
+ })),
...tvResults.results.map((r: any) => ({ ...r, media_type: 'tv' }))
);
}
@@ -519,7 +557,7 @@ async function discoverFromTmdb(params: any) {
}
async function searchTmdbTitles(
- titles: Array<{ title: string; year?: number; type: string; rationale?: string }>
+ titles: { title: string; year?: number; type: string; rationale?: string }[]
) {
const tmdb = new TheMovieDb();
const results: any[] = [];
@@ -557,13 +595,13 @@ async function searchTmdbTitles(
* Returns only recommendations that resolved to a real TMDb entry.
*/
async function resolveRecommendationsViaTmdb(
- recs: Array<{
+ recs: {
title: string;
year?: number;
type?: string;
rationale?: string;
tmdbId?: number;
- }>
+ }[]
): Promise {
const tmdb = new TheMovieDb();
const resolved: any[] = [];
@@ -597,8 +635,7 @@ async function resolveRecommendationsViaTmdb(
}) ||
candidates.find((r: any) =>
rec.type
- ? r.media_type ===
- (rec.type === 'tv' ? 'tv' : 'movie')
+ ? r.media_type === (rec.type === 'tv' ? 'tv' : 'movie')
: true
) ||
candidates[0];
@@ -624,7 +661,12 @@ async function resolveRecommendationsViaTmdb(
return resolved;
}
-function mergeAndScoreRecommendations(aiRecs: any[], tmdbRecs: any[], profile: string, keywords: string[]) {
+function mergeAndScoreRecommendations(
+ aiRecs: any[],
+ tmdbRecs: any[],
+ profile: string,
+ keywords: string[]
+) {
const merged = new Map();
// Add AI recommendations with higher base score
@@ -656,8 +698,8 @@ function mergeAndScoreRecommendations(aiRecs: any[], tmdbRecs: any[], profile: s
year: rec.release_date
? new Date(rec.release_date).getFullYear()
: rec.first_air_date
- ? new Date(rec.first_air_date).getFullYear()
- : undefined,
+ ? new Date(rec.first_air_date).getFullYear()
+ : undefined,
mediaType: rec.media_type === 'tv' ? 'tv' : 'movie',
rationale: `Popular in genres related to your interests`,
score: 0.6,
@@ -718,7 +760,9 @@ async function filterExistingContent(recommendations: any[], userId: number) {
getRepository(UserFeedback)
.createQueryBuilder('feedback')
.where('feedback.userId = :userId', { userId })
- .andWhere('feedback.feedbackType IN (:...types)', { types: ['dislike', 'seen'] })
+ .andWhere('feedback.feedbackType IN (:...types)', {
+ types: ['dislike', 'seen'],
+ })
.getMany(),
]);
@@ -766,8 +810,7 @@ async function storeRecommendations(recommendations: any[], userId: number) {
const now = new Date();
for (const rec of recommendations) {
- const mediaType =
- rec.mediaType === 'tv' ? MediaType.TV : MediaType.MOVIE;
+ const mediaType = rec.mediaType === 'tv' ? MediaType.TV : MediaType.MOVIE;
const existing = await repository.findOne({
where: { userId, tmdbId: rec.tmdbId, mediaType },
diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts
index 617fd91ba8..306675e6af 100644
--- a/server/lib/settings/index.ts
+++ b/server/lib/settings/index.ts
@@ -365,7 +365,7 @@ export interface AiRecommendationSettings {
enabled: boolean;
sliderTitle: string;
maxResults: number;
- minScore: number;
+ minRating: number;
ttlDays: number;
}
@@ -493,7 +493,7 @@ class Settings {
enabled: false,
sliderTitle: 'Recommended for You',
maxResults: 20,
- minScore: 0.5,
+ minRating: 7,
ttlDays: 14,
},
search: {
diff --git a/server/migration/postgres/1784393737543-AddAiRecommendations.ts b/server/migration/postgres/1784393737543-AddAiRecommendations.ts
index 36d4d96ea9..d86e4a95ec 100644
--- a/server/migration/postgres/1784393737543-AddAiRecommendations.ts
+++ b/server/migration/postgres/1784393737543-AddAiRecommendations.ts
@@ -30,7 +30,9 @@ export class AddAiRecommendations1784393737543 implements MigrationInterface {
}
public async down(queryRunner: QueryRunner): Promise {
- await queryRunner.query(`ALTER TABLE "user_settings" DROP COLUMN "aiProviderConfig"`);
+ 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_CREATED"`);
diff --git a/server/migration/sqlite/1784393737543-AddAiRecommendations.ts b/server/migration/sqlite/1784393737543-AddAiRecommendations.ts
index 6919dbb49d..1d9b8efe13 100644
--- a/server/migration/sqlite/1784393737543-AddAiRecommendations.ts
+++ b/server/migration/sqlite/1784393737543-AddAiRecommendations.ts
@@ -30,7 +30,9 @@ export class AddAiRecommendations1784393737543 implements MigrationInterface {
}
public async down(queryRunner: QueryRunner): Promise {
- await queryRunner.query(`ALTER TABLE "user_settings" DROP COLUMN "aiProviderConfig"`);
+ 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_CREATED"`);
diff --git a/server/routes/ai.ts b/server/routes/ai.ts
index 29bd688cde..8d72ce988c 100644
--- a/server/routes/ai.ts
+++ b/server/routes/ai.ts
@@ -1,16 +1,16 @@
-import { getRepository } from '../datasource';
-import { OpenAI } from 'openai';
-import { User } from '../entity/User';
-import { UserFeedback } from '../entity/UserFeedback';
-import { AiRecommendation } from '../entity/AiRecommendation';
-import Media from '../entity/Media';
-import { MediaType } from '../constants/media';
-import { getSettings } from '../lib/settings';
-import { createLLMClient } from '../api/ai';
-import { aiSearch, generateRecommendations } from '../lib/aiRecommendations';
-import { mapSearchResults } from '../models/Search';
-import logger from '../logger';
+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 { getSettings } from '@server/lib/settings';
+import logger from '@server/logger';
+import { mapSearchResults } from '@server/models/Search';
import { Router } from 'express';
+import { OpenAI } from 'openai';
import { z } from 'zod';
const aiRoutes = Router();
@@ -35,7 +35,7 @@ aiRoutes.get('/settings', (req, res, next) => {
enabled: settings.ai.recommendations.enabled,
sliderTitle: settings.ai.recommendations.sliderTitle,
maxResults: settings.ai.recommendations.maxResults,
- minScore: settings.ai.recommendations.minScore,
+ minRating: settings.ai.recommendations.minRating,
ttlDays: settings.ai.recommendations.ttlDays,
},
search: {
@@ -58,25 +58,33 @@ aiRoutes.put('/settings', async (req, res, next) => {
// Update settings with request body
if (req.body.enabled !== undefined) settings.ai.enabled = req.body.enabled;
if (req.body.provider) {
- 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;
+ 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;
}
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;
+ 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.minScore)
- settings.ai.recommendations.minScore = req.body.recommendations.minScore;
+ 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;
+ if (req.body.search.enabled !== undefined)
+ settings.ai.search.enabled = req.body.search.enabled;
}
await settings.save();
@@ -94,7 +102,7 @@ aiRoutes.put('/settings', async (req, res, next) => {
enabled: settings.ai.recommendations.enabled,
sliderTitle: settings.ai.recommendations.sliderTitle,
maxResults: settings.ai.recommendations.maxResults,
- minScore: settings.ai.recommendations.minScore,
+ minRating: settings.ai.recommendations.minRating,
ttlDays: settings.ai.recommendations.ttlDays,
},
search: {
@@ -114,7 +122,9 @@ aiRoutes.post('/test', async (req, res, next) => {
const { provider } = req.body;
if (!provider) {
- return res.status(400).json({ message: 'Provider configuration required' });
+ return res
+ .status(400)
+ .json({ message: 'Provider configuration required' });
}
// Build the client from the submitted form values. The API key is not
@@ -126,9 +136,13 @@ aiRoutes.post('/test', async (req, res, next) => {
provider.apiKey || storedSettings.ai.provider.apiKey || 'sk-not-required';
const client = new OpenAI({
apiKey,
- baseURL: provider.baseUrl || storedSettings.ai.provider.baseUrl || 'https://api.openai.com/v1',
+ baseURL:
+ provider.baseUrl ||
+ storedSettings.ai.provider.baseUrl ||
+ 'https://api.openai.com/v1',
});
- const model = provider.model || storedSettings.ai.provider.model || 'gpt-4o-mini';
+ const model =
+ provider.model || storedSettings.ai.provider.model || 'gpt-4o-mini';
const startTime = Date.now();
@@ -280,16 +294,23 @@ aiRoutes.get('/feedback/stats', async (req, res, next) => {
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,
- }),
- ]);
+ 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,
@@ -356,7 +377,9 @@ aiRoutes.post('/regenerate', async (req, res, next) => {
const settings = getSettings();
if (!settings.ai.enabled || !settings.ai.recommendations.enabled) {
- return res.status(400).json({ message: 'AI recommendations are disabled' });
+ return res
+ .status(400)
+ .json({ message: 'AI recommendations are disabled' });
}
// Trigger recommendation generation
diff --git a/server/routes/discover.ts b/server/routes/discover.ts
index 3a4f106bd6..b111c56d7b 100644
--- a/server/routes/discover.ts
+++ b/server/routes/discover.ts
@@ -984,87 +984,88 @@ 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
- const tmdb = createTmdbWithRegionLanguage(req.user);
- const detailedResults = await Promise.all(
- stored.map(async (rec) => {
- 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;
- }
- })
- );
+discoverRoutes.get('/ai-recommendations', async (req, res, next) => {
+ try {
+ const user = req.user?.id;
+ if (!user) {
+ return res.status(401).json({ message: 'Unauthorized' });
+ }
- const validResults = detailedResults.filter((r) => r !== null);
+ const settings = getSettings();
- res.json({
+ 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: validResults.length,
- results: validResults,
+ totalResults: 0,
+ results: [],
});
- } catch (error) {
- logger.error('AI recommendations endpoint error:', error);
- next(error);
}
+
+ // 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
+ const tmdb = createTmdbWithRegionLanguage(req.user);
+ const detailedResults = await Promise.all(
+ stored.map(async (rec) => {
+ 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 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 63afe0322b..bbaf52a8ee 100644
--- a/server/routes/index.ts
+++ b/server/routes/index.ts
@@ -19,7 +19,6 @@ import { mapWatchProviderDetails } from '@server/models/common';
import overrideRuleRoutes from '@server/routes/overrideRule';
import settingsRoutes from '@server/routes/settings';
import watchlistRoutes from '@server/routes/watchlist';
-import aiRoutes from './ai';
import {
appDataPath,
appDataPermissions,
@@ -29,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';
diff --git a/src/components/Discover/AiRecommendationCard/index.tsx b/src/components/Discover/AiRecommendationCard/index.tsx
index 4ff1394a5d..459d853672 100644
--- a/src/components/Discover/AiRecommendationCard/index.tsx
+++ b/src/components/Discover/AiRecommendationCard/index.tsx
@@ -1,6 +1,6 @@
import Button from '@app/components/Common/Button';
-import TitleCard from '@app/components/TitleCard';
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';
@@ -49,8 +49,7 @@ const AiRecommendationCard = ({
// 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 mediaType = item.mediaType === 'tv' ? MediaType.TV : MediaType.MOVIE;
const handleClick = async (type: FeedbackType) => {
const previous = active;
diff --git a/src/components/Discover/AiRecommendations.tsx b/src/components/Discover/AiRecommendations.tsx
index e5138b7f86..449fe74442 100644
--- a/src/components/Discover/AiRecommendations.tsx
+++ b/src/components/Discover/AiRecommendations.tsx
@@ -50,9 +50,7 @@ const AiRecommendations = () => {
{intl.formatMessage(messages.airecommendationsDescription)}
- {isLoadingInitialData && (
-
- )}
+ {isLoadingInitialData && }
{showEmpty && !isLoadingInitialData && (
{intl.formatMessage(messages.empty)}
diff --git a/src/components/Discover/index.tsx b/src/components/Discover/index.tsx
index 200ba851dd..a2df6dfc82 100644
--- a/src/components/Discover/index.tsx
+++ b/src/components/Discover/index.tsx
@@ -400,7 +400,10 @@ const Discover = () => {
sliderComponent = (
@@ -410,7 +413,9 @@ const Discover = () => {
sliderComponent = (
diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx
index 7fb46bd896..d8b8ef6463 100644
--- a/src/components/Search/index.tsx
+++ b/src/components/Search/index.tsx
@@ -3,7 +3,9 @@ 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 useAiSearch, {
+ type AiSearchInterpretation,
+} from '@app/hooks/useAiSearch';
import useDiscover from '@app/hooks/useDiscover';
import defineMessages from '@app/utils/defineMessages';
import { SparklesIcon } from '@heroicons/react/24/solid';
@@ -14,7 +16,7 @@ import type {
} from '@server/models/Search';
import { useRouter } from 'next/router';
import { useState } from 'react';
-import { useIntl, type IntlShape } from 'react-intl';
+import { useIntl } from 'react-intl';
const messages = defineMessages('components.Search', {
search: 'Search',
@@ -24,25 +26,22 @@ const messages = defineMessages('components.Search', {
'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.',
+ 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.',
});
// Build a human-readable summary of how the AI parsed the query.
const formatInterpretation = (
- interp: AiSearchInterpretation | undefined,
- _intl: IntlShape
+ interp: AiSearchInterpretation | undefined
): string | null => {
if (!interp?.discoverParams) return null;
const p = interp.discoverParams;
const parts: string[] = [];
if (p.genres?.length) parts.push(`Genres: ${p.genres.join(', ')}`);
if (p.year_from || p.year_to) {
- parts.push(
- `Years: ${p.year_from ?? '…'}–${p.year_to ?? '…'}`
- );
+ parts.push(`Years: ${p.year_from ?? '…'}–${p.year_to ?? '…'}`);
}
if (p.original_language) parts.push(`Language: ${p.original_language}`);
if (p.min_rating) parts.push(`Min rating: ${p.min_rating}`);
@@ -66,7 +65,7 @@ const Search = () => {
// 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, intl);
+ const interpretation = formatInterpretation(ai.data?.interpretation);
return (
<>
diff --git a/src/components/Settings/SettingsAi/index.tsx b/src/components/Settings/SettingsAi/index.tsx
index 52a739ab2a..20c399576c 100644
--- a/src/components/Settings/SettingsAi/index.tsx
+++ b/src/components/Settings/SettingsAi/index.tsx
@@ -1,9 +1,7 @@
import Button from '@app/components/Common/Button';
import PageTitle from '@app/components/Common/PageTitle';
import SensitiveInput from '@app/components/Common/SensitiveInput';
-import SettingsBadge from '@app/components/Settings/SettingsBadge';
import useToasts from '@app/hooks/useToasts';
-import { useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
@@ -11,7 +9,7 @@ import { ArrowPathIcon } from '@heroicons/react/24/solid';
import axios from 'axios';
import { Form, Formik } from 'formik';
import { useIntl } from 'react-intl';
-import useSWR, { mutate } from 'swr';
+import useSWR from 'swr';
import * as Yup from 'yup';
interface AiSettings {
@@ -27,7 +25,7 @@ interface AiSettings {
enabled: boolean;
sliderTitle: string;
maxResults: number;
- minScore: number;
+ minRating: number;
ttlDays: number;
};
search: {
@@ -37,11 +35,13 @@ interface AiSettings {
const messages = defineMessages('components.Settings.SettingsAi', {
aiSettings: 'AI Settings',
- aiSettingsDescription: 'Configure AI-powered recommendations and search features.',
+ aiSettingsDescription:
+ 'Configure AI-powered recommendations and search features.',
enabled: 'Enable AI Features',
enabledTip: 'Enable AI-powered recommendations and search',
providerType: 'AI Provider',
- providerTypeTip: 'Choose your AI provider (OpenAI, Ollama, OpenRouter, or custom)',
+ 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.',
@@ -60,8 +60,9 @@ const messages = defineMessages('components.Settings.SettingsAi', {
sliderTitleTip: 'Title for the recommendations slider on the discover page',
maxResults: 'Max Results',
maxResultsTip: 'Maximum number of recommendations to generate per user',
- minScore: 'Minimum Score',
- minScoreTip: 'Minimum confidence score for recommendations (0.0 - 1.0)',
+ 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.',
@@ -73,7 +74,7 @@ const messages = defineMessages('components.Settings.SettingsAi', {
toastSettingsFailure: 'Something went wrong while saving AI settings.',
validationModelRequired: 'You must provide a model name',
validationMaxResults: 'Max results must be between 1 and 50',
- validationMinScore: 'Minimum score must be between 0 and 1',
+ validationMinRating: 'Minimum rating must be between 0 and 10',
testingConnection: 'Testing AI connection...',
connectionTestSuccess: 'Connection successful! Latency: {latency}ms',
connectionTestFailure: 'Connection failed: {error}',
@@ -81,23 +82,24 @@ const messages = defineMessages('components.Settings.SettingsAi', {
const SettingsAi = () => {
const { addToast } = useToasts();
- const { hasPermission } = useUser();
const intl = useIntl();
- const { data, error, mutate } = useSWR
('/api/v1/ai/settings');
+ 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)),
+ 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(),
- minScore: Yup.number()
- .min(0, intl.formatMessage(messages.validationMinScore))
- .max(1, intl.formatMessage(messages.validationMinScore))
+ 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))
@@ -130,7 +132,7 @@ const SettingsAi = () => {
{ appearance: 'error', autoDismiss: true }
);
}
- } catch (error) {
+ } catch {
addToast(intl.formatMessage(messages.connectionFailure), {
appearance: 'error',
autoDismiss: true,
@@ -139,7 +141,11 @@ const SettingsAi = () => {
};
if (!data) {
- return Loading...
;
+ return (
+
+ Loading...
+
+ );
}
return (
@@ -170,20 +176,14 @@ const SettingsAi = () => {
}
}}
>
- {({
- values,
- errors,
- touched,
- isSubmitting,
- handleSubmit,
- handleChange,
- setFieldValue,
- }) => (
+ {({ values, errors, touched, isSubmitting, handleChange }) => (