From 62848f6ecd2b1c968b21cc148ecd6497530398c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=20Jurmanovi=C4=87?= Date: Wed, 12 Aug 2026 21:15:21 +0200 Subject: [PATCH] Add RecommendationPipeline --- src/app/pipelines/RecommendationPipeline.ts | 170 ++++++++++++ .../pipelines/RecommendationPipeline.test.ts | 246 ++++++++++++++++++ 2 files changed, 416 insertions(+) create mode 100644 src/app/pipelines/RecommendationPipeline.ts create mode 100644 test/app/pipelines/RecommendationPipeline.test.ts diff --git a/src/app/pipelines/RecommendationPipeline.ts b/src/app/pipelines/RecommendationPipeline.ts new file mode 100644 index 0000000..c73c9dc --- /dev/null +++ b/src/app/pipelines/RecommendationPipeline.ts @@ -0,0 +1,170 @@ +import { LlmPipeline } from '../../core/llm/LlmPipeline'; +import type { RunContext } from '../../core/llm/RunContext'; +import type { JellyfinClient } from '../clients/jellyfin/JellyfinClient'; +import type { TmdbClient } from '../clients/tmdb/TmdbClient'; +import type { RadarrClient } from '../clients/radarr/RadarrClient'; +import type { MovieRecommenderTool } from '../tools/MovieRecommenderTool'; +import type { MovieSearchTool } from '../tools/MovieSearchTool'; +import type { Movie, MovieSuggestion, Recommendation, TMDbMovie } from '../models'; +import type { Tracer } from '../../core/llm/Tracer'; +import type { Logger } from '../../core/logging/logger'; + +export interface RecommendationRequest { + count: number; + useLlm: boolean; +} + +export interface RecommendationPipelineOptions { + jellyfin: JellyfinClient; + tmdb: TmdbClient; + radarr?: RadarrClient; + tool: MovieRecommenderTool; + searchTool: MovieSearchTool; + minRatedMovies: number; + maxRetry: number; +} + +export class RecommendationPipeline extends LlmPipeline { + protected readonly name = 'recommendation-pipeline'; + + constructor( + tracer: Tracer, + logger: Logger, + private readonly options: RecommendationPipelineOptions + ) { + super(tracer, logger); + } + + protected async runInternal(input: RecommendationRequest, ctx: RunContext): Promise { + const favorites = await this.options.jellyfin.getFavoriteMovies(); + const useFavoritesAsSignal = favorites.length >= this.options.minRatedMovies; + const watched = useFavoritesAsSignal ? [] : await this.options.jellyfin.getRecentlyWatched(); + const signal = useFavoritesAsSignal ? favorites : watched; + + const library = await this.options.jellyfin.getAllMovies(); + const exclude = new Set(toTmdbIds(library)); + + if (this.options.radarr) { + const existing = await this.options.radarr.getExisting(); + for (const m of existing) exclude.add(m.tmdbId); + } + + const recommendations: Recommendation[] = []; + const seen: Movie[] = []; + + for (let attempt = 0; attempt <= this.options.maxRetry && recommendations.length < input.count; attempt++) { + const remaining = input.count - recommendations.length; + + let suggestions: MovieSuggestion[]; + if (input.useLlm) { + suggestions = await this.runTool( + this.options.tool, + { favorites, watched, library, previouslySuggested: seen, count: remaining }, + ctx + ); + } else { + suggestions = await this.tmdbFallback(signal, exclude, remaining); + } + + if (suggestions.length === 0) break; + + for (const suggestion of suggestions) { + if (recommendations.length >= input.count) break; + const resolved = await this.options.tmdb.searchMovie(suggestion.title, suggestion.year); + if (!resolved) continue; + seen.push(toSeenMovie(resolved)); + if (exclude.has(resolved.id)) continue; + exclude.add(resolved.id); + recommendations.push({ + title: resolved.title, + year: resolved.year, + tmdbId: resolved.id, + reason: suggestion.reason, + }); + } + } + + return recommendations; + } + + async autoRequest(recommendations: Recommendation[]): Promise { + if (!this.options.radarr) return; + for (const rec of recommendations) { + const movie = await this.options.radarr.lookup(rec.tmdbId); + await this.options.radarr.addMovie(movie); + } + } + + async searchAsync(query: string, count: number): Promise { + const trace = this.tracer.startTrace(`${this.name}-search`); + const ctx: RunContext = { trace, logger: this.logger }; + try { + const library = await this.options.jellyfin.getAllMovies(); + const exclude = new Set(toTmdbIds(library)); + const suggestions = await this.runTool(this.options.searchTool, { query, count }, ctx); + + const recommendations: Recommendation[] = []; + for (const suggestion of suggestions) { + if (recommendations.length >= count) break; + const resolved = await this.options.tmdb.searchMovie(suggestion.title, suggestion.year); + if (!resolved || exclude.has(resolved.id)) continue; + exclude.add(resolved.id); + recommendations.push({ + title: resolved.title, + year: resolved.year, + tmdbId: resolved.id, + reason: suggestion.reason, + }); + } + return recommendations; + } finally { + await this.tracer.flush(); + } + } + + private async tmdbFallback(signals: Movie[], exclude: Set, count: number): Promise { + const freq = new Map(); + const meta = new Map(); + + for (const m of signals) { + const tmdbId = Number(m.tmdbId); + if (!tmdbId) continue; + const [similar, recommended] = await Promise.all([ + this.options.tmdb.getSimilar(tmdbId), + this.options.tmdb.getRecommendations(tmdbId), + ]); + for (const tm of [...similar, ...recommended]) { + if (!exclude.has(tm.id)) { + freq.set(tm.id, (freq.get(tm.id) ?? 0) + 1); + meta.set(tm.id, tm); + } + } + } + + const sorted = [...freq.entries()].sort((a, b) => b[1] - a[1]); + const suggestions: MovieSuggestion[] = []; + for (const [id] of sorted) { + if (suggestions.length >= count) break; + const tm = meta.get(id)!; + suggestions.push({ title: tm.title, year: tm.year, reason: 'Recommended by TMDb' }); + } + return suggestions; + } +} + +function toTmdbIds(movies: Movie[]): number[] { + return movies.map((m) => Number(m.tmdbId)).filter((id) => Number.isFinite(id) && id !== 0); +} + +function toSeenMovie(resolved: TMDbMovie): Movie { + return { + name: resolved.title, + year: resolved.year, + imdbId: '', + tmdbId: String(resolved.id), + genres: [], + communityRating: 0, + isFavorite: false, + playCount: 0, + }; +} diff --git a/test/app/pipelines/RecommendationPipeline.test.ts b/test/app/pipelines/RecommendationPipeline.test.ts new file mode 100644 index 0000000..6c88464 --- /dev/null +++ b/test/app/pipelines/RecommendationPipeline.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, it, vi } from 'vitest'; +import { RecommendationPipeline } from '../../../src/app/pipelines/RecommendationPipeline'; +import type { JellyfinClient } from '../../../src/app/clients/jellyfin/JellyfinClient'; +import type { TmdbClient } from '../../../src/app/clients/tmdb/TmdbClient'; +import type { RadarrClient } from '../../../src/app/clients/radarr/RadarrClient'; +import type { MovieRecommenderTool } from '../../../src/app/tools/MovieRecommenderTool'; +import type { MovieSearchTool } from '../../../src/app/tools/MovieSearchTool'; +import type { Movie, TMDbMovie } from '../../../src/app/models'; +import type { Tracer, TraceHandle } from '../../../src/core/llm/Tracer'; +import type { Logger } from '../../../src/core/logging/logger'; + +const movie = (name: string, year: number, tmdbId: string): Movie => ({ + name, + year, + imdbId: '', + tmdbId, + genres: [], + communityRating: 0, + isFavorite: false, + playCount: 0, +}); + +function makeTracer() { + const trace = { id: 'trace-1', startGeneration: vi.fn() } as unknown as TraceHandle; + const tracer = { + startTrace: vi.fn().mockReturnValue(trace), + flush: vi.fn().mockResolvedValue(undefined), + } as unknown as Tracer; + return tracer; +} + +describe('RecommendationPipeline.runAsync', () => { + it('uses favorites as the signal when there are enough, resolves suggestions via TMDb, and excludes library/Radarr matches', async () => { + const jellyfin = { + getFavoriteMovies: vi.fn().mockResolvedValue([movie('Dune', 2021, '1')]), + getRecentlyWatched: vi.fn(), + getAllMovies: vi.fn().mockResolvedValue([movie('Arrival', 2016, '2')]), + } as unknown as JellyfinClient; + + const searchMovie = vi + .fn() + .mockResolvedValueOnce({ id: 3, title: 'Interstellar', year: 2014 } as TMDbMovie); + const tmdb = { searchMovie } as unknown as TmdbClient; + + const radarr = { + getExisting: vi.fn().mockResolvedValue([{ tmdbId: 4 }]), + } as unknown as RadarrClient; + + const tool = { + runAsync: vi.fn().mockResolvedValue([{ title: 'Interstellar', year: 2014, reason: 'space epic' }]), + } as unknown as MovieRecommenderTool; + const searchTool = { runAsync: vi.fn() } as unknown as MovieSearchTool; + + const pipeline = new RecommendationPipeline(makeTracer(), {} as Logger, { + jellyfin, + tmdb, + radarr, + tool, + searchTool, + minRatedMovies: 1, + maxRetry: 2, + }); + + const result = await pipeline.runAsync({ count: 1, useLlm: true }); + + expect(jellyfin.getRecentlyWatched).not.toHaveBeenCalled(); + expect(result).toEqual([{ title: 'Interstellar', year: 2014, tmdbId: 3, reason: 'space epic' }]); + expect(tool.runAsync).toHaveBeenCalledWith( + expect.objectContaining({ favorites: [movie('Dune', 2021, '1')], count: 1 }), + expect.anything() + ); + }); + + it('falls back to recently-watched as the signal when favorites are below the threshold', async () => { + const jellyfin = { + getFavoriteMovies: vi.fn().mockResolvedValue([]), + getRecentlyWatched: vi.fn().mockResolvedValue([movie('Arrival', 2016, '2')]), + getAllMovies: vi.fn().mockResolvedValue([]), + } as unknown as JellyfinClient; + const tmdb = { searchMovie: vi.fn().mockResolvedValue(undefined) } as unknown as TmdbClient; + const tool = { runAsync: vi.fn().mockResolvedValue([]) } as unknown as MovieRecommenderTool; + const searchTool = { runAsync: vi.fn() } as unknown as MovieSearchTool; + + const pipeline = new RecommendationPipeline(makeTracer(), {} as Logger, { + jellyfin, + tmdb, + tool, + searchTool, + minRatedMovies: 5, + maxRetry: 0, + }); + + await pipeline.runAsync({ count: 1, useLlm: true }); + + expect(jellyfin.getRecentlyWatched).toHaveBeenCalled(); + expect(tool.runAsync).toHaveBeenCalledWith( + expect.objectContaining({ watched: [movie('Arrival', 2016, '2')] }), + expect.anything() + ); + }); + + it('retries up to maxRetry times, excluding already-suggested movies, until count is reached', async () => { + const jellyfin = { + getFavoriteMovies: vi.fn().mockResolvedValue([movie('Dune', 2021, '1')]), + getRecentlyWatched: vi.fn(), + getAllMovies: vi.fn().mockResolvedValue([]), + } as unknown as JellyfinClient; + + const tmdb = { + searchMovie: vi + .fn() + .mockResolvedValueOnce({ id: 10, title: 'AlreadyInLibrary', year: 2000 } as TMDbMovie) + .mockResolvedValueOnce({ id: 11, title: 'NewPick', year: 2022 } as TMDbMovie), + } as unknown as TmdbClient; + + const tool = { + runAsync: vi + .fn() + .mockResolvedValueOnce([{ title: 'AlreadyInLibrary', year: 2000, reason: 'r1' }]) + .mockResolvedValueOnce([{ title: 'NewPick', year: 2022, reason: 'r2' }]), + } as unknown as MovieRecommenderTool; + const searchTool = { runAsync: vi.fn() } as unknown as MovieSearchTool; + + const pipeline = new RecommendationPipeline(makeTracer(), {} as Logger, { + jellyfin, + tmdb, + tool, + searchTool, + minRatedMovies: 1, + maxRetry: 2, + }); + + // Simulate id 10 already excluded (already in library) by pre-seeding via getAllMovies + jellyfin.getAllMovies = vi.fn().mockResolvedValue([movie('AlreadyInLibrary', 2000, '10')]); + + const result = await pipeline.runAsync({ count: 1, useLlm: true }); + + expect(tool.runAsync).toHaveBeenCalledTimes(2); + expect(result).toEqual([{ title: 'NewPick', year: 2022, tmdbId: 11, reason: 'r2' }]); + }); + + it('uses the TMDb similarity/frequency fallback when useLlm is false', async () => { + const jellyfin = { + getFavoriteMovies: vi.fn().mockResolvedValue([movie('Dune', 2021, '1')]), + getRecentlyWatched: vi.fn(), + getAllMovies: vi.fn().mockResolvedValue([]), + } as unknown as JellyfinClient; + + const tmdb = { + getSimilar: vi.fn().mockResolvedValue([{ id: 20, title: 'Similar A', year: 2010 } as TMDbMovie]), + getRecommendations: vi + .fn() + .mockResolvedValue([{ id: 20, title: 'Similar A', year: 2010 } as TMDbMovie]), + searchMovie: vi.fn().mockResolvedValue({ id: 20, title: 'Similar A', year: 2010 } as TMDbMovie), + } as unknown as TmdbClient; + + const tool = { runAsync: vi.fn() } as unknown as MovieRecommenderTool; + const searchTool = { runAsync: vi.fn() } as unknown as MovieSearchTool; + + const pipeline = new RecommendationPipeline(makeTracer(), {} as Logger, { + jellyfin, + tmdb, + tool, + searchTool, + minRatedMovies: 1, + maxRetry: 0, + }); + + const result = await pipeline.runAsync({ count: 1, useLlm: false }); + + expect(tool.runAsync).not.toHaveBeenCalled(); + expect(result).toEqual([{ title: 'Similar A', year: 2010, tmdbId: 20, reason: 'Recommended by TMDb' }]); + }); +}); + +describe('RecommendationPipeline.autoRequest', () => { + it('looks up and adds each recommendation to Radarr when Radarr is configured', async () => { + const radarr = { + lookup: vi.fn().mockResolvedValue({ + title: 'Dune', + tmdbId: 1, + qualityProfileId: 1, + rootFolderPath: '/movies', + monitored: true, + addOptions: { searchForMovie: true }, + }), + addMovie: vi.fn().mockResolvedValue(undefined), + } as unknown as RadarrClient; + + const pipeline = new RecommendationPipeline(makeTracer(), {} as Logger, { + jellyfin: {} as JellyfinClient, + tmdb: {} as TmdbClient, + radarr, + tool: {} as MovieRecommenderTool, + searchTool: {} as MovieSearchTool, + minRatedMovies: 1, + maxRetry: 0, + }); + + await pipeline.autoRequest([{ title: 'Dune', year: 2021, tmdbId: 1, reason: 'r' }]); + + expect(radarr.lookup).toHaveBeenCalledWith(1); + expect(radarr.addMovie).toHaveBeenCalled(); + }); + + it('does nothing when Radarr is not configured', async () => { + const pipeline = new RecommendationPipeline(makeTracer(), {} as Logger, { + jellyfin: {} as JellyfinClient, + tmdb: {} as TmdbClient, + tool: {} as MovieRecommenderTool, + searchTool: {} as MovieSearchTool, + minRatedMovies: 1, + maxRetry: 0, + }); + + await expect(pipeline.autoRequest([{ title: 'Dune', year: 2021, tmdbId: 1, reason: 'r' }])).resolves.toBeUndefined(); + }); +}); + +describe('RecommendationPipeline.searchAsync', () => { + it('runs the search tool, resolves via TMDb, and excludes library matches', async () => { + const jellyfin = { + getAllMovies: vi.fn().mockResolvedValue([movie('AlreadyHave', 1999, '5')]), + } as unknown as JellyfinClient; + const tmdb = { + searchMovie: vi.fn().mockResolvedValue({ id: 6, title: 'New Find', year: 2020 } as TMDbMovie), + } as unknown as TmdbClient; + const searchTool = { + runAsync: vi.fn().mockResolvedValue([{ title: 'New Find', year: 2020, reason: 'matches query' }]), + } as unknown as MovieSearchTool; + + const pipeline = new RecommendationPipeline(makeTracer(), {} as Logger, { + jellyfin, + tmdb, + tool: {} as MovieRecommenderTool, + searchTool, + minRatedMovies: 1, + maxRetry: 0, + }); + + const result = await pipeline.searchAsync('sci-fi like Dune', 5); + + expect(result).toEqual([{ title: 'New Find', year: 2020, tmdbId: 6, reason: 'matches query' }]); + expect(searchTool.runAsync).toHaveBeenCalledWith({ query: 'sci-fi like Dune', count: 5 }, expect.anything()); + }); +});