mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 02:45:39 -04:00
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:
@@ -1,12 +1,23 @@
|
||||
import { headers } from "next/headers";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { isTmdbConfigured } from "@/lib/config";
|
||||
import { discover } from "@/lib/tmdb/client";
|
||||
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
||||
|
||||
const SORT_BY_PATTERN = /^[a-z_]+\.(asc|desc)$/;
|
||||
const MAX_PAGE = 500;
|
||||
const querySchema = z.object({
|
||||
type: z.enum(["movie", "tv"]).default("movie"),
|
||||
sort_by: z
|
||||
.string()
|
||||
.regex(/^[a-z_]+\.(asc|desc)$/)
|
||||
.default("popularity.desc"),
|
||||
genre: z
|
||||
.string()
|
||||
.regex(/^\d+(,\d+)*$/)
|
||||
.optional(),
|
||||
page: z.coerce.number().int().min(1).max(500).default(1),
|
||||
});
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth.api.getSession({
|
||||
@@ -26,40 +37,19 @@ export async function GET(req: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const { searchParams } = req.nextUrl;
|
||||
const rawType = searchParams.get("type");
|
||||
if (rawType && rawType !== "movie" && rawType !== "tv") {
|
||||
const raw = Object.fromEntries(req.nextUrl.searchParams);
|
||||
const result = querySchema.safeParse(raw);
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "type must be movie or tv" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const type = rawType === "tv" ? "tv" : "movie";
|
||||
const genre = searchParams.get("genre");
|
||||
const sortBy = searchParams.get("sort_by") || "popularity.desc";
|
||||
const pageRaw = searchParams.get("page") || "1";
|
||||
const page = Number.parseInt(pageRaw, 10);
|
||||
|
||||
if (!Number.isInteger(page) || page < 1 || page > MAX_PAGE) {
|
||||
return NextResponse.json(
|
||||
{ error: `page must be an integer between 1 and ${MAX_PAGE}` },
|
||||
{ error: result.error.issues[0].message },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!SORT_BY_PATTERN.test(sortBy)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid sort_by value" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (genre && !/^\d+(,\d+)*$/.test(genre)) {
|
||||
return NextResponse.json({ error: "Invalid genre value" }, { status: 400 });
|
||||
}
|
||||
const { type, sort_by, genre, page } = result.data;
|
||||
|
||||
const params: Record<string, string> = {
|
||||
sort_by: sortBy,
|
||||
sort_by,
|
||||
"vote_count.gte": "50",
|
||||
};
|
||||
if (genre) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
getRadarrList,
|
||||
getSonarrList,
|
||||
parseStatusParam,
|
||||
resolveListToken,
|
||||
} from "@/lib/services/lists";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ token: string }> },
|
||||
) {
|
||||
const { token } = await params;
|
||||
const result = resolveListToken(token);
|
||||
if (!result) {
|
||||
return NextResponse.json([]);
|
||||
}
|
||||
|
||||
const statuses = parseStatusParam(req.nextUrl.searchParams.get("status"));
|
||||
|
||||
if (result.provider === "sonarr") {
|
||||
return NextResponse.json(await getSonarrList(result.userId, statuses));
|
||||
}
|
||||
return NextResponse.json(getRadarrList(result.userId, statuses));
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
import { headers } from "next/headers";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { importTitle } from "@/lib/services/metadata";
|
||||
|
||||
const bodySchema = z.object({
|
||||
tmdbId: z.coerce.number().int().positive(),
|
||||
type: z.enum(["movie", "tv"]),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers(),
|
||||
@@ -12,25 +18,8 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = body as { tmdbId?: unknown; type?: unknown };
|
||||
const type = parsed.type;
|
||||
const tmdbId =
|
||||
typeof parsed.tmdbId === "number"
|
||||
? parsed.tmdbId
|
||||
: Number.parseInt(String(parsed.tmdbId), 10);
|
||||
|
||||
if (
|
||||
!Number.isInteger(tmdbId) ||
|
||||
tmdbId < 1 ||
|
||||
(type !== "movie" && type !== "tv")
|
||||
) {
|
||||
const result = bodySchema.safeParse(await req.json().catch(() => null));
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "tmdbId (positive integer) and type (movie|tv) are required" },
|
||||
{ status: 400 },
|
||||
@@ -38,7 +27,7 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
const title = await importTitle(tmdbId, type);
|
||||
const title = await importTitle(result.data.tmdbId, result.data.type);
|
||||
return NextResponse.json(title);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { headers } from "next/headers";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { importTitle } from "@/lib/services/metadata";
|
||||
|
||||
const bodySchema = z.object({
|
||||
tmdbId: z.coerce.number().int().positive(),
|
||||
type: z.enum(["movie", "tv"]),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers(),
|
||||
@@ -12,25 +18,8 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = body as { tmdbId?: unknown; type?: unknown };
|
||||
const type = parsed.type;
|
||||
const tmdbId =
|
||||
typeof parsed.tmdbId === "number"
|
||||
? parsed.tmdbId
|
||||
: Number.parseInt(String(parsed.tmdbId), 10);
|
||||
|
||||
if (
|
||||
!Number.isInteger(tmdbId) ||
|
||||
tmdbId < 1 ||
|
||||
(type !== "movie" && type !== "tv")
|
||||
) {
|
||||
const result = bodySchema.safeParse(await req.json().catch(() => null));
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "tmdbId (positive integer) and type (movie|tv) are required" },
|
||||
{ status: 400 },
|
||||
@@ -38,7 +27,7 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
try {
|
||||
const title = await importTitle(tmdbId, type);
|
||||
const title = await importTitle(result.data.tmdbId, result.data.type);
|
||||
return NextResponse.json({ id: title?.id });
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -2,7 +2,7 @@ import { eq } from "drizzle-orm";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { webhookConnections } from "@/lib/db/schema";
|
||||
import { integrations } from "@/lib/db/schema";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import type { WebhookEvent } from "@/lib/services/webhooks";
|
||||
import {
|
||||
@@ -23,8 +23,8 @@ export async function POST(
|
||||
// Look up connection by token — this IS the auth
|
||||
const connection = db
|
||||
.select()
|
||||
.from(webhookConnections)
|
||||
.where(eq(webhookConnections.token, token))
|
||||
.from(integrations)
|
||||
.where(eq(integrations.token, token))
|
||||
.get();
|
||||
|
||||
if (!connection || !connection.enabled) {
|
||||
@@ -32,12 +32,19 @@ export async function POST(
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
// Only webhook-type integrations are handled here
|
||||
if (connection.type !== "webhook") {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
const provider = connection.provider as "plex" | "jellyfin" | "emby";
|
||||
|
||||
try {
|
||||
let event: WebhookEvent | null;
|
||||
if (connection.provider === "plex") {
|
||||
if (provider === "plex") {
|
||||
const formData = await req.formData();
|
||||
event = parsePlexPayload(formData);
|
||||
} else if (connection.provider === "emby") {
|
||||
} else if (provider === "emby") {
|
||||
const body = await req.json();
|
||||
event = parseEmbyPayload(body);
|
||||
} else {
|
||||
@@ -50,12 +57,7 @@ export async function POST(
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
await processWebhook(
|
||||
connection.id,
|
||||
connection.userId,
|
||||
connection.provider,
|
||||
event,
|
||||
);
|
||||
await processWebhook(connection.id, connection.userId, provider, event);
|
||||
} catch (err) {
|
||||
// Swallow errors — never return non-200 to media servers
|
||||
log.debug("Webhook processing failed:", err);
|
||||
|
||||
Reference in New Issue
Block a user