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
+81 -76
View File
@@ -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();
}
+4 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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,
),
],
+2 -2
View File
@@ -47,8 +47,8 @@ const REQUIRED_TABLES = [
"userRatings",
"userTitleStatus",
"verification",
"webhookConnections",
"webhookEventLog",
"integrations",
"integrationEvents",
] as const;
let backupOpQueue: Promise<void> = Promise.resolve();
+212
View File
@@ -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);
});
});
+114
View File
@@ -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;
}
+3
View File
@@ -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))
+6 -6
View File
@@ -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();
}
+24
View File
@@ -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
View File
@@ -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}`);
}
+6
View File
@@ -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;