feat: radarr integration (#1053)
* feat: sonarr integration * fix: reviewed changes
This commit is contained in:
@@ -5,7 +5,7 @@ import { decryptSecret } from "@homarr/common";
|
|||||||
import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
|
import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
|
||||||
import { db, eq } from "@homarr/db";
|
import { db, eq } from "@homarr/db";
|
||||||
import { items } from "@homarr/db/schema/sqlite";
|
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 type { CalendarEvent } from "@homarr/integrations/types";
|
||||||
import { createItemAndIntegrationChannel } from "@homarr/redis";
|
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 start = dayjs().subtract(Number(options.filterPastMonths), "months").toDate();
|
||||||
const end = dayjs().add(Number(options.filterFutureMonths), "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,
|
...integration.integration,
|
||||||
decryptedSecrets: integration.integration.secrets.map((secret) => ({
|
decryptedSecrets,
|
||||||
...secret,
|
|
||||||
value: decryptSecret(secret.value),
|
|
||||||
})),
|
|
||||||
});
|
});
|
||||||
const events = await sonarr.getCalendarEventsAsync(start, end);
|
|
||||||
|
const events = await integrationInstance.getCalendarEventsAsync(start, end);
|
||||||
|
|
||||||
const cache = createItemAndIntegrationChannel<CalendarEvent[]>("calendar", integration.integrationId);
|
const cache = createItemAndIntegrationChannel<CalendarEvent[]>("calendar", integration.integrationId);
|
||||||
await cache.setAsync(events);
|
await cache.setAsync(events);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration
|
|||||||
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
|
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
|
||||||
import { JellyfinIntegration } from "../jellyfin/jellyfin-integration";
|
import { JellyfinIntegration } from "../jellyfin/jellyfin-integration";
|
||||||
import { JellyseerrIntegration } from "../jellyseerr/jellyseerr-integration";
|
import { JellyseerrIntegration } from "../jellyseerr/jellyseerr-integration";
|
||||||
|
import { RadarrIntegration } from "../media-organizer/radarr/radarr-integration";
|
||||||
import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration";
|
import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration";
|
||||||
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
|
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
|
||||||
import { PiHoleIntegration } from "../pi-hole/pi-hole-integration";
|
import { PiHoleIntegration } from "../pi-hole/pi-hole-integration";
|
||||||
@@ -27,6 +28,7 @@ export const integrationCreators = {
|
|||||||
homeAssistant: HomeAssistantIntegration,
|
homeAssistant: HomeAssistantIntegration,
|
||||||
jellyfin: JellyfinIntegration,
|
jellyfin: JellyfinIntegration,
|
||||||
sonarr: SonarrIntegration,
|
sonarr: SonarrIntegration,
|
||||||
|
radarr: RadarrIntegration,
|
||||||
jellyseerr: JellyseerrIntegration,
|
jellyseerr: JellyseerrIntegration,
|
||||||
overseerr: OverseerrIntegration,
|
overseerr: OverseerrIntegration,
|
||||||
prowlarr: ProwlarrIntegration,
|
prowlarr: ProwlarrIntegration,
|
||||||
|
|||||||
@@ -2,15 +2,16 @@
|
|||||||
export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration";
|
export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration";
|
||||||
export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration";
|
export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration";
|
||||||
export { JellyfinIntegration } from "./jellyfin/jellyfin-integration";
|
export { JellyfinIntegration } from "./jellyfin/jellyfin-integration";
|
||||||
export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration";
|
|
||||||
export { JellyseerrIntegration } from "./jellyseerr/jellyseerr-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 { OverseerrIntegration } from "./overseerr/overseerr-integration";
|
||||||
export { PiHoleIntegration } from "./pi-hole/pi-hole-integration";
|
export { PiHoleIntegration } from "./pi-hole/pi-hole-integration";
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export type { StreamSession } from "./interfaces/media-server/session";
|
|
||||||
export { MediaRequestStatus } from "./interfaces/media-requests/media-request";
|
export { MediaRequestStatus } from "./interfaces/media-requests/media-request";
|
||||||
export type { MediaRequestList, MediaRequestStats } 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
|
// Helpers
|
||||||
export { integrationCreatorByKind } from "./base/creator";
|
export { integrationCreatorByKind } from "./base/creator";
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user