diff --git a/apps/nextjs/public/images/apps/lastfm.svg b/apps/nextjs/public/images/apps/lastfm.svg new file mode 100644 index 000000000..e38bfb7d3 --- /dev/null +++ b/apps/nextjs/public/images/apps/lastfm.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/apps/nextjs/public/images/apps/vgmdb.svg b/apps/nextjs/public/images/apps/vgmdb.svg new file mode 100644 index 000000000..8439f025e --- /dev/null +++ b/apps/nextjs/public/images/apps/vgmdb.svg @@ -0,0 +1,47 @@ + + + + + + + + + + diff --git a/packages/cron-jobs/src/jobs/integrations/media-organizer.ts b/packages/cron-jobs/src/jobs/integrations/media-organizer.ts index be088b448..c05e3fb7a 100644 --- a/packages/cron-jobs/src/jobs/integrations/media-organizer.ts +++ b/packages/cron-jobs/src/jobs/integrations/media-organizer.ts @@ -27,7 +27,7 @@ export const mediaOrganizerJob = createCronJob("mediaOrganizer", EVERY_MINUTE).w //Asserting the integration kind until all of them get implemented const integrationInstance = integrationCreatorFromSecrets( - integration as Modify, + integration as Modify, ); const events = await integrationInstance.getCalendarEventsAsync(start, end); diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index 5894aea9e..e61f100b3 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -12,6 +12,7 @@ import { TransmissionIntegration } from "../download-client/transmission/transmi import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration"; 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 { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration"; import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration"; @@ -64,4 +65,5 @@ export const integrationCreators = { overseerr: OverseerrIntegration, prowlarr: ProwlarrIntegration, openmediavault: OpenMediaVaultIntegration, + lidarr: LidarrIntegration, } satisfies Partial Integration>>; diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index 8a0556c6b..7f84d2164 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -16,6 +16,7 @@ export { OverseerrIntegration } from "./overseerr/overseerr-integration"; 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"; // Types export type { IntegrationInput } from "./base/integration"; diff --git a/packages/integrations/src/media-organizer/lidarr/lidarr-integration.ts b/packages/integrations/src/media-organizer/lidarr/lidarr-integration.ts new file mode 100644 index 000000000..dcf6f5913 --- /dev/null +++ b/packages/integrations/src/media-organizer/lidarr/lidarr-integration.ts @@ -0,0 +1,127 @@ +import { logger } from "@homarr/log"; +import { z } from "@homarr/validation"; + +import type { CalendarEvent } from "../../calendar-types"; +import { MediaOrganizerIntegration } from "../media-organizer-integration"; + +export class LidarrIntegration 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): 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 ? "true" : "false"); + const response = await fetch(url, { + headers: { + "X-Api-Key": super.getSecretValue("apiKey"), + }, + }); + const lidarrCalendarEvents = await z.array(lidarrCalendarEventSchema).parseAsync(await response.json()); + + return lidarrCalendarEvents.map((lidarrCalendarEvent): CalendarEvent => { + return { + name: lidarrCalendarEvent.title, + subName: lidarrCalendarEvent.artist.artistName, + description: lidarrCalendarEvent.overview, + thumbnail: this.chooseBestImageAsURL(lidarrCalendarEvent), + date: lidarrCalendarEvent.releaseDate, + mediaInformation: { + type: "audio", + }, + links: this.getLinksForLidarrCalendarEvent(lidarrCalendarEvent), + }; + }); + } + + private getLinksForLidarrCalendarEvent = (event: z.infer) => { + const links: CalendarEvent["links"] = []; + + for (const link of event.artist.links) { + switch (link.name) { + case "vgmdb": + links.push({ + href: link.url, + name: "VgmDB", + color: "#f5c518", + isDark: false, + logo: "/images/apps/vgmdb.svg", + notificationColor: "cyan", + }); + break; + case "imdb": + links.push({ + href: link.url, + name: "IMDb", + color: "#f5c518", + isDark: false, + logo: "/images/apps/imdb.png", + notificationColor: "cyan", + }); + break; + case "last": + links.push({ + href: link.url, + name: "LastFM", + color: "#cf222a", + isDark: false, + logo: "/images/apps/lastfm.svg", + notificationColor: "cyan", + }); + break; + } + } + + 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; + }; +} + +const lidarrCalendarEventImageSchema = z.array( + z.object({ + coverType: z.enum(["screenshot", "poster", "banner", "fanart", "clearlogo", "cover"]), + remoteUrl: z.string().url(), + }), +); + +const lidarrCalendarEventSchema = z.object({ + title: z.string(), + overview: z.string().optional(), + images: lidarrCalendarEventImageSchema, + artist: z.object({ links: z.array(z.object({ url: z.string().url(), name: z.string() })), artistName: z.string() }), + releaseDate: z.string().transform((value) => new Date(value)), +}); diff --git a/packages/integrations/src/media-organizer/media-organizer-integration.ts b/packages/integrations/src/media-organizer/media-organizer-integration.ts new file mode 100644 index 000000000..3b841e7c0 --- /dev/null +++ b/packages/integrations/src/media-organizer/media-organizer-integration.ts @@ -0,0 +1,17 @@ +import { Integration } from "../base/integration"; + +export abstract class MediaOrganizerIntegration 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. + */ + protected readonly priorities: string[] = [ + "cover", // Official, perfect aspect ratio + "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 + ]; +} diff --git a/packages/integrations/src/media-organizer/radarr/radarr-integration.ts b/packages/integrations/src/media-organizer/radarr/radarr-integration.ts index bee8b1d63..e1387408c 100644 --- a/packages/integrations/src/media-organizer/radarr/radarr-integration.ts +++ b/packages/integrations/src/media-organizer/radarr/radarr-integration.ts @@ -2,24 +2,11 @@ import type { AtLeastOneOf } from "@homarr/common/types"; import { logger } from "@homarr/log"; import { z } from "@homarr/validation"; -import { Integration } from "../../base/integration"; import type { CalendarEvent } from "../../calendar-types"; import { radarrReleaseTypes } from "../../calendar-types"; +import { MediaOrganizerIntegration } from "../media-organizer-integration"; -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 - ]; - +export class RadarrIntegration extends MediaOrganizerIntegration { /** * Gets the events in the Radarr calendar between two dates. * @param start The start date diff --git a/packages/integrations/src/media-organizer/sonarr/sonarr-integration.ts b/packages/integrations/src/media-organizer/sonarr/sonarr-integration.ts index faa789b1b..aeb54fe4d 100644 --- a/packages/integrations/src/media-organizer/sonarr/sonarr-integration.ts +++ b/packages/integrations/src/media-organizer/sonarr/sonarr-integration.ts @@ -1,23 +1,10 @@ import { logger } from "@homarr/log"; import { z } from "@homarr/validation"; -import { Integration } from "../../base/integration"; import type { CalendarEvent } from "../../calendar-types"; +import { MediaOrganizerIntegration } from "../media-organizer-integration"; -export class SonarrIntegration 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 - ]; - +export class SonarrIntegration extends MediaOrganizerIntegration { /** * Gets the events in the Sonarr calendar between two dates. * @param start The start date