From 563c5d8f3c0dc5b7dcedadf7fa22c690c131a5c8 Mon Sep 17 00:00:00 2001 From: Manuel <30572287+manuel-rw@users.noreply.github.com> Date: Wed, 20 Nov 2024 22:23:25 +0100 Subject: [PATCH] feat: add readarr integration (#1446) --- .../integration-test-connection.ts | 1 - .../src/jobs/integrations/media-organizer.ts | 5 +- packages/integrations/src/base/creator.ts | 4 +- packages/integrations/src/index.ts | 1 + .../readarr/readarr-integration.ts | 114 ++++++++++++++++++ 5 files changed, 119 insertions(+), 6 deletions(-) create mode 100644 packages/integrations/src/media-organizer/readarr/readarr-integration.ts diff --git a/packages/api/src/router/integration/integration-test-connection.ts b/packages/api/src/router/integration/integration-test-connection.ts index 91d12f344..93a0bc356 100644 --- a/packages/api/src/router/integration/integration-test-connection.ts +++ b/packages/api/src/router/integration/integration-test-connection.ts @@ -52,7 +52,6 @@ export const testConnectionAsync = async ( const { secrets: _, ...baseIntegration } = integration; - // @ts-expect-error - For now we expect an error here as not all integrations have been implemented const integrationInstance = integrationCreator({ ...baseIntegration, decryptedSecrets, diff --git a/packages/cron-jobs/src/jobs/integrations/media-organizer.ts b/packages/cron-jobs/src/jobs/integrations/media-organizer.ts index c05e3fb7a..ede54eaf7 100644 --- a/packages/cron-jobs/src/jobs/integrations/media-organizer.ts +++ b/packages/cron-jobs/src/jobs/integrations/media-organizer.ts @@ -1,7 +1,6 @@ import dayjs from "dayjs"; import SuperJSON from "superjson"; -import type { Modify } from "@homarr/common/types"; import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions"; import { db } from "@homarr/db"; import { getItemsWithIntegrationsAsync } from "@homarr/db/queries"; @@ -26,9 +25,7 @@ export const mediaOrganizerJob = createCronJob("mediaOrganizer", EVERY_MINUTE).w const end = dayjs().add(Number(options.filterFutureMonths), "months").toDate(); //Asserting the integration kind until all of them get implemented - const integrationInstance = integrationCreatorFromSecrets( - integration as Modify, - ); + const integrationInstance = integrationCreatorFromSecrets(integration); const events = await integrationInstance.getCalendarEventsAsync(start, end); diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index e61f100b3..4c6e2aa6d 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -14,6 +14,7 @@ import { JellyfinIntegration } from "../jellyfin/jellyfin-integration"; import { JellyseerrIntegration } from "../jellyseerr/jellyseerr-integration"; import { LidarrIntegration } from "../media-organizer/lidarr/lidarr-integration"; import { RadarrIntegration } from "../media-organizer/radarr/radarr-integration"; +import { ReadarrIntegration } from "../media-organizer/readarr/readarr-integration"; import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration"; import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration"; import { OverseerrIntegration } from "../overseerr/overseerr-integration"; @@ -66,4 +67,5 @@ export const integrationCreators = { prowlarr: ProwlarrIntegration, openmediavault: OpenMediaVaultIntegration, lidarr: LidarrIntegration, -} satisfies Partial Integration>>; + readarr: ReadarrIntegration, +} satisfies Record Integration>; diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index 7f84d2164..ea13ce13b 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -17,6 +17,7 @@ export { PiHoleIntegration } from "./pi-hole/pi-hole-integration"; export { PlexIntegration } from "./plex/plex-integration"; export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration"; export { LidarrIntegration } from "./media-organizer/lidarr/lidarr-integration"; +export { ReadarrIntegration } from "./media-organizer/readarr/readarr-integration"; // Types export type { IntegrationInput } from "./base/integration"; diff --git a/packages/integrations/src/media-organizer/readarr/readarr-integration.ts b/packages/integrations/src/media-organizer/readarr/readarr-integration.ts new file mode 100644 index 000000000..4283c39fa --- /dev/null +++ b/packages/integrations/src/media-organizer/readarr/readarr-integration.ts @@ -0,0 +1,114 @@ +import { logger } from "@homarr/log"; +import { z } from "@homarr/validation"; + +import type { CalendarEvent } from "../../calendar-types"; +import { MediaOrganizerIntegration } from "../media-organizer-integration"; + +export class ReadarrIntegration extends MediaOrganizerIntegration { + public async testConnectionAsync(): Promise { + await super.handleTestConnectionResponseAsync({ + queryFunctionAsync: async () => { + return await fetch(`${this.integration.url}/api`, { + headers: { "X-Api-Key": super.getSecretValue("apiKey") }, + }); + }, + }); + } + + /** + * Gets the events in the Lidarr 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, + includeAuthor = true, + ): Promise { + const url = new URL(this.integration.url); + url.pathname = "/api/v1/calendar"; + url.searchParams.append("start", start.toISOString()); + url.searchParams.append("end", end.toISOString()); + url.searchParams.append("unmonitored", includeUnmonitored.toString()); + url.searchParams.append("includeAuthor", includeAuthor.toString()); + const response = await fetch(url, { + headers: { + "X-Api-Key": super.getSecretValue("apiKey"), + }, + }); + const readarrCalendarEvents = await z.array(readarrCalendarEventSchema).parseAsync(await response.json()); + + return readarrCalendarEvents.map((readarrCalendarEvent): CalendarEvent => { + return { + name: readarrCalendarEvent.title, + subName: readarrCalendarEvent.author.authorName, + description: readarrCalendarEvent.overview, + thumbnail: this.chooseBestImageAsURL(readarrCalendarEvent), + date: readarrCalendarEvent.releaseDate, + mediaInformation: { + type: "audio", + }, + links: this.getLinksForReadarrCalendarEvent(readarrCalendarEvent), + }; + }); + } + + private getLinksForReadarrCalendarEvent = (event: z.infer) => { + return [ + { + href: `${this.integration.url}/author/${event.author.foreignAuthorId}`, + color: "#f5c518", + isDark: false, + logo: "/images/apps/readarr.svg", + name: "Readarr", + notificationColor: "#f5c518", + }, + ] satisfies CalendarEvent["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 `${this.integration.url}${bestImage.url}`; + }; +} + +const readarrCalendarEventImageSchema = z.array( + z.object({ + coverType: z.enum(["screenshot", "poster", "banner", "fanart", "clearlogo", "cover"]), + url: z.string().transform((url) => url.replace(/\?lastWrite=[0-9]+/, "")), // returns a random string, needs to be removed for loading the image + }), +); + +const readarrCalendarEventSchema = z.object({ + title: z.string(), + overview: z.string().optional(), + images: readarrCalendarEventImageSchema, + links: z.array( + z.object({ + name: z.string(), + url: z.string(), + }), + ), + author: z.object({ + authorName: z.string(), + foreignAuthorId: z.string(), + }), + releaseDate: z.string().transform((value) => new Date(value)), +});