Add Sonarr and Radarr list integrations

- New `lists` service fetches Sonarr/Radarr library via their REST APIs
  and auto-imports matching titles from TMDB into the user's watchlist
- `app/api/lists/[token]/route.ts` webhook endpoint triggers a list sync
- Unified `IntegrationCard` component replaces `WebhookCard`, handling
  both webhook-style (Plex/Jellyfin/Emby) and list-style (Sonarr/Radarr)
  integrations with per-type config forms
- Schema migration adds `sonarr` and `radarr` to the integration type
  enum and a `listConnections` table for list-based integrations
- 212 tests added for the lists service covering import, deduplication,
  and error handling
This commit is contained in:
2026-03-06 15:34:34 -05:00
parent 8a3717cea7
commit 49cea568a9
27 changed files with 3787 additions and 604 deletions
+7 -5
View File
@@ -1,12 +1,12 @@
import path from "node:path";
import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import {
fetchAndMaybeCache,
type ImageCategory,
imageCacheEnabled,
} from "@/lib/services/image-cache";
const VALID_CATEGORIES = new Set<ImageCategory>([
const categorySchema = z.enum([
"posters",
"backdrops",
"stills",
@@ -33,11 +33,13 @@ export async function GET(
return NextResponse.json({ error: "Invalid path" }, { status: 400 });
}
const [category, rawFilename] = segments.path;
const [rawCategory, rawFilename] = segments.path;
if (!VALID_CATEGORIES.has(category as ImageCategory)) {
const catResult = categorySchema.safeParse(rawCategory);
if (!catResult.success) {
return NextResponse.json({ error: "Invalid category" }, { status: 400 });
}
const category = catResult.data;
// Sanitize filename — only allow basename to prevent path traversal
const filename = path.basename(rawFilename);
@@ -46,7 +48,7 @@ export async function GET(
}
const tmdbPath = `/${filename}`;
const result = await fetchAndMaybeCache(tmdbPath, category as ImageCategory);
const result = await fetchAndMaybeCache(tmdbPath, category);
if (!result) {
return NextResponse.json({ error: "Not found" }, { status: 404 });