mirror of
https://github.com/FJurmanovic/sugarr.git
synced 2026-11-11 15:51:09 +00:00
Each pipeline invocation (runAsync/searchAsync) now gets a fresh sessionId, wrapped via the new Tracer.runWithSession() (implemented with @langfuse/tracing's propagateAttributes, since v5 has no direct sessionId/tags option on startObservation) so it shows up in Langfuse's Sessions view. The session is also tagged with what triggered the run: "cron", "api-list", "api-trigger", "api-search". Verified: full test suite (79/79) passing, tsc/eslint clean, real docker compose build + container smoke test against /health.
431 lines
16 KiB
TypeScript
431 lines
16 KiB
TypeScript
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),
|
|
runWithSession: vi.fn().mockImplementation((_session: unknown, fn: () => unknown) => fn()),
|
|
} 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' }]);
|
|
});
|
|
|
|
it('continues with an incomplete exclusion set when fetching the library fails', async () => {
|
|
const jellyfin = {
|
|
getFavoriteMovies: vi.fn().mockResolvedValue([movie('Dune', 2021, '1')]),
|
|
getRecentlyWatched: vi.fn(),
|
|
getAllMovies: vi.fn().mockRejectedValue(new Error('jellyfin down')),
|
|
} as unknown as JellyfinClient;
|
|
const tmdb = {
|
|
searchMovie: vi.fn().mockResolvedValue({ id: 3, title: 'Interstellar', year: 2014 } as TMDbMovie),
|
|
} as unknown as TmdbClient;
|
|
const tool = {
|
|
runAsync: vi.fn().mockResolvedValue([{ title: 'Interstellar', year: 2014, reason: 'r' }]),
|
|
} as unknown as MovieRecommenderTool;
|
|
const searchTool = { runAsync: vi.fn() } as unknown as MovieSearchTool;
|
|
const logger = { warn: vi.fn() } as unknown as Logger;
|
|
|
|
const pipeline = new RecommendationPipeline(makeTracer(), logger, {
|
|
jellyfin,
|
|
tmdb,
|
|
tool,
|
|
searchTool,
|
|
minRatedMovies: 1,
|
|
maxRetry: 0,
|
|
});
|
|
|
|
const result = await pipeline.runAsync({ count: 1, useLlm: true });
|
|
|
|
expect(result).toEqual([{ title: 'Interstellar', year: 2014, tmdbId: 3, reason: 'r' }]);
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
expect.objectContaining({ err: expect.any(Error) }),
|
|
'Could not fetch library; exclusion may be incomplete'
|
|
);
|
|
});
|
|
|
|
it('skips a suggestion whose TMDb resolution fails instead of aborting the run', 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()
|
|
.mockRejectedValueOnce(new Error('tmdb down'))
|
|
.mockResolvedValueOnce({ id: 3, title: 'Interstellar', year: 2014 } as TMDbMovie),
|
|
} as unknown as TmdbClient;
|
|
const tool = {
|
|
runAsync: vi.fn().mockResolvedValue([
|
|
{ title: 'Unresolvable', year: 1999, reason: 'r1' },
|
|
{ title: 'Interstellar', year: 2014, reason: 'r2' },
|
|
]),
|
|
} as unknown as MovieRecommenderTool;
|
|
const searchTool = { runAsync: vi.fn() } as unknown as MovieSearchTool;
|
|
const logger = { warn: vi.fn() } as unknown as Logger;
|
|
|
|
const pipeline = new RecommendationPipeline(makeTracer(), logger, {
|
|
jellyfin,
|
|
tmdb,
|
|
tool,
|
|
searchTool,
|
|
minRatedMovies: 1,
|
|
maxRetry: 0,
|
|
});
|
|
|
|
const result = await pipeline.runAsync({ count: 2, useLlm: true });
|
|
|
|
expect(result).toEqual([{ title: 'Interstellar', year: 2014, tmdbId: 3, reason: 'r2' }]);
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
expect.objectContaining({ err: expect.any(Error), title: 'Unresolvable' }),
|
|
'TMDb search failed, skipping suggestion'
|
|
);
|
|
});
|
|
|
|
it('skips a signal movie whose TMDb similar/recommendations lookup fails in the fallback path', async () => {
|
|
const jellyfin = {
|
|
getFavoriteMovies: vi
|
|
.fn()
|
|
.mockResolvedValue([movie('Broken', 2000, '1'), movie('Dune', 2021, '2')]),
|
|
getRecentlyWatched: vi.fn(),
|
|
getAllMovies: vi.fn().mockResolvedValue([]),
|
|
} as unknown as JellyfinClient;
|
|
const tmdb = {
|
|
getSimilar: vi.fn().mockImplementation((id: number) => {
|
|
if (id === 1) return Promise.reject(new Error('tmdb down'));
|
|
return Promise.resolve([{ id: 20, title: 'Similar A', year: 2010 } as TMDbMovie]);
|
|
}),
|
|
getRecommendations: vi.fn().mockImplementation((id: number) => {
|
|
if (id === 1) return Promise.reject(new Error('tmdb down'));
|
|
return Promise.resolve([]);
|
|
}),
|
|
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 logger = { warn: vi.fn() } as unknown as Logger;
|
|
|
|
const pipeline = new RecommendationPipeline(makeTracer(), logger, {
|
|
jellyfin,
|
|
tmdb,
|
|
tool,
|
|
searchTool,
|
|
minRatedMovies: 2,
|
|
maxRetry: 0,
|
|
});
|
|
|
|
const result = await pipeline.runAsync({ count: 1, useLlm: false });
|
|
|
|
expect(result).toEqual([{ title: 'Similar A', year: 2010, tmdbId: 20, reason: 'Recommended by TMDb' }]);
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
expect.objectContaining({ err: expect.any(Error), tmdbId: 1 }),
|
|
'TMDb similar/recommendations failed, skipping signal movie'
|
|
);
|
|
});
|
|
});
|
|
|
|
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());
|
|
});
|
|
|
|
it('continues with an empty exclusion set when fetching the library fails', async () => {
|
|
const jellyfin = {
|
|
getAllMovies: vi.fn().mockRejectedValue(new Error('jellyfin down')),
|
|
} 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 logger = { warn: vi.fn() } as unknown as Logger;
|
|
|
|
const pipeline = new RecommendationPipeline(makeTracer(), 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(logger.warn).toHaveBeenCalledWith(
|
|
expect.objectContaining({ err: expect.any(Error) }),
|
|
'Could not fetch library; exclusion may be incomplete'
|
|
);
|
|
});
|
|
});
|