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:
+81
-76
@@ -2,10 +2,11 @@
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { headers } from "next/headers";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { type BackupFrequency, rescheduleBackup } from "@/lib/cron";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { webhookConnections } from "@/lib/db/schema";
|
||||
import { integrations } from "@/lib/db/schema";
|
||||
import {
|
||||
type BackupInfo,
|
||||
createBackup,
|
||||
@@ -14,6 +15,14 @@ import {
|
||||
} from "@/lib/services/backup";
|
||||
import { getSetting, setSetting } from "@/lib/services/settings";
|
||||
|
||||
const providerSchema = z.enum(["plex", "jellyfin", "emby", "sonarr", "radarr"]);
|
||||
|
||||
const LIST_PROVIDERS = new Set(["sonarr", "radarr"]);
|
||||
|
||||
function integrationTypeFor(provider: string): "webhook" | "list" {
|
||||
return LIST_PROVIDERS.has(provider) ? "list" : "webhook";
|
||||
}
|
||||
|
||||
async function getSession() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) throw new Error("Unauthorized");
|
||||
@@ -26,124 +35,106 @@ async function getAdminSession() {
|
||||
return session;
|
||||
}
|
||||
|
||||
// --- Webhook actions ---
|
||||
function generateToken() {
|
||||
return Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString(
|
||||
"hex",
|
||||
);
|
||||
}
|
||||
|
||||
export async function saveWebhookConnection(
|
||||
provider: "plex" | "jellyfin" | "emby",
|
||||
enabled?: boolean,
|
||||
) {
|
||||
// --- Integration actions ---
|
||||
|
||||
export async function saveIntegration(provider: string, enabled?: boolean) {
|
||||
const session = await getSession();
|
||||
|
||||
if (!["plex", "jellyfin", "emby"].includes(provider)) {
|
||||
throw new Error("Invalid provider");
|
||||
}
|
||||
const parsed = providerSchema.parse(provider);
|
||||
|
||||
const existing = db
|
||||
.select()
|
||||
.from(webhookConnections)
|
||||
.from(integrations)
|
||||
.where(
|
||||
and(
|
||||
eq(webhookConnections.userId, session.user.id),
|
||||
eq(webhookConnections.provider, provider),
|
||||
eq(integrations.userId, session.user.id),
|
||||
eq(integrations.provider, parsed),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
|
||||
if (existing) {
|
||||
const connection = db
|
||||
.update(webhookConnections)
|
||||
const row = db
|
||||
.update(integrations)
|
||||
.set({
|
||||
enabled: typeof enabled === "boolean" ? enabled : existing.enabled,
|
||||
})
|
||||
.where(eq(webhookConnections.id, existing.id))
|
||||
.where(eq(integrations.id, existing.id))
|
||||
.returning()
|
||||
.get();
|
||||
return {
|
||||
...connection,
|
||||
lastEventAt: connection.lastEventAt?.toISOString() ?? null,
|
||||
createdAt: connection.createdAt.toISOString(),
|
||||
...row,
|
||||
lastEventAt: row.lastEventAt?.toISOString() ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const token = Buffer.from(
|
||||
crypto.getRandomValues(new Uint8Array(32)),
|
||||
).toString("hex");
|
||||
const now = new Date();
|
||||
|
||||
const connection = db
|
||||
.insert(webhookConnections)
|
||||
const row = db
|
||||
.insert(integrations)
|
||||
.values({
|
||||
userId: session.user.id,
|
||||
provider,
|
||||
token,
|
||||
provider: parsed,
|
||||
type: integrationTypeFor(parsed),
|
||||
token: generateToken(),
|
||||
enabled: true,
|
||||
createdAt: now,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
|
||||
return {
|
||||
...connection,
|
||||
lastEventAt: connection.lastEventAt?.toISOString() ?? null,
|
||||
createdAt: connection.createdAt.toISOString(),
|
||||
...row,
|
||||
lastEventAt: row.lastEventAt?.toISOString() ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteWebhookConnection(
|
||||
provider: "plex" | "jellyfin" | "emby",
|
||||
) {
|
||||
export async function deleteIntegration(provider: string) {
|
||||
const session = await getSession();
|
||||
const parsed = providerSchema.parse(provider);
|
||||
|
||||
if (!["plex", "jellyfin", "emby"].includes(provider)) {
|
||||
throw new Error("Invalid provider");
|
||||
}
|
||||
|
||||
db.delete(webhookConnections)
|
||||
db.delete(integrations)
|
||||
.where(
|
||||
and(
|
||||
eq(webhookConnections.userId, session.user.id),
|
||||
eq(webhookConnections.provider, provider),
|
||||
eq(integrations.userId, session.user.id),
|
||||
eq(integrations.provider, parsed),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function regenerateWebhookToken(
|
||||
provider: "plex" | "jellyfin" | "emby",
|
||||
) {
|
||||
export async function regenerateIntegrationToken(provider: string) {
|
||||
const session = await getSession();
|
||||
const parsed = providerSchema.parse(provider);
|
||||
|
||||
if (!["plex", "jellyfin", "emby"].includes(provider)) {
|
||||
throw new Error("Invalid provider");
|
||||
}
|
||||
|
||||
const newToken = Buffer.from(
|
||||
crypto.getRandomValues(new Uint8Array(32)),
|
||||
).toString("hex");
|
||||
|
||||
const connection = db
|
||||
.update(webhookConnections)
|
||||
.set({ token: newToken })
|
||||
const row = db
|
||||
.update(integrations)
|
||||
.set({ token: generateToken() })
|
||||
.where(
|
||||
and(
|
||||
eq(webhookConnections.userId, session.user.id),
|
||||
eq(webhookConnections.provider, provider),
|
||||
eq(integrations.userId, session.user.id),
|
||||
eq(integrations.provider, parsed),
|
||||
),
|
||||
)
|
||||
.returning()
|
||||
.get();
|
||||
|
||||
if (!connection) {
|
||||
throw new Error("Connection not found");
|
||||
}
|
||||
if (!row) throw new Error("Integration not found");
|
||||
|
||||
return {
|
||||
...connection,
|
||||
lastEventAt: connection.lastEventAt?.toISOString() ?? null,
|
||||
createdAt: connection.createdAt.toISOString(),
|
||||
...row,
|
||||
lastEventAt: row.lastEventAt?.toISOString() ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// --- Admin actions ---
|
||||
|
||||
export async function toggleRegistration(open: boolean) {
|
||||
await getAdminSession();
|
||||
setSetting("registrationOpen", String(open));
|
||||
@@ -189,14 +180,33 @@ export async function getScheduledBackupSettings(): Promise<{
|
||||
};
|
||||
}
|
||||
|
||||
const maxBackupsSchema = z
|
||||
.number()
|
||||
.int()
|
||||
.refine((n) => n === 0 || (n >= 1 && n <= 30), {
|
||||
message: "Max backups must be between 1 and 30, or 0 for unlimited",
|
||||
});
|
||||
|
||||
export async function setMaxBackupsAction(max: number): Promise<void> {
|
||||
await getAdminSession();
|
||||
if (max < 0 || (max > 30 && max !== 0))
|
||||
throw new Error("Max backups must be between 1 and 30, or 0 for unlimited");
|
||||
maxBackupsSchema.parse(max);
|
||||
setSetting("maxBackupRetention", String(max));
|
||||
}
|
||||
|
||||
const VALID_FREQUENCIES: BackupFrequency[] = ["6h", "12h", "1d", "7d"];
|
||||
const backupScheduleSchema = z.object({
|
||||
frequency: z.enum(["6h", "12h", "1d", "7d"]),
|
||||
time: z
|
||||
.string()
|
||||
.regex(/^\d{2}:\d{2}$/, "Invalid time format")
|
||||
.refine(
|
||||
(t) => {
|
||||
const [h, m] = t.split(":").map(Number);
|
||||
return h >= 0 && h <= 23 && m >= 0 && m <= 59;
|
||||
},
|
||||
{ message: "Invalid time value" },
|
||||
),
|
||||
dayOfWeek: z.number().int().min(0).max(6).default(0),
|
||||
});
|
||||
|
||||
export async function setBackupScheduleAction(
|
||||
frequency: BackupFrequency,
|
||||
@@ -204,14 +214,9 @@ export async function setBackupScheduleAction(
|
||||
dayOfWeek = 0,
|
||||
): Promise<void> {
|
||||
await getAdminSession();
|
||||
if (!VALID_FREQUENCIES.includes(frequency))
|
||||
throw new Error("Invalid frequency");
|
||||
if (!/^\d{2}:\d{2}$/.test(time)) throw new Error("Invalid time format");
|
||||
const [h, m] = time.split(":").map(Number);
|
||||
if (h < 0 || h > 23 || m < 0 || m > 59) throw new Error("Invalid time value");
|
||||
if (dayOfWeek < 0 || dayOfWeek > 6) throw new Error("Invalid day of week");
|
||||
setSetting("backupScheduleFrequency", frequency);
|
||||
setSetting("backupScheduleTime", time);
|
||||
setSetting("backupScheduleDow", String(dayOfWeek));
|
||||
const parsed = backupScheduleSchema.parse({ frequency, time, dayOfWeek });
|
||||
setSetting("backupScheduleFrequency", parsed.frequency);
|
||||
setSetting("backupScheduleTime", parsed.time);
|
||||
setSetting("backupScheduleDow", String(parsed.dayOfWeek));
|
||||
rescheduleBackup();
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { headers } from "next/headers";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { episodes } from "@/lib/db/schema";
|
||||
@@ -40,10 +41,11 @@ export async function markAllWatchedAction(titleId: string) {
|
||||
markAllEpisodesWatched(userId, titleId);
|
||||
}
|
||||
|
||||
const ratingSchema = z.number().int().min(0).max(5);
|
||||
|
||||
export async function updateTitleRating(titleId: string, ratingStars: number) {
|
||||
const userId = await getSessionUserId();
|
||||
if (ratingStars < 0 || ratingStars > 5) throw new Error("Invalid rating");
|
||||
rateTitleStars(userId, titleId, ratingStars);
|
||||
rateTitleStars(userId, titleId, ratingSchema.parse(ratingStars));
|
||||
}
|
||||
|
||||
export async function watchMovie(titleId: string) {
|
||||
|
||||
+10
-19
@@ -1,31 +1,22 @@
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { WebhookConnection } from "@/app/(pages)/settings/_components/webhook-card";
|
||||
import type { IntegrationConnection } from "@/app/(pages)/settings/_components/integration-card";
|
||||
import {
|
||||
deleteWebhookConnection,
|
||||
regenerateWebhookToken,
|
||||
saveWebhookConnection,
|
||||
deleteIntegration,
|
||||
regenerateIntegrationToken,
|
||||
saveIntegration,
|
||||
} from "@/lib/actions/settings";
|
||||
|
||||
export const connectionsAtom = atom<WebhookConnection[]>([]);
|
||||
export const connectionsAtom = atom<IntegrationConnection[]>([]);
|
||||
|
||||
function providerLabel(provider: "plex" | "jellyfin" | "emby") {
|
||||
return provider === "plex"
|
||||
? "Plex"
|
||||
: provider === "emby"
|
||||
? "Emby"
|
||||
: "Jellyfin";
|
||||
}
|
||||
|
||||
export function useConnectionActions(provider: "plex" | "jellyfin" | "emby") {
|
||||
export function useConnectionActions(provider: string, label: string) {
|
||||
const [connections, setConnections] = useAtom(connectionsAtom);
|
||||
const label = providerLabel(provider);
|
||||
const connection = connections.find((c) => c.provider === provider) ?? null;
|
||||
|
||||
const handleConnect = useCallback(async () => {
|
||||
try {
|
||||
const result = await saveWebhookConnection(provider);
|
||||
const result = await saveIntegration(provider);
|
||||
setConnections((prev) => [...prev, { ...result, recentEvents: [] }]);
|
||||
toast.success(`${label} connected`);
|
||||
} catch {
|
||||
@@ -37,7 +28,7 @@ export function useConnectionActions(provider: "plex" | "jellyfin" | "emby") {
|
||||
const previous = connections;
|
||||
setConnections((prev) => prev.filter((c) => c.provider !== provider));
|
||||
try {
|
||||
await deleteWebhookConnection(provider);
|
||||
await deleteIntegration(provider);
|
||||
toast.success(`${label} disconnected`);
|
||||
} catch {
|
||||
setConnections(previous);
|
||||
@@ -47,13 +38,13 @@ export function useConnectionActions(provider: "plex" | "jellyfin" | "emby") {
|
||||
|
||||
const handleRegenerateToken = useCallback(async () => {
|
||||
try {
|
||||
const result = await regenerateWebhookToken(provider);
|
||||
const result = await regenerateIntegrationToken(provider);
|
||||
setConnections((prev) =>
|
||||
prev.map((c) =>
|
||||
c.provider === provider ? { ...c, token: result.token } : c,
|
||||
),
|
||||
);
|
||||
toast.success(`${label} webhook URL regenerated`);
|
||||
toast.success(`${label} URL regenerated`);
|
||||
} catch {
|
||||
toast.error(`Failed to regenerate ${label} URL`);
|
||||
}
|
||||
|
||||
+14
-14
@@ -102,6 +102,7 @@ export const titles = sqliteTable(
|
||||
{
|
||||
id: uuidPk(),
|
||||
tmdbId: int("tmdbId").notNull(),
|
||||
tvdbId: int("tvdbId"),
|
||||
type: text("type", { enum: ["movie", "tv"] }).notNull(),
|
||||
title: text("title").notNull(),
|
||||
originalTitle: text("originalTitle"),
|
||||
@@ -374,39 +375,38 @@ export const titleCast = sqliteTable(
|
||||
],
|
||||
);
|
||||
|
||||
// ─── Webhook Connections ─────────────────────────────────────────────
|
||||
// ─── Integrations ───────────────────────────────────────────────────
|
||||
|
||||
export const webhookConnections = sqliteTable(
|
||||
"webhookConnections",
|
||||
export const integrations = sqliteTable(
|
||||
"integrations",
|
||||
{
|
||||
id: uuidPk(),
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
provider: text("provider", {
|
||||
enum: ["plex", "jellyfin", "emby"],
|
||||
}).notNull(),
|
||||
provider: text("provider").notNull(),
|
||||
type: text("type", { enum: ["webhook", "list"] }).notNull(),
|
||||
token: text("token").notNull().unique(),
|
||||
enabled: int("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
createdAt: int("createdAt", { mode: "timestamp" }).notNull(),
|
||||
lastEventAt: int("lastEventAt", { mode: "timestamp" }),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("webhookConnections_userId_provider").on(
|
||||
uniqueIndex("integrations_userId_provider").on(
|
||||
table.userId,
|
||||
table.provider,
|
||||
),
|
||||
uniqueIndex("webhookConnections_token").on(table.token),
|
||||
uniqueIndex("integrations_token").on(table.token),
|
||||
],
|
||||
);
|
||||
|
||||
export const webhookEventLog = sqliteTable(
|
||||
"webhookEventLog",
|
||||
export const integrationEvents = sqliteTable(
|
||||
"integrationEvents",
|
||||
{
|
||||
id: uuidPk(),
|
||||
connectionId: text("connectionId")
|
||||
integrationId: text("integrationId")
|
||||
.notNull()
|
||||
.references(() => webhookConnections.id, { onDelete: "cascade" }),
|
||||
.references(() => integrations.id, { onDelete: "cascade" }),
|
||||
eventType: text("eventType"),
|
||||
mediaType: text("mediaType"),
|
||||
mediaTitle: text("mediaTitle"),
|
||||
@@ -417,8 +417,8 @@ export const webhookEventLog = sqliteTable(
|
||||
receivedAt: int("receivedAt", { mode: "timestamp" }).notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("webhookEventLog_connectionId_receivedAt").on(
|
||||
table.connectionId,
|
||||
index("integrationEvents_integrationId_receivedAt").on(
|
||||
table.integrationId,
|
||||
table.receivedAt,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -47,8 +47,8 @@ const REQUIRED_TABLES = [
|
||||
"userRatings",
|
||||
"userTitleStatus",
|
||||
"verification",
|
||||
"webhookConnections",
|
||||
"webhookEventLog",
|
||||
"integrations",
|
||||
"integrationEvents",
|
||||
] as const;
|
||||
|
||||
let backupOpQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
||||
import {
|
||||
clearAllTables,
|
||||
insertIntegration,
|
||||
insertStatus,
|
||||
insertTitle,
|
||||
insertUser,
|
||||
} from "@/lib/test-utils";
|
||||
import {
|
||||
getRadarrList,
|
||||
getSonarrList,
|
||||
parseStatusParam,
|
||||
resolveListToken,
|
||||
} from "./lists";
|
||||
|
||||
// Mock getTvExternalIds for lazy resolution tests
|
||||
const mockGetTvExternalIds = mock(() =>
|
||||
Promise.resolve({ tvdb_id: 55555, imdb_id: "tt9999999" }),
|
||||
);
|
||||
mock.module("@/lib/tmdb/client", () => ({
|
||||
getTvExternalIds: mockGetTvExternalIds,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllTables();
|
||||
mockGetTvExternalIds.mockClear();
|
||||
});
|
||||
|
||||
describe("resolveListToken", () => {
|
||||
test("returns userId and provider for valid sonarr token", () => {
|
||||
insertUser("user-1");
|
||||
insertIntegration("user-1", "sonarr", "sonarr-token");
|
||||
expect(resolveListToken("sonarr-token")).toEqual({
|
||||
userId: "user-1",
|
||||
provider: "sonarr",
|
||||
});
|
||||
});
|
||||
|
||||
test("returns userId and provider for valid radarr token", () => {
|
||||
insertUser("user-1");
|
||||
insertIntegration("user-1", "radarr", "radarr-token");
|
||||
expect(resolveListToken("radarr-token")).toEqual({
|
||||
userId: "user-1",
|
||||
provider: "radarr",
|
||||
});
|
||||
});
|
||||
|
||||
test("returns null for invalid token", () => {
|
||||
expect(resolveListToken("nonexistent")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for webhook-type token", () => {
|
||||
insertUser("user-1");
|
||||
insertIntegration("user-1", "plex", "plex-token");
|
||||
expect(resolveListToken("plex-token")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseStatusParam", () => {
|
||||
test("defaults to watchlist when null", () => {
|
||||
expect(parseStatusParam(null)).toEqual(["watchlist"]);
|
||||
});
|
||||
|
||||
test("parses comma-separated statuses", () => {
|
||||
expect(parseStatusParam("watchlist,in_progress")).toEqual([
|
||||
"watchlist",
|
||||
"in_progress",
|
||||
]);
|
||||
});
|
||||
|
||||
test("filters invalid statuses", () => {
|
||||
expect(parseStatusParam("watchlist,invalid,completed")).toEqual([
|
||||
"watchlist",
|
||||
"completed",
|
||||
]);
|
||||
});
|
||||
|
||||
test("defaults to watchlist when all invalid", () => {
|
||||
expect(parseStatusParam("foo,bar")).toEqual(["watchlist"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRadarrList", () => {
|
||||
test("returns movie TMDB IDs on watchlist", () => {
|
||||
insertUser("user-1");
|
||||
insertTitle({ id: "m1", tmdbId: 100, type: "movie" });
|
||||
insertTitle({ id: "m2", tmdbId: 200, type: "movie" });
|
||||
insertStatus("user-1", "m1", "watchlist");
|
||||
insertStatus("user-1", "m2", "watchlist");
|
||||
|
||||
const list = getRadarrList("user-1");
|
||||
expect(list).toEqual(expect.arrayContaining([{ Id: 100 }, { Id: 200 }]));
|
||||
expect(list).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("excludes TV shows", () => {
|
||||
insertUser("user-1");
|
||||
insertTitle({ id: "m1", tmdbId: 100, type: "movie" });
|
||||
insertTitle({ id: "tv1", tmdbId: 200, type: "tv" });
|
||||
insertStatus("user-1", "m1", "watchlist");
|
||||
insertStatus("user-1", "tv1", "watchlist");
|
||||
|
||||
const list = getRadarrList("user-1");
|
||||
expect(list).toEqual([{ Id: 100 }]);
|
||||
});
|
||||
|
||||
test("filters by status", () => {
|
||||
insertUser("user-1");
|
||||
insertTitle({ id: "m1", tmdbId: 100, type: "movie" });
|
||||
insertTitle({ id: "m2", tmdbId: 200, type: "movie" });
|
||||
insertStatus("user-1", "m1", "watchlist");
|
||||
insertStatus("user-1", "m2", "completed");
|
||||
|
||||
expect(getRadarrList("user-1", ["watchlist"])).toEqual([{ Id: 100 }]);
|
||||
expect(getRadarrList("user-1", ["completed"])).toEqual([{ Id: 200 }]);
|
||||
expect(getRadarrList("user-1", ["watchlist", "completed"])).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("returns empty array for user with no movies", () => {
|
||||
insertUser("user-1");
|
||||
expect(getRadarrList("user-1")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSonarrList", () => {
|
||||
test("returns TV shows with TVDB IDs", async () => {
|
||||
insertUser("user-1");
|
||||
insertTitle({
|
||||
id: "tv1",
|
||||
tmdbId: 300,
|
||||
tvdbId: 12345,
|
||||
type: "tv",
|
||||
title: "Show A",
|
||||
});
|
||||
insertStatus("user-1", "tv1", "watchlist");
|
||||
|
||||
const list = await getSonarrList("user-1");
|
||||
expect(list).toEqual([{ TvdbId: 12345, Title: "Show A" }]);
|
||||
});
|
||||
|
||||
test("excludes movies", async () => {
|
||||
insertUser("user-1");
|
||||
insertTitle({ id: "m1", tmdbId: 100, type: "movie" });
|
||||
insertTitle({
|
||||
id: "tv1",
|
||||
tmdbId: 300,
|
||||
tvdbId: 12345,
|
||||
type: "tv",
|
||||
title: "Show A",
|
||||
});
|
||||
insertStatus("user-1", "m1", "watchlist");
|
||||
insertStatus("user-1", "tv1", "watchlist");
|
||||
|
||||
const list = await getSonarrList("user-1");
|
||||
expect(list).toEqual([{ TvdbId: 12345, Title: "Show A" }]);
|
||||
});
|
||||
|
||||
test("lazily resolves missing TVDB ID", async () => {
|
||||
insertUser("user-1");
|
||||
insertTitle({ id: "tv1", tmdbId: 300, type: "tv", title: "Show B" });
|
||||
insertStatus("user-1", "tv1", "watchlist");
|
||||
|
||||
mockGetTvExternalIds.mockResolvedValueOnce({
|
||||
tvdb_id: 55555,
|
||||
imdb_id: "tt1234567",
|
||||
});
|
||||
|
||||
const list = await getSonarrList("user-1");
|
||||
expect(list).toEqual([{ TvdbId: 55555, Title: "Show B" }]);
|
||||
expect(mockGetTvExternalIds).toHaveBeenCalledWith(300);
|
||||
});
|
||||
|
||||
test("skips shows where TVDB ID cannot be resolved", async () => {
|
||||
insertUser("user-1");
|
||||
insertTitle({ id: "tv1", tmdbId: 300, type: "tv", title: "Show C" });
|
||||
insertStatus("user-1", "tv1", "watchlist");
|
||||
|
||||
mockGetTvExternalIds.mockResolvedValueOnce({
|
||||
tvdb_id: null,
|
||||
imdb_id: null,
|
||||
});
|
||||
|
||||
const list = await getSonarrList("user-1");
|
||||
expect(list).toEqual([]);
|
||||
});
|
||||
|
||||
test("filters by status", async () => {
|
||||
insertUser("user-1");
|
||||
insertTitle({
|
||||
id: "tv1",
|
||||
tmdbId: 300,
|
||||
tvdbId: 111,
|
||||
type: "tv",
|
||||
title: "Show A",
|
||||
});
|
||||
insertTitle({
|
||||
id: "tv2",
|
||||
tmdbId: 400,
|
||||
tvdbId: 222,
|
||||
type: "tv",
|
||||
title: "Show B",
|
||||
});
|
||||
insertStatus("user-1", "tv1", "watchlist");
|
||||
insertStatus("user-1", "tv2", "completed");
|
||||
|
||||
const watchlist = await getSonarrList("user-1", ["watchlist"]);
|
||||
expect(watchlist).toEqual([{ TvdbId: 111, Title: "Show A" }]);
|
||||
|
||||
const all = await getSonarrList("user-1", ["watchlist", "completed"]);
|
||||
expect(all).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { integrations, titles, userTitleStatus } from "@/lib/db/schema";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import { getTvExternalIds } from "@/lib/tmdb/client";
|
||||
|
||||
const log = createLogger("lists");
|
||||
|
||||
/** Look up a list integration token and return the userId + provider, or null. */
|
||||
export function resolveListToken(
|
||||
token: string,
|
||||
): { userId: string; provider: "sonarr" | "radarr" } | null {
|
||||
const row = db
|
||||
.select({
|
||||
userId: integrations.userId,
|
||||
provider: integrations.provider,
|
||||
})
|
||||
.from(integrations)
|
||||
.where(
|
||||
and(
|
||||
eq(integrations.token, token),
|
||||
eq(integrations.type, "list"),
|
||||
eq(integrations.enabled, true),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
if (!row) return null;
|
||||
return {
|
||||
userId: row.userId,
|
||||
provider: row.provider as "sonarr" | "radarr",
|
||||
};
|
||||
}
|
||||
|
||||
const statusSchema = z.enum(["watchlist", "in_progress", "completed"]);
|
||||
type Status = z.infer<typeof statusSchema>;
|
||||
|
||||
/** Parse a comma-separated status query param into validated statuses. */
|
||||
export function parseStatusParam(param: string | null): Status[] {
|
||||
if (!param) return ["watchlist"];
|
||||
const parsed = param
|
||||
.split(",")
|
||||
.filter((s) => statusSchema.safeParse(s).success) as Status[];
|
||||
return parsed.length > 0 ? parsed : ["watchlist"];
|
||||
}
|
||||
|
||||
/** Radarr custom list format: `[{ Id: tmdbId }]` for movies. */
|
||||
export function getRadarrList(
|
||||
userId: string,
|
||||
statuses: Status[] = ["watchlist"],
|
||||
): { Id: number }[] {
|
||||
const rows = db
|
||||
.select({ tmdbId: titles.tmdbId })
|
||||
.from(userTitleStatus)
|
||||
.innerJoin(titles, eq(userTitleStatus.titleId, titles.id))
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, userId),
|
||||
eq(titles.type, "movie"),
|
||||
inArray(userTitleStatus.status, statuses),
|
||||
),
|
||||
)
|
||||
.all();
|
||||
return rows.map((r) => ({ Id: r.tmdbId }));
|
||||
}
|
||||
|
||||
/** Sonarr custom list format: `[{ TvdbId, Title }]` for TV shows.
|
||||
* Lazily resolves missing TVDB IDs via TMDB API and caches them. */
|
||||
export async function getSonarrList(
|
||||
userId: string,
|
||||
statuses: Status[] = ["watchlist"],
|
||||
): Promise<{ TvdbId: number; Title: string }[]> {
|
||||
const rows = db
|
||||
.select({
|
||||
id: titles.id,
|
||||
tmdbId: titles.tmdbId,
|
||||
tvdbId: titles.tvdbId,
|
||||
title: titles.title,
|
||||
})
|
||||
.from(userTitleStatus)
|
||||
.innerJoin(titles, eq(userTitleStatus.titleId, titles.id))
|
||||
.where(
|
||||
and(
|
||||
eq(userTitleStatus.userId, userId),
|
||||
eq(titles.type, "tv"),
|
||||
inArray(userTitleStatus.status, statuses),
|
||||
),
|
||||
)
|
||||
.all();
|
||||
|
||||
const result: { TvdbId: number; Title: string }[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
let tvdbId = row.tvdbId;
|
||||
|
||||
if (tvdbId == null) {
|
||||
try {
|
||||
const externalIds = await getTvExternalIds(row.tmdbId);
|
||||
tvdbId = externalIds.tvdb_id;
|
||||
if (tvdbId != null) {
|
||||
db.update(titles).set({ tvdbId }).where(eq(titles.id, row.id)).run();
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn(`Failed to resolve TVDB ID for TMDB ${row.tmdbId}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
if (tvdbId != null) {
|
||||
result.push({ TvdbId: tvdbId, Title: row.title });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -149,6 +149,7 @@ async function _importTitle(tmdbId: number, type: "movie" | "tv") {
|
||||
backdropPath: show.backdrop_path,
|
||||
status: show.status,
|
||||
contentRating: extractTvContentRating(show),
|
||||
tvdbId: show.external_ids?.tvdb_id ?? null,
|
||||
lastFetchedAt: new Date(),
|
||||
})
|
||||
.where(eq(titles.id, existing.id))
|
||||
@@ -238,6 +239,7 @@ async function _importTitle(tmdbId: number, type: "movie" | "tv") {
|
||||
const row = insertTitleOrGet(
|
||||
{
|
||||
tmdbId: show.id,
|
||||
tvdbId: show.external_ids?.tvdb_id ?? null,
|
||||
type: "tv",
|
||||
title: show.name,
|
||||
originalTitle: show.original_name,
|
||||
@@ -326,6 +328,7 @@ export async function refreshTitle(titleId: string) {
|
||||
voteCount: show.vote_count,
|
||||
status: show.status,
|
||||
contentRating: extractTvContentRating(show),
|
||||
tvdbId: show.external_ids?.tvdb_id ?? null,
|
||||
lastFetchedAt: now,
|
||||
})
|
||||
.where(eq(titles.id, titleId))
|
||||
|
||||
@@ -2,11 +2,11 @@ import { and, eq, gte } from "drizzle-orm";
|
||||
import { db } from "@/lib/db/client";
|
||||
import {
|
||||
episodes,
|
||||
integrationEvents,
|
||||
integrations,
|
||||
seasons,
|
||||
userEpisodeWatches,
|
||||
userMovieWatches,
|
||||
webhookConnections,
|
||||
webhookEventLog,
|
||||
} from "@/lib/db/schema";
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import { findByExternalId, searchTv } from "@/lib/tmdb/client";
|
||||
@@ -283,9 +283,9 @@ function logEvent(
|
||||
status: "success" | "ignored" | "error",
|
||||
errorMessage?: string,
|
||||
) {
|
||||
db.insert(webhookEventLog)
|
||||
db.insert(integrationEvents)
|
||||
.values({
|
||||
connectionId,
|
||||
integrationId: connectionId,
|
||||
eventType:
|
||||
event?.provider === "plex"
|
||||
? "media.scrobble"
|
||||
@@ -300,9 +300,9 @@ function logEvent(
|
||||
})
|
||||
.run();
|
||||
|
||||
db.update(webhookConnections)
|
||||
db.update(integrations)
|
||||
.set({ lastEventAt: new Date() })
|
||||
.where(eq(webhookConnections.id, connectionId))
|
||||
.where(eq(integrations.id, connectionId))
|
||||
.run();
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ const {
|
||||
userRatings,
|
||||
availabilityOffers,
|
||||
titleRecommendations,
|
||||
integrations,
|
||||
} = schema;
|
||||
|
||||
export const testClient = new Database(":memory:");
|
||||
@@ -58,6 +59,7 @@ export function insertTitle(
|
||||
overrides: {
|
||||
id?: string;
|
||||
tmdbId?: number;
|
||||
tvdbId?: number;
|
||||
type?: "movie" | "tv";
|
||||
title?: string;
|
||||
} = {},
|
||||
@@ -68,6 +70,7 @@ export function insertTitle(
|
||||
.values({
|
||||
id,
|
||||
tmdbId: overrides.tmdbId ?? 12345,
|
||||
tvdbId: overrides.tvdbId,
|
||||
type: overrides.type ?? "movie",
|
||||
title: overrides.title ?? "Test Movie",
|
||||
})
|
||||
@@ -180,6 +183,27 @@ export function insertAvailabilityOffer(
|
||||
.run();
|
||||
}
|
||||
|
||||
export function insertIntegration(
|
||||
userId: string,
|
||||
provider: string,
|
||||
token = "test-token",
|
||||
) {
|
||||
const type =
|
||||
provider === "sonarr" || provider === "radarr" ? "list" : "webhook";
|
||||
return testDb
|
||||
.insert(integrations)
|
||||
.values({
|
||||
userId,
|
||||
provider,
|
||||
type,
|
||||
token,
|
||||
enabled: true,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
}
|
||||
|
||||
export function insertRecommendation(
|
||||
titleId: string,
|
||||
recommendedTitleId: string,
|
||||
|
||||
+6
-1
@@ -1,5 +1,6 @@
|
||||
import { createLogger } from "@/lib/logger";
|
||||
import type {
|
||||
TmdbExternalIds,
|
||||
TmdbFindResult,
|
||||
TmdbGenreListResponse,
|
||||
TmdbMovieCreditsResponse,
|
||||
@@ -90,10 +91,14 @@ export async function getMovieDetails(tmdbId: number) {
|
||||
|
||||
export async function getTvDetails(tmdbId: number) {
|
||||
return tmdbFetch<TmdbTvDetails>(`/tv/${tmdbId}`, {
|
||||
append_to_response: "content_ratings",
|
||||
append_to_response: "content_ratings,external_ids",
|
||||
});
|
||||
}
|
||||
|
||||
export async function getTvExternalIds(tmdbId: number) {
|
||||
return tmdbFetch<TmdbExternalIds>(`/tv/${tmdbId}/external_ids`);
|
||||
}
|
||||
|
||||
export async function getTvSeasonDetails(tmdbId: number, seasonNumber: number) {
|
||||
return tmdbFetch<TmdbSeasonDetails>(`/tv/${tmdbId}/season/${seasonNumber}`);
|
||||
}
|
||||
|
||||
@@ -61,9 +61,15 @@ export interface TmdbTvDetails {
|
||||
content_ratings?: {
|
||||
results: { iso_3166_1: string; rating: string }[];
|
||||
};
|
||||
external_ids?: TmdbExternalIds;
|
||||
seasons: TmdbSeasonSummary[];
|
||||
}
|
||||
|
||||
export interface TmdbExternalIds {
|
||||
tvdb_id: number | null;
|
||||
imdb_id: string | null;
|
||||
}
|
||||
|
||||
export interface TmdbSeasonSummary {
|
||||
id: number;
|
||||
season_number: number;
|
||||
|
||||
Reference in New Issue
Block a user