diff --git a/README.md b/README.md new file mode 100644 index 0000000..7a45686 --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# sugarr + +Movie-recommendation companion for Jellyfin: pulls your favorite/recently-watched +movies, gets suggestions from an LLM (via Langfuse-managed prompts) or a TMDb +similarity/frequency fallback, resolves them against TMDb, excludes anything +already in your library or Radarr, and can auto-request the rest via Radarr. +Runs on a cron schedule and/or as an HTTP API. + +This repo currently has two implementations: + +- **`cmd/`, `internal/`** — the original Go implementation. +- **`src/`** — a TypeScript/OOP rewrite (Node + Fastify + Langfuse), the one + documented below. See `docs/superpowers/specs/2026-08-12-typescript-oop-rewrite-design.md` + and `docs/superpowers/plans/2026-08-12-typescript-oop-rewrite.md` for the + design and implementation plan. + +## Running the TypeScript app + +```bash +pnpm install +cp .env.example .env # fill in the values below +pnpm dev # or: pnpm build && pnpm start +``` + +### Required environment variables + +See `.env.example` for the full list with defaults. At minimum you need: + +- `JELLYFIN_URL`, `JELLYFIN_API_KEY`, `JELLYFIN_USER_ID`, `TMDB_API_KEY` +- `LANGFUSE_PUBLIC_KEY`, `LANGFUSE_SECRET_KEY`, `LANGFUSE_BASE_URL` — the app + fetches its LLM prompts from Langfuse at runtime (see below), so these are + required even if `LLM_ENABLED=false` (only the recommend/search endpoints + actually call Langfuse; if you never enable the LLM path you can point + these at any reachable Langfuse instance). + +### Docker + +```bash +docker compose -f docker-compose.node.yml up -d --build +``` + +Uses `Dockerfile.node` / `docker-compose.node.yml`, kept separate from the Go +app's `Dockerfile` / `docker-compose.yml`. + +## Seeding Langfuse prompts + +The app fetches two chat prompts from Langfuse by name — `movie-recommender` +and `movie-search` — compiles them with per-request variables, and reads +model/temperature/etc. from each prompt version's `config` field. Ready-to-import +definitions are in `langfuse/`: + +- `langfuse/movie-recommender.prompt.json` +- `langfuse/movie-search.prompt.json` + +Create each one in your Langfuse project (UI: **Prompts → New prompt**, type +**Chat**; or via the Langfuse API/SDK) using the `prompt` array as the chat +messages and the `config` object as the prompt's config. Variables use +`{{name}}` interpolation: + +- `movie-recommender`: `count`, `favorites`, `watched`, `library`, `previouslySuggested` +- `movie-search`: `query`, `count` + +## Prompt caching and invalidation + +Compiled prompts are cached in memory per process (`LANGFUSE_PROMPT_CACHE_ENABLED`, +default `true`; set to `false` to always fetch fresh from Langfuse — useful +while iterating on a prompt). To invalidate the cache after updating a prompt +in Langfuse without restarting the app, call: + +``` +POST /webhooks/langfuse/invalidate +Content-Type: application/json +Authorization: Bearer # if SECRET_KEY is set + +{"promptName": "movie-recommender"} # or {} / omit body to clear the whole cache +``` + +This isn't wired to a Langfuse-native push webhook (self-hosted Langfuse has +no built-in "prompt updated" event) — call it manually, from CI after a prompt +change, or from Langfuse Cloud's webhook feature if you have one configured. + +## HTTP API + +| Method | Path | Notes | +|---|---|---| +| GET | `/health` | exempt from auth | +| GET | `/recommendations` | `?count=`, `?force_llm=true`, `?force_tmdb=true` | +| POST | `/recommendations/search` | body `{"query": "..."}`, requires `LLM_ENABLED=true` | +| POST | `/recommendations/trigger` | runs the pipeline + auto-requests via Radarr if enabled | +| POST | `/recommendations/request` | body `{"tmdb_id": 12345}`, requires `RADARR_ENABLED=true` | +| POST | `/webhooks/langfuse/invalidate` | body `{"promptName"?: string}` | + +If `SECRET_KEY` is set, all routes except `/health` require +`Authorization: Bearer `. diff --git a/langfuse/movie-recommender.prompt.json b/langfuse/movie-recommender.prompt.json new file mode 100644 index 0000000..04ddc57 --- /dev/null +++ b/langfuse/movie-recommender.prompt.json @@ -0,0 +1,20 @@ +{ + "name": "movie-recommender", + "type": "chat", + "prompt": [ + { + "role": "system", + "content": "You are a movie recommendation engine. Based on the user's favorite and recently watched movies below, suggest exactly {{count}} movies they would enjoy.\n\nIMPORTANT:\n- Do NOT suggest any movie from the \"Already In Library\" list.\n- Do NOT suggest any movie from the \"Already Suggested\" list.\n- Return ONLY a JSON array, no other text.\n- Each entry: {\"title\": \"Movie Title\", \"year\": 2024, \"reason\": \"brief reason\"}" + }, + { + "role": "user", + "content": "User's favorite movies:\n{{favorites}}\n\nUser's recently watched movies:\n{{watched}}\n\nAlready in library (DO NOT suggest these):\n{{library}}\n\nAlready suggested (DO NOT repeat these):\n{{previouslySuggested}}" + } + ], + "config": { + "model": "llama3", + "temperature": 0.7, + "maxTokens": 1024 + }, + "labels": ["production"] +} diff --git a/langfuse/movie-search.prompt.json b/langfuse/movie-search.prompt.json new file mode 100644 index 0000000..f6c626e --- /dev/null +++ b/langfuse/movie-search.prompt.json @@ -0,0 +1,20 @@ +{ + "name": "movie-search", + "type": "chat", + "prompt": [ + { + "role": "system", + "content": "You are a movie recommendation engine. Return ONLY a JSON array of {{count}} movies matching the user's request. No other text.\nEach entry: {\"title\": \"Movie Title\", \"year\": 2024, \"reason\": \"brief reason\"}" + }, + { + "role": "user", + "content": "The user is looking for: {{query}}" + } + ], + "config": { + "model": "llama3", + "temperature": 0.7, + "maxTokens": 1024 + }, + "labels": ["production"] +} diff --git a/src/app/controllers/RecommendationsController.ts b/src/app/controllers/RecommendationsController.ts index cda27fd..dd078b7 100644 --- a/src/app/controllers/RecommendationsController.ts +++ b/src/app/controllers/RecommendationsController.ts @@ -30,7 +30,8 @@ export class RecommendationsController extends Controller { } private async handleList(request: FastifyRequest<{ Querystring: ListQuery }>, reply: FastifyReply) { - const count = request.query.count ? Number(request.query.count) : this.config.RECOMMEND_COUNT; + const parsedCount = request.query.count ? Number(request.query.count) : NaN; + const count = Number.isInteger(parsedCount) && parsedCount > 0 ? parsedCount : this.config.RECOMMEND_COUNT; const useLlm = request.query.force_tmdb === 'true' ? false diff --git a/src/app/index.ts b/src/app/index.ts index 19fc451..5b82f17 100644 --- a/src/app/index.ts +++ b/src/app/index.ts @@ -38,8 +38,8 @@ async function main(): Promise { ? new RadarrClient(config.RADARR_URL, config.RADARR_API_KEY, config.RADARR_QUALITY_PROFILE_ID, config.RADARR_ROOT_FOLDER) : undefined; - const recommenderTool = new MovieRecommenderTool(promptService, llmClient); - const searchTool = new MovieSearchTool(promptService, llmClient); + const recommenderTool = new MovieRecommenderTool(promptService, llmClient, config.LLM_MODEL); + const searchTool = new MovieSearchTool(promptService, llmClient, config.LLM_MODEL); const pipeline = new RecommendationPipeline(tracer, logger, { jellyfin, diff --git a/src/app/pipelines/RecommendationPipeline.ts b/src/app/pipelines/RecommendationPipeline.ts index a315cea..5bcb212 100644 --- a/src/app/pipelines/RecommendationPipeline.ts +++ b/src/app/pipelines/RecommendationPipeline.ts @@ -41,12 +41,21 @@ export class RecommendationPipeline extends LlmPipeline(toTmdbIds(library)); if (this.options.radarr) { - const existing = await this.options.radarr.getExisting(); - for (const m of existing) exclude.add(m.tmdbId); + try { + const existing = await this.options.radarr.getExisting(); + for (const m of existing) exclude.add(m.tmdbId); + } catch (err) { + ctx.logger.warn({ err }, 'Could not fetch Radarr library; exclusion may be incomplete'); + } } const recommendations: Recommendation[] = []; @@ -70,7 +79,13 @@ export class RecommendationPipeline extends LlmPipeline= input.count) break; - const resolved = await this.options.tmdb.searchMovie(suggestion.title, suggestion.year); + let resolved: TMDbMovie | undefined; + try { + resolved = await this.options.tmdb.searchMovie(suggestion.title, suggestion.year); + } catch (err) { + ctx.logger.warn({ err, title: suggestion.title }, 'TMDb search failed, skipping suggestion'); + continue; + } if (!resolved) continue; seen.push(toSeenMovie(resolved)); if (exclude.has(resolved.id)) continue; @@ -103,14 +118,25 @@ export class RecommendationPipeline extends LlmPipeline(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); + let resolved: TMDbMovie | undefined; + try { + resolved = await this.options.tmdb.searchMovie(suggestion.title, suggestion.year); + } catch (err) { + ctx.logger.warn({ err, title: suggestion.title }, 'TMDb search failed, skipping suggestion'); + continue; + } if (!resolved || exclude.has(resolved.id)) continue; exclude.add(resolved.id); recommendations.push({ @@ -133,10 +159,17 @@ export class RecommendationPipeline extends LlmPipeline 0 ? JSON.parse(text) : undefined; diff --git a/src/core/llm/LlmTool.ts b/src/core/llm/LlmTool.ts index 5abef38..8595d7b 100644 --- a/src/core/llm/LlmTool.ts +++ b/src/core/llm/LlmTool.ts @@ -7,7 +7,8 @@ export abstract class LlmTool { constructor( protected readonly promptService: PromptService, - protected readonly llmClient: LlmClient + protected readonly llmClient: LlmClient, + protected readonly defaultModel: string = 'gpt-4o-mini' ) {} protected abstract buildVariables(input: TInput, ctx: RunContext): Record; @@ -27,7 +28,7 @@ export abstract class LlmTool { const variables = this.buildVariables(processedInput, ctx); const compiled = await this.promptService.getCompiled(this.promptName, variables); - const model = String(compiled.config.model ?? 'gpt-4o-mini'); + const model = String(compiled.config.model ?? this.defaultModel); const temperature = typeof compiled.config.temperature === 'number' ? compiled.config.temperature : undefined; const maxTokens = diff --git a/test/app/controllers/RecommendationsController.test.ts b/test/app/controllers/RecommendationsController.test.ts index bee8bc1..27b4707 100644 --- a/test/app/controllers/RecommendationsController.test.ts +++ b/test/app/controllers/RecommendationsController.test.ts @@ -28,6 +28,20 @@ describe('RecommendationsController', () => { expect(runAsync).toHaveBeenCalledWith({ count: 3, useLlm: true }); }); + it('falls back to the configured count for a malformed or non-positive count query param', async () => { + const runAsync = vi.fn().mockResolvedValue([]); + const app = makeApp({ RECOMMEND_COUNT: 10, LLM_ENABLED: true }, { runAsync } as unknown as RecommendationPipeline); + + await app.inject({ method: 'GET', url: '/recommendations?count=abc' }); + expect(runAsync).toHaveBeenCalledWith({ count: 10, useLlm: true }); + + await app.inject({ method: 'GET', url: '/recommendations?count=-5' }); + expect(runAsync).toHaveBeenCalledWith({ count: 10, useLlm: true }); + + await app.inject({ method: 'GET', url: '/recommendations?count=0' }); + expect(runAsync).toHaveBeenCalledWith({ count: 10, useLlm: true }); + }); + it('POST /recommendations/trigger auto-requests via Radarr when enabled', async () => { const recs = [{ title: 'Dune', year: 2021, tmdbId: 1, reason: 'r' }]; const runAsync = vi.fn().mockResolvedValue(recs); diff --git a/test/app/pipelines/RecommendationPipeline.test.ts b/test/app/pipelines/RecommendationPipeline.test.ts index 24d334d..a3b55be 100644 --- a/test/app/pipelines/RecommendationPipeline.test.ts +++ b/test/app/pipelines/RecommendationPipeline.test.ts @@ -171,6 +171,119 @@ describe('RecommendationPipeline.runAsync', () => { 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', () => { @@ -283,4 +396,34 @@ describe('RecommendationPipeline.searchAsync', () => { 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' + ); + }); }); diff --git a/test/core/http/ApiClient.test.ts b/test/core/http/ApiClient.test.ts index 030a957..d891a26 100644 --- a/test/core/http/ApiClient.test.ts +++ b/test/core/http/ApiClient.test.ts @@ -101,4 +101,11 @@ describe('ApiClient', () => { const client = new TestClient('https://api.example.com'); await expect(client.getThing(1)).rejects.toBeInstanceOf(ApiError); }); + + it('omits the query string (which may carry secrets) from the ApiError message', async () => { + fetchMock.mockResolvedValue({ ok: false, status: 500, text: async () => 'boom' }); + + const client = new TestClient('https://api.example.com'); + await expect(client.getThing(1)).rejects.toThrow('GET https://api.example.com/things/1 returned 500'); + }); }); diff --git a/test/core/llm/LlmTool.test.ts b/test/core/llm/LlmTool.test.ts index cd44628..b142b6d 100644 --- a/test/core/llm/LlmTool.test.ts +++ b/test/core/llm/LlmTool.test.ts @@ -60,6 +60,44 @@ describe('LlmTool.runAsync', () => { ); }); + it('falls back to the constructor-provided defaultModel when the prompt config omits model', async () => { + const getCompiled = vi + .fn() + .mockResolvedValue({ name: 'greet', version: 1, config: {}, messages: [{ role: 'user', content: 'hi' }] }); + const promptService = { getCompiled } as unknown as PromptService; + const chat = vi.fn().mockResolvedValue({ content: 'hi' }); + const llmClient = { chat } as unknown as LlmClient; + + const { ctx } = makeCtx(); + const tool = new GreetTool(promptService, llmClient, 'llama3'); + + await tool.runAsync({ who: 'alice' }, ctx); + + expect(chat).toHaveBeenCalledWith( + [{ role: 'user', content: 'hi' }], + { model: 'llama3', temperature: undefined, maxTokens: undefined } + ); + }); + + it('falls back to gpt-4o-mini when no defaultModel is given and the prompt config omits model', async () => { + const getCompiled = vi + .fn() + .mockResolvedValue({ name: 'greet', version: 1, config: {}, messages: [{ role: 'user', content: 'hi' }] }); + const promptService = { getCompiled } as unknown as PromptService; + const chat = vi.fn().mockResolvedValue({ content: 'hi' }); + const llmClient = { chat } as unknown as LlmClient; + + const { ctx } = makeCtx(); + const tool = new GreetTool(promptService, llmClient); + + await tool.runAsync({ who: 'alice' }, ctx); + + expect(chat).toHaveBeenCalledWith( + [{ role: 'user', content: 'hi' }], + { model: 'gpt-4o-mini', temperature: undefined, maxTokens: undefined } + ); + }); + it('ends the generation with error metadata and rethrows when the LLM call fails', async () => { const promptService = { getCompiled: vi.fn().mockResolvedValue({ name: 'greet', version: 1, config: {}, messages: [] }),