feat(integrations): add ICal (#3980)

Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
Nicolas Newman
2025-09-17 11:57:50 -05:00
committed by GitHub
parent 0a1a75dc5f
commit fedbff3fd1
22 changed files with 395 additions and 196 deletions

View File

@@ -18,6 +18,7 @@ import { GitHubContainerRegistryIntegration } from "../github-container-registry
import { GithubIntegration } from "../github/github-integration";
import { GitlabIntegration } from "../gitlab/gitlab-integration";
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
import { ICalIntegration } from "../ical/ical-integration";
import { JellyfinIntegration } from "../jellyfin/jellyfin-integration";
import { JellyseerrIntegration } from "../jellyseerr/jellyseerr-integration";
import { LinuxServerIOIntegration } from "../linuxserverio/linuxserverio-integration";
@@ -112,6 +113,7 @@ export const integrationCreators = {
codeberg: CodebergIntegration,
linuxServerIO: LinuxServerIOIntegration,
gitHubContainerRegistry: GitHubContainerRegistryIntegration,
ical: ICalIntegration,
quay: QuayIntegration,
ntfy: NTFYIntegration,
mock: MockIntegration,

View File

@@ -0,0 +1,67 @@
import ICAL from "ical.js";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { ICalendarIntegration } from "../interfaces/calendar/calendar-integration";
import type { CalendarEvent } from "../interfaces/calendar/calendar-types";
export class ICalIntegration extends Integration implements ICalendarIntegration {
async getCalendarEventsAsync(start: Date, end: Date): Promise<CalendarEvent[]> {
const response = await fetchWithTrustedCertificatesAsync(super.getSecretValue("url"));
const result = await response.text();
const jcal = ICAL.parse(result) as unknown[];
const comp = new ICAL.Component(jcal);
return comp.getAllSubcomponents("vevent").reduce((prev, vevent) => {
const event = new ICAL.Event(vevent);
const startDate = event.startDate.toJSDate();
const endDate = event.endDate.toJSDate();
if (startDate > end) return prev;
if (endDate < start) return prev;
return prev.concat({
title: event.summary,
subTitle: null,
description: event.description,
startDate,
endDate,
image: null,
location: event.location,
indicatorColor: "red",
links: [],
});
}, [] as CalendarEvent[]);
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(super.getSecretValue("url"));
if (!response.ok) return TestConnectionError.StatusResult(response);
const result = await response.text();
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const jcal = ICAL.parse(result);
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const comp = new ICAL.Component(jcal);
return comp.getAllSubcomponents("vevent").length > 0
? { success: true }
: TestConnectionError.ParseResult({
name: "Calendar parse error",
message: "No events found",
cause: new Error("No events found"),
});
} catch (error) {
return TestConnectionError.ParseResult({
name: "Calendar parse error",
message: "Failed to parse calendar",
cause: error as Error,
});
}
}
}

View File

@@ -23,6 +23,7 @@ export { PlexIntegration } from "./plex/plex-integration";
export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration";
export { TrueNasIntegration } from "./truenas/truenas-integration";
export { OPNsenseIntegration } from "./opnsense/opnsense-integration";
export { ICalIntegration } from "./ical/ical-integration";
// Types
export type { IntegrationInput } from "./base/integration";

View File

@@ -1,24 +1,41 @@
export const radarrReleaseTypes = ["inCinemas", "digitalRelease", "physicalRelease"] as const;
export type RadarrReleaseType = (typeof radarrReleaseTypes)[number];
export interface CalendarEvent {
name: string;
subName: string;
date: Date;
dates?: { type: RadarrReleaseType; 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;
}[];
export interface RadarrMetadata {
type: "radarr";
releaseType: RadarrReleaseType;
}
export type CalendarMetadata = RadarrMetadata;
export interface CalendarLink {
name: string;
isDark: boolean;
href: string;
color?: string;
logo?: string;
}
export interface CalendarImageBadge {
content: string;
color: string;
}
export interface CalendarImage {
src: string;
badge?: CalendarImageBadge;
aspectRatio?: { width: number; height: number };
}
export interface CalendarEvent {
title: string;
subTitle: string | null;
description: string | null;
startDate: Date;
endDate: Date | null;
image: CalendarImage | null;
location: string | null;
metadata?: CalendarMetadata;
indicatorColor: string;
links: CalendarLink[];
}

View File

@@ -8,7 +8,7 @@ import type { IntegrationTestingInput } from "../../base/integration";
import { TestConnectionError } from "../../base/test-connection/test-connection-error";
import type { TestingResult } from "../../base/test-connection/test-connection-service";
import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration";
import type { CalendarEvent } from "../../interfaces/calendar/calendar-types";
import type { CalendarEvent, CalendarLink } from "../../interfaces/calendar/calendar-types";
import { mediaOrganizerPriorities } from "../media-organizer";
export class LidarrIntegration extends Integration implements ICalendarIntegration {
@@ -44,22 +44,28 @@ export class LidarrIntegration extends Integration implements ICalendarIntegrati
const lidarrCalendarEvents = await z.array(lidarrCalendarEventSchema).parseAsync(await response.json());
return lidarrCalendarEvents.map((lidarrCalendarEvent): CalendarEvent => {
const imageSrc = this.chooseBestImage(lidarrCalendarEvent);
return {
name: lidarrCalendarEvent.title,
subName: lidarrCalendarEvent.artist.artistName,
description: lidarrCalendarEvent.overview,
thumbnail: this.chooseBestImageAsURL(lidarrCalendarEvent),
date: lidarrCalendarEvent.releaseDate,
mediaInformation: {
type: "audio",
},
title: lidarrCalendarEvent.title,
subTitle: lidarrCalendarEvent.artist.artistName,
description: lidarrCalendarEvent.overview ?? null,
startDate: lidarrCalendarEvent.releaseDate,
endDate: null,
image: imageSrc
? {
src: imageSrc.remoteUrl,
aspectRatio: { width: 7, height: 12 },
}
: null,
location: null,
indicatorColor: "cyan",
links: this.getLinksForLidarrCalendarEvent(lidarrCalendarEvent),
};
});
}
private getLinksForLidarrCalendarEvent = (event: z.infer<typeof lidarrCalendarEventSchema>) => {
const links: CalendarEvent["links"] = [];
const links: CalendarLink[] = [];
for (const link of event.artist.links) {
switch (link.name) {
@@ -70,7 +76,6 @@ export class LidarrIntegration extends Integration implements ICalendarIntegrati
color: "#f5c518",
isDark: false,
logo: "/images/apps/vgmdb.svg",
notificationColor: "cyan",
});
break;
case "imdb":
@@ -80,7 +85,6 @@ export class LidarrIntegration extends Integration implements ICalendarIntegrati
color: "#f5c518",
isDark: false,
logo: "/images/apps/imdb.png",
notificationColor: "cyan",
});
break;
case "last":
@@ -90,7 +94,6 @@ export class LidarrIntegration extends Integration implements ICalendarIntegrati
color: "#cf222a",
isDark: false,
logo: "/images/apps/lastfm.svg",
notificationColor: "cyan",
});
break;
}

View File

@@ -1,15 +1,14 @@
import { z } from "zod/v4";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import type { AtLeastOneOf } from "@homarr/common/types";
import { logger } from "@homarr/log";
import { Integration } from "../../base/integration";
import type { IntegrationTestingInput } from "../../base/integration";
import { Integration } from "../../base/integration";
import { TestConnectionError } from "../../base/test-connection/test-connection-error";
import type { TestingResult } from "../../base/test-connection/test-connection-service";
import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration";
import type { CalendarEvent } from "../../interfaces/calendar/calendar-types";
import type { CalendarEvent, CalendarLink } from "../../interfaces/calendar/calendar-types";
import { radarrReleaseTypes } from "../../interfaces/calendar/calendar-types";
import { mediaOrganizerPriorities } from "../media-organizer";
@@ -34,33 +33,44 @@ export class RadarrIntegration extends Integration implements ICalendarIntegrati
});
const radarrCalendarEvents = await z.array(radarrCalendarEventSchema).parseAsync(await response.json());
return radarrCalendarEvents.map((radarrCalendarEvent): CalendarEvent => {
const dates = radarrReleaseTypes
.map((type) => (radarrCalendarEvent[type] ? { type, date: radarrCalendarEvent[type] } : undefined))
.filter((date) => date) as AtLeastOneOf<Exclude<CalendarEvent["dates"], undefined>[number]>;
return {
name: radarrCalendarEvent.title,
subName: radarrCalendarEvent.originalTitle,
description: radarrCalendarEvent.overview,
thumbnail: this.chooseBestImageAsURL(radarrCalendarEvent),
date: dates[0].date,
dates,
mediaInformation: {
type: "movie",
},
links: this.getLinksForRadarrCalendarEvent(radarrCalendarEvent),
};
return radarrCalendarEvents.flatMap((radarrCalendarEvent): CalendarEvent[] => {
const imageSrc = this.chooseBestImageAsURL(radarrCalendarEvent);
return radarrReleaseTypes
.map((releaseType) => ({ type: releaseType, date: radarrCalendarEvent[releaseType] }))
.filter((item) => item.date !== undefined)
.map((item) => ({
title: radarrCalendarEvent.title,
subTitle: radarrCalendarEvent.originalTitle,
description: radarrCalendarEvent.overview ?? null,
// Check is done above in the filter
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
startDate: item.date!,
endDate: null,
image: imageSrc
? {
src: imageSrc,
aspectRatio: { width: 7, height: 12 },
}
: null,
location: null,
metadata: {
type: "radarr",
releaseType: item.type,
},
indicatorColor: "yellow",
links: this.getLinksForRadarrCalendarEvent(radarrCalendarEvent),
}));
});
}
private getLinksForRadarrCalendarEvent = (event: z.infer<typeof radarrCalendarEventSchema>) => {
const links: CalendarEvent["links"] = [
const links: CalendarLink[] = [
{
href: this.url(`/movie/${event.titleSlug}`).toString(),
name: "Radarr",
logo: "/images/apps/radarr.svg",
color: undefined,
notificationColor: "yellow",
isDark: true,
},
];

View File

@@ -8,7 +8,7 @@ import type { IntegrationTestingInput } from "../../base/integration";
import { TestConnectionError } from "../../base/test-connection/test-connection-error";
import type { TestingResult } from "../../base/test-connection/test-connection-service";
import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration";
import type { CalendarEvent } from "../../interfaces/calendar/calendar-types";
import type { CalendarEvent, CalendarLink } from "../../interfaces/calendar/calendar-types";
import { mediaOrganizerPriorities } from "../media-organizer";
export class ReadarrIntegration extends Integration implements ICalendarIntegration {
@@ -50,15 +50,22 @@ export class ReadarrIntegration extends Integration implements ICalendarIntegrat
const readarrCalendarEvents = await z.array(readarrCalendarEventSchema).parseAsync(await response.json());
return readarrCalendarEvents.map((readarrCalendarEvent): CalendarEvent => {
const imageSrc = this.chooseBestImageAsURL(readarrCalendarEvent);
return {
name: readarrCalendarEvent.title,
subName: readarrCalendarEvent.author.authorName,
description: readarrCalendarEvent.overview,
thumbnail: this.chooseBestImageAsURL(readarrCalendarEvent),
date: readarrCalendarEvent.releaseDate,
mediaInformation: {
type: "audio",
},
title: readarrCalendarEvent.title,
subTitle: readarrCalendarEvent.author.authorName,
description: readarrCalendarEvent.overview ?? null,
startDate: readarrCalendarEvent.releaseDate,
endDate: null,
image: imageSrc
? {
src: imageSrc,
aspectRatio: { width: 7, height: 12 },
}
: null,
location: null,
indicatorColor: "#f5c518",
links: this.getLinksForReadarrCalendarEvent(readarrCalendarEvent),
};
});
@@ -72,9 +79,8 @@ export class ReadarrIntegration extends Integration implements ICalendarIntegrat
isDark: false,
logo: "/images/apps/readarr.svg",
name: "Readarr",
notificationColor: "#f5c518",
},
] satisfies CalendarEvent["links"];
] satisfies CalendarLink[];
};
private chooseBestImage = (

View File

@@ -8,7 +8,7 @@ import type { IntegrationTestingInput } from "../../base/integration";
import { TestConnectionError } from "../../base/test-connection/test-connection-error";
import type { TestingResult } from "../../base/test-connection/test-connection-service";
import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration";
import type { CalendarEvent } from "../../interfaces/calendar/calendar-types";
import type { CalendarEvent, CalendarLink } from "../../interfaces/calendar/calendar-types";
import { mediaOrganizerPriorities } from "../media-organizer";
export class SonarrIntegration extends Integration implements ICalendarIntegration {
@@ -33,33 +33,36 @@ export class SonarrIntegration extends Integration implements ICalendarIntegrati
"X-Api-Key": super.getSecretValue("apiKey"),
},
});
const sonarCalendarEvents = await z.array(sonarrCalendarEventSchema).parseAsync(await response.json());
const sonarrCalendarEvents = await z.array(sonarrCalendarEventSchema).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),
}),
);
return sonarrCalendarEvents.map((event): CalendarEvent => {
const imageSrc = this.chooseBestImageAsURL(event);
return {
title: event.title,
subTitle: event.series.title,
description: event.series.overview ?? null,
startDate: event.airDateUtc,
endDate: null,
image: imageSrc
? {
src: imageSrc,
aspectRatio: { width: 7, height: 12 },
}
: null,
location: null,
indicatorColor: "blue",
links: this.getLinksForSonarrCalendarEvent(event),
};
});
}
private getLinksForSonarCalendarEvent = (event: z.infer<typeof sonarrCalendarEventSchema>) => {
const links: CalendarEvent["links"] = [
private getLinksForSonarrCalendarEvent = (event: z.infer<typeof sonarrCalendarEventSchema>) => {
const links: CalendarLink[] = [
{
href: this.url(`/series/${event.series.titleSlug}`).toString(),
name: "Sonarr",
logo: "/images/apps/sonarr.svg",
color: undefined,
notificationColor: "blue",
isDark: true,
},
];

View File

@@ -8,32 +8,46 @@ export class CalendarMockService implements ICalendarIntegration {
}
}
const homarrMeetup = (start: Date, end: Date): CalendarEvent => ({
name: "Homarr Meetup",
subName: "",
description: "Yearly meetup of the Homarr community",
date: randomDateBetween(start, end),
links: [
{
href: "https://homarr.dev",
name: "Homarr",
logo: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg",
color: "#000000",
notificationColor: "#fa5252",
isDark: true,
},
],
});
const homarrMeetup = (start: Date, end: Date): CalendarEvent => {
const startDate = randomDateBetween(start, end);
const endDate = new Date(startDate.getTime() + 2 * 60 * 60 * 1000); // 2 hours later
return {
title: "Homarr Meetup",
subTitle: "",
description: "Yearly meetup of the Homarr community",
startDate,
endDate,
image: null,
location: "Mountains",
indicatorColor: "#fa5252",
links: [
{
href: "https://homarr.dev",
name: "Homarr",
logo: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg",
color: "#000000",
isDark: true,
},
],
};
};
const titanicRelease = (start: Date, end: Date): CalendarEvent => ({
name: "Titanic",
subName: "A classic movie",
title: "Titanic",
subTitle: "A classic movie",
description: "A tragic love story set on the ill-fated RMS Titanic.",
date: randomDateBetween(start, end),
thumbnail: "https://image.tmdb.org/t/p/original/5bTWA20cL9LCIGNpde4Epc2Ijzn.jpg",
mediaInformation: {
type: "movie",
startDate: randomDateBetween(start, end),
endDate: null,
image: {
src: "https://image.tmdb.org/t/p/original/5bTWA20cL9LCIGNpde4Epc2Ijzn.jpg",
aspectRatio: { width: 7, height: 12 },
},
location: null,
metadata: {
type: "radarr",
releaseType: "inCinemas",
},
indicatorColor: "cyan",
links: [
{
href: "https://www.imdb.com/title/tt0120338/",
@@ -41,22 +55,26 @@ const titanicRelease = (start: Date, end: Date): CalendarEvent => ({
color: "#f5c518",
isDark: false,
logo: "/images/apps/imdb.svg",
notificationColor: "cyan",
},
],
});
const seriesRelease = (start: Date, end: Date): CalendarEvent => ({
name: "The Mandalorian",
subName: "A Star Wars Series",
title: "The Mandalorian",
subTitle: "A Star Wars Series",
description: "A lone bounty hunter in the outer reaches of the galaxy.",
date: randomDateBetween(start, end),
thumbnail: "https://image.tmdb.org/t/p/original/ztvm7C7hiUpS3CZRXFmJxljICzK.jpg",
mediaInformation: {
type: "tv",
seasonNumber: 1,
episodeNumber: 1,
startDate: randomDateBetween(start, end),
endDate: null,
image: {
src: "https://image.tmdb.org/t/p/original/sWgBv7LV2PRoQgkxwlibdGXKz1S.jpg",
aspectRatio: { width: 7, height: 12 },
badge: {
content: "S1:E1",
color: "red",
},
},
location: null,
indicatorColor: "blue",
links: [
{
href: "https://www.imdb.com/title/tt8111088/",
@@ -64,7 +82,6 @@ const seriesRelease = (start: Date, end: Date): CalendarEvent => ({
color: "#f5c518",
isDark: false,
logo: "/images/apps/imdb.svg",
notificationColor: "blue",
},
],
});

View File

@@ -63,17 +63,20 @@ export class NextcloudIntegration extends Integration implements ICalendarIntegr
);
return {
name: veventObject.summary,
date,
subName: "",
title: veventObject.summary,
subTitle: null,
description: veventObject.description,
startDate: date,
endDate: veventObject.end,
image: null,
location: veventObject.location || null,
indicatorColor: "#ff8600",
links: [
{
href: url.toString(),
name: "Nextcloud",
logo: "/images/apps/nextcloud.svg",
color: undefined,
notificationColor: "#ff8600",
isDark: true,
},
],