mirror of
https://github.com/FJurmanovic/sugarr.git
synced 2026-11-10 23:31:08 +00:00
Add RecommendationPipeline
This commit is contained in:
170
src/app/pipelines/RecommendationPipeline.ts
Normal file
170
src/app/pipelines/RecommendationPipeline.ts
Normal file
@@ -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<RecommendationRequest, Recommendation[]> {
|
||||
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<Recommendation[]> {
|
||||
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<number>(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<void> {
|
||||
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<Recommendation[]> {
|
||||
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<number>(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<number>, count: number): Promise<MovieSuggestion[]> {
|
||||
const freq = new Map<number, number>();
|
||||
const meta = new Map<number, TMDbMovie>();
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
246
test/app/pipelines/RecommendationPipeline.test.ts
Normal file
246
test/app/pipelines/RecommendationPipeline.test.ts
Normal file
@@ -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());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user