Add RecommendationPipeline

This commit is contained in:
2026-08-12 21:15:21 +02:00
parent 5edeb0ebb9
commit 62848f6ecd
2 changed files with 416 additions and 0 deletions

View 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,
};
}