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