Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: radarr integration #1053

Merged
merged 2 commits into from
Sep 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions packages/cron-jobs/src/jobs/integrations/media-organizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { decryptSecret } from "@homarr/common";
import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
import { db, eq } from "@homarr/db";
import { items } from "@homarr/db/schema/sqlite";
import { SonarrIntegration } from "@homarr/integrations";
import { integrationCreatorByKind } from "@homarr/integrations";
import type { CalendarEvent } from "@homarr/integrations/types";
import { createItemAndIntegrationChannel } from "@homarr/redis";

Expand Down Expand Up @@ -41,14 +41,17 @@ export const mediaOrganizerJob = createCronJob("mediaOrganizer", EVERY_MINUTE).w
const start = dayjs().subtract(Number(options.filterPastMonths), "months").toDate();
const end = dayjs().add(Number(options.filterFutureMonths), "months").toDate();

const sonarr = new SonarrIntegration({
const decryptedSecrets = integration.integration.secrets.map((secret) => ({
...secret,
value: decryptSecret(secret.value),
}));

const integrationInstance = integrationCreatorByKind(integration.integration.kind as "radarr" | "sonarr", {
...integration.integration,
decryptedSecrets: integration.integration.secrets.map((secret) => ({
...secret,
value: decryptSecret(secret.value),
})),
decryptedSecrets,
});
const events = await sonarr.getCalendarEventsAsync(start, end);

const events = await integrationInstance.getCalendarEventsAsync(start, end);

const cache = createItemAndIntegrationChannel<CalendarEvent[]>("calendar", integration.integrationId);
await cache.setAsync(events);
Expand Down
2 changes: 2 additions & 0 deletions packages/integrations/src/base/creator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
import { JellyfinIntegration } from "../jellyfin/jellyfin-integration";
import { JellyseerrIntegration } from "../jellyseerr/jellyseerr-integration";
import { RadarrIntegration } from "../media-organizer/radarr/radarr-integration";
import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration";
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
import { PiHoleIntegration } from "../pi-hole/pi-hole-integration";
Expand All @@ -26,6 +27,7 @@ export const integrationCreators = {
homeAssistant: HomeAssistantIntegration,
jellyfin: JellyfinIntegration,
sonarr: SonarrIntegration,
radarr: RadarrIntegration,
jellyseerr: JellyseerrIntegration,
overseerr: OverseerrIntegration,
} satisfies Partial<Record<IntegrationKind, new (integration: IntegrationInput) => Integration>>;
5 changes: 3 additions & 2 deletions packages/integrations/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,16 @@
export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration";
export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration";
export { JellyfinIntegration } from "./jellyfin/jellyfin-integration";
export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration";
export { JellyseerrIntegration } from "./jellyseerr/jellyseerr-integration";
export { RadarrIntegration } from "./media-organizer/radarr/radarr-integration";
export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration";
export { OverseerrIntegration } from "./overseerr/overseerr-integration";
export { PiHoleIntegration } from "./pi-hole/pi-hole-integration";

// Types
export type { StreamSession } from "./interfaces/media-server/session";
export { MediaRequestStatus } from "./interfaces/media-requests/media-request";
export type { MediaRequestList, MediaRequestStats } from "./interfaces/media-requests/media-request";
export type { StreamSession } from "./interfaces/media-server/session";

// Helpers
export { integrationCreatorByKind } from "./base/creator";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { logger } from "@homarr/log";
import { z } from "@homarr/validation";

import { Integration } from "../../base/integration";
import type { CalendarEvent } from "../../calendar-types";

export class RadarrIntegration extends Integration {
/**
* Priority list that determines the quality of images using their order.
* Types at the start of the list are better than those at the end.
* We do this to attempt to find the best quality image for the show.
*/
private readonly priorities: z.infer<typeof radarrCalendarEventSchema>["images"][number]["coverType"][] = [
"poster", // Official, perfect aspect ratio
"banner", // Official, bad aspect ratio
"fanart", // Unofficial, possibly bad quality
"screenshot", // Bad aspect ratio, possibly bad quality
"clearlogo", // Without background, bad aspect ratio
];

/**
* Gets the events in the Radarr calendar between two dates.
* @param start The start date
* @param end The end date
* @param includeUnmonitored When true results will include unmonitored items of the Tadarr library.
*/
async getCalendarEventsAsync(start: Date, end: Date, includeUnmonitored = true): Promise<CalendarEvent[]> {
const url = new URL(this.integration.url);
url.pathname = "/api/v3/calendar";
url.searchParams.append("start", start.toISOString());
url.searchParams.append("end", end.toISOString());
url.searchParams.append("unmonitored", includeUnmonitored ? "true" : "false");
const response = await fetch(url, {
headers: {
"X-Api-Key": super.getSecretValue("apiKey"),
},
});
const radarrCalendarEvents = await z.array(radarrCalendarEventSchema).parseAsync(await response.json());

return radarrCalendarEvents.map(
(radarrCalendarEvent): CalendarEvent => ({
name: radarrCalendarEvent.title,
subName: radarrCalendarEvent.originalTitle,
description: radarrCalendarEvent.overview,
thumbnail: this.chooseBestImageAsURL(radarrCalendarEvent),
date: radarrCalendarEvent.inCinemas,
mediaInformation: {
type: "movie",
},
links: this.getLinksForRadarrCalendarEvent(radarrCalendarEvent),
}),
);
}

private getLinksForRadarrCalendarEvent = (event: z.infer<typeof radarrCalendarEventSchema>) => {
const links: CalendarEvent["links"] = [
{
href: `${this.integration.url}/movie/${event.titleSlug}`,
name: "Radarr",
logo: "/images/apps/radarr.svg",
color: undefined,
notificationColor: "yellow",
isDark: true,
},
];

if (event.imdbId) {
links.push({
href: `https://www.imdb.com/title/${event.imdbId}/`,
name: "IMDb",
color: "#f5c518",
isDark: false,
logo: "/images/apps/imdb.png",
});
}

return links;
};

private chooseBestImage = (
event: z.infer<typeof radarrCalendarEventSchema>,
): z.infer<typeof radarrCalendarEventSchema>["images"][number] | undefined => {
const flatImages = [...event.images];

const sortedImages = flatImages.sort(
(imageA, imageB) => this.priorities.indexOf(imageA.coverType) - this.priorities.indexOf(imageB.coverType),
);
logger.debug(`Sorted images to [${sortedImages.map((image) => image.coverType).join(",")}]`);
return sortedImages[0];
};

private chooseBestImageAsURL = (event: z.infer<typeof radarrCalendarEventSchema>): string | undefined => {
const bestImage = this.chooseBestImage(event);
if (!bestImage) {
return undefined;
}
return bestImage.remoteUrl;
};

public async testConnectionAsync(): Promise<void> {
await super.handleTestConnectionResponseAsync({
queryFunctionAsync: async () => {
return await fetch(`${this.integration.url}/api`, {
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
});
},
});
}
}

const radarrCalendarEventImageSchema = z.array(
z.object({
coverType: z.enum(["screenshot", "poster", "banner", "fanart", "clearlogo"]),
remoteUrl: z.string().url(),
}),
);

const radarrCalendarEventSchema = z.object({
title: z.string(),
originalTitle: z.string(),
inCinemas: z.string().transform((value) => new Date(value)),
overview: z.string().optional(),
titleSlug: z.string(),
images: radarrCalendarEventImageSchema,
imdbId: z.string().optional(),
});