feat: add calendar widget (#663)
* feat: add calendar widget * feat: add artifacts to gitignore
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
|
||||
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
|
||||
import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration";
|
||||
import { PiHoleIntegration } from "../pi-hole/pi-hole-integration";
|
||||
import type { IntegrationInput } from "./integration";
|
||||
|
||||
@@ -10,6 +11,8 @@ export const integrationCreatorByKind = (kind: IntegrationKind, integration: Int
|
||||
return new PiHoleIntegration(integration);
|
||||
case "homeAssistant":
|
||||
return new HomeAssistantIntegration(integration);
|
||||
case "sonarr":
|
||||
return new SonarrIntegration(integration);
|
||||
default:
|
||||
throw new Error(`Unknown integration kind ${kind}. Did you forget to add it to the integration creator?`);
|
||||
}
|
||||
|
||||
20
packages/integrations/src/calendar-types.ts
Normal file
20
packages/integrations/src/calendar-types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export interface CalendarEvent {
|
||||
name: string;
|
||||
subName: string;
|
||||
date: Date;
|
||||
description?: string;
|
||||
thumbnail?: string;
|
||||
mediaInformation?: {
|
||||
type: "audio" | "video" | "tv" | "movie";
|
||||
seasonNumber?: number;
|
||||
episodeNumber?: number;
|
||||
};
|
||||
links: {
|
||||
href: string;
|
||||
name: string;
|
||||
color: string | undefined;
|
||||
notificationColor?: string | undefined;
|
||||
isDark: boolean | undefined;
|
||||
logo: string;
|
||||
}[];
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// General integrations
|
||||
export { PiHoleIntegration } from "./pi-hole/pi-hole-integration";
|
||||
export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration";
|
||||
export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration";
|
||||
|
||||
// Helpers
|
||||
export { IntegrationTestConnectionError } from "./base/test-connection-error";
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { appendPath } from "@homarr/common";
|
||||
import { logger } from "@homarr/log";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import { Integration } from "../../base/integration";
|
||||
import type { CalendarEvent } from "../../calendar-types";
|
||||
|
||||
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<typeof sonarCalendarEventSchema>["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 Sonarr 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 Sonarr 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("includeSeries", "true");
|
||||
url.searchParams.append("includeEpisodeFile", "true");
|
||||
url.searchParams.append("includeEpisodeImages", "true");
|
||||
url.searchParams.append("unmonitored", includeUnmonitored ? "true" : "false");
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"X-Api-Key": super.getSecretValue("apiKey"),
|
||||
},
|
||||
});
|
||||
const sonarCalendarEvents = await z.array(sonarCalendarEventSchema).parseAsync(await response.json());
|
||||
|
||||
return sonarCalendarEvents.map(
|
||||
(sonarCalendarEvent): CalendarEvent => ({
|
||||
name: sonarCalendarEvent.title,
|
||||
subName: sonarCalendarEvent.series.title,
|
||||
description: sonarCalendarEvent.series.overview,
|
||||
thumbnail: this.chooseBestImageAsURL(sonarCalendarEvent),
|
||||
date: sonarCalendarEvent.airDateUtc,
|
||||
mediaInformation: {
|
||||
type: "tv",
|
||||
episodeNumber: sonarCalendarEvent.episodeNumber,
|
||||
seasonNumber: sonarCalendarEvent.seasonNumber,
|
||||
},
|
||||
links: this.getLinksForSonarCalendarEvent(sonarCalendarEvent),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private getLinksForSonarCalendarEvent = (event: z.infer<typeof sonarCalendarEventSchema>) => {
|
||||
const links: CalendarEvent["links"] = [
|
||||
{
|
||||
href: `${this.integration.url}/series/${event.series.titleSlug}`,
|
||||
name: "Sonarr",
|
||||
logo: "/images/apps/sonarr.svg",
|
||||
color: undefined,
|
||||
notificationColor: "blue",
|
||||
isDark: true,
|
||||
},
|
||||
];
|
||||
|
||||
if (event.series.imdbId) {
|
||||
links.push({
|
||||
href: `https://www.imdb.com/title/${event.series.imdbId}/`,
|
||||
name: "IMDb",
|
||||
color: "#f5c518",
|
||||
isDark: false,
|
||||
logo: "/images/apps/imdb.png",
|
||||
});
|
||||
}
|
||||
|
||||
return links;
|
||||
};
|
||||
|
||||
private chooseBestImage = (
|
||||
event: z.infer<typeof sonarCalendarEventSchema>,
|
||||
): z.infer<typeof sonarCalendarEventSchema>["images"][number] | undefined => {
|
||||
const flatImages = [...event.images, ...event.series.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 sonarCalendarEventSchema>): 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(appendPath(this.integration.url, "/api/ping"), {
|
||||
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sonarCalendarEventImageSchema = z.array(
|
||||
z.object({
|
||||
coverType: z.enum(["screenshot", "poster", "banner", "fanart", "clearlogo"]),
|
||||
remoteUrl: z.string().url(),
|
||||
}),
|
||||
);
|
||||
|
||||
const sonarCalendarEventSchema = z.object({
|
||||
title: z.string(),
|
||||
airDateUtc: z.string().transform((value) => new Date(value)),
|
||||
seasonNumber: z.number().min(0),
|
||||
episodeNumber: z.number().min(0),
|
||||
series: z.object({
|
||||
overview: z.string(),
|
||||
title: z.string(),
|
||||
titleSlug: z.string(),
|
||||
images: sonarCalendarEventImageSchema,
|
||||
imdbId: z.string().optional(),
|
||||
}),
|
||||
images: sonarCalendarEventImageSchema,
|
||||
});
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./interfaces/dns-hole-summary/dns-hole-summary-types";
|
||||
export * from "./calendar-types";
|
||||
|
||||
Reference in New Issue
Block a user