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(); }); it('logs and continues when one recommendation fails, instead of aborting the rest', async () => { const radarr = { lookup: vi .fn() .mockRejectedValueOnce(new Error('radarr down')) .mockResolvedValueOnce({ title: 'Arrival', tmdbId: 2, qualityProfileId: 1, rootFolderPath: '/movies', monitored: true, addOptions: { searchForMovie: true }, }), addMovie: vi.fn().mockResolvedValue(undefined), } as unknown as RadarrClient; const logger = { error: vi.fn() } as unknown as Logger; const pipeline = new RecommendationPipeline(makeTracer(), 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: 'r1' }, { title: 'Arrival', year: 2016, tmdbId: 2, reason: 'r2' }, ]); expect(logger.error).toHaveBeenCalledWith( expect.objectContaining({ tmdbId: 1, title: 'Dune' }), 'Radarr auto-request failed' ); expect(radarr.lookup).toHaveBeenCalledTimes(2); expect(radarr.addMovie).toHaveBeenCalledTimes(1); }); }); 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()); }); });