diff --git a/packages/cron-jobs/src/jobs/integrations/media-organizer.ts b/packages/cron-jobs/src/jobs/integrations/media-organizer.ts index 2daf7bd9b..1403d9742 100644 --- a/packages/cron-jobs/src/jobs/integrations/media-organizer.ts +++ b/packages/cron-jobs/src/jobs/integrations/media-organizer.ts @@ -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"; @@ -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("calendar", integration.integrationId); await cache.setAsync(events); diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index 27c289ef2..4034a187b 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -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"; @@ -27,6 +28,7 @@ export const integrationCreators = { homeAssistant: HomeAssistantIntegration, jellyfin: JellyfinIntegration, sonarr: SonarrIntegration, + radarr: RadarrIntegration, jellyseerr: JellyseerrIntegration, overseerr: OverseerrIntegration, prowlarr: ProwlarrIntegration, diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index c603976e7..15d25d3d9 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -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"; diff --git a/packages/integrations/src/media-organizer/radarr/radarr-integration.ts b/packages/integrations/src/media-organizer/radarr/radarr-integration.ts new file mode 100644 index 000000000..1ef79bf78 --- /dev/null +++ b/packages/integrations/src/media-organizer/radarr/radarr-integration.ts @@ -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["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 { + 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) => { + 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, + ): z.infer["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): string | undefined => { + const bestImage = this.chooseBestImage(event); + if (!bestImage) { + return undefined; + } + return bestImage.remoteUrl; + }; + + public async testConnectionAsync(): Promise { + 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(), +});