feat(integrations): add app linking (#4338)

This commit is contained in:
Meier Lukas
2025-10-24 20:21:27 +02:00
committed by GitHub
parent 6f0b5d7e04
commit 172db0e3f9
47 changed files with 6791 additions and 158 deletions

View File

@@ -1,7 +1,4 @@
import { decryptSecret } from "@homarr/common/server";
import type { Modify } from "@homarr/common/types";
import type { Integration as DbIntegration } from "@homarr/db/schema";
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
import type { IntegrationKind } from "@homarr/definitions";
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
import { CodebergIntegration } from "../codeberg/codeberg-integration";
@@ -62,20 +59,6 @@ export const createIntegrationAsync = async <TKind extends keyof typeof integrat
return new creator(integration) as IntegrationInstanceOfKind<TKind>;
};
export const createIntegrationAsyncFromSecrets = <TKind extends keyof typeof integrationCreators>(
integration: Modify<DbIntegration, { kind: TKind }> & {
secrets: { kind: IntegrationSecretKind; value: `${string}.${string}` }[];
},
) => {
return createIntegrationAsync({
...integration,
decryptedSecrets: integration.secrets.map((secret) => ({
...secret,
value: decryptSecret(secret.value),
})),
});
};
type IntegrationInstance = new (integration: IntegrationInput) => Integration;
// factories are an array, to differentiate in js between class constructors and functions

View File

@@ -17,6 +17,7 @@ export interface IntegrationInput {
id: string;
name: string;
url: string;
externalUrl: string | null;
decryptedSecrets: IntegrationSecret[];
}
@@ -54,8 +55,12 @@ export abstract class Integration {
return this.integration.decryptedSecrets.some((secret) => secret.kind === kind);
}
protected url(path: `/${string}`, queryParams?: Record<string, string | Date | number | boolean>) {
const baseUrl = removeTrailingSlash(this.integration.url);
private createUrl(
inputUrl: string,
path: `/${string}`,
queryParams?: Record<string, string | Date | number | boolean>,
) {
const baseUrl = removeTrailingSlash(inputUrl);
const url = new URL(`${baseUrl}${path}`);
if (queryParams) {
@@ -66,6 +71,13 @@ export abstract class Integration {
return url;
}
protected url(path: `/${string}`, queryParams?: Record<string, string | Date | number | boolean>) {
return this.createUrl(this.integration.url, path, queryParams);
}
protected externalUrl(path: `/${string}`, queryParams?: Record<string, string | Date | number | boolean>) {
return this.createUrl(this.integration.externalUrl ?? this.integration.url, path, queryParams);
}
public async testConnectionAsync(): Promise<TestingResult> {
try {

View File

@@ -125,7 +125,7 @@ export class EmbyIntegration extends Integration implements IMediaServerIntegrat
sessionId: `${sessionInfo.Id}`,
sessionName: `${sessionInfo.Client} (${sessionInfo.DeviceName})`,
user: {
profilePictureUrl: super.url(`/Users/${sessionInfo.UserId}/Images/Primary`).toString(),
profilePictureUrl: super.externalUrl(`/Users/${sessionInfo.UserId}/Images/Primary`).toString(),
userId: sessionInfo.UserId ?? "",
username: sessionInfo.UserName ?? "",
},
@@ -169,13 +169,13 @@ export class EmbyIntegration extends Integration implements IMediaServerIntegrat
description: item.Overview,
releaseDate: item.PremiereDate ?? item.DateCreated,
imageUrls: {
poster: super.url(`/Items/${item.Id}/Images/Primary?maxHeight=492&maxWidth=328&quality=90`).toString(),
backdrop: super.url(`/Items/${item.Id}/Images/Backdrop/0?maxWidth=960&quality=70`).toString(),
poster: super.externalUrl(`/Items/${item.Id}/Images/Primary?maxHeight=492&maxWidth=328&quality=90`).toString(),
backdrop: super.externalUrl(`/Items/${item.Id}/Images/Backdrop/0?maxWidth=960&quality=70`).toString(),
},
producer: item.Studios.at(0)?.Name,
rating: item.CommunityRating?.toFixed(1),
tags: item.Genres,
href: super.url(`/web/index.html#!/item?id=${item.Id}&serverId=${item.ServerId}`).toString(),
href: super.externalUrl(`/web/index.html#!/item?id=${item.Id}&serverId=${item.ServerId}`).toString(),
}));
}

View File

@@ -54,4 +54,4 @@ export type { Notification } from "./interfaces/notifications/notification-types
export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items";
// Helpers
export { createIntegrationAsync, createIntegrationAsyncFromSecrets } from "./base/creator";
export { createIntegrationAsync } from "./base/creator";

View File

@@ -94,7 +94,7 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
sessionId: `${sessionInfo.Id}`,
sessionName: `${sessionInfo.Client} (${sessionInfo.DeviceName})`,
user: {
profilePictureUrl: this.url(`/Users/${sessionInfo.UserId}/Images/Primary`).toString(),
profilePictureUrl: this.externalUrl(`/Users/${sessionInfo.UserId}/Images/Primary`).toString(),
userId: sessionInfo.UserId ?? "",
username: sessionInfo.UserName ?? "",
},
@@ -130,13 +130,13 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
releaseDate: new Date(item.PremiereDate ?? item.DateCreated!),
imageUrls: {
poster: super.url(`/Items/${item.Id}/Images/Primary?maxHeight=492&maxWidth=328&quality=90`).toString(),
backdrop: super.url(`/Items/${item.Id}/Images/Backdrop/0?maxWidth=960&quality=70`).toString(),
poster: super.externalUrl(`/Items/${item.Id}/Images/Primary?maxHeight=492&maxWidth=328&quality=90`).toString(),
backdrop: super.externalUrl(`/Items/${item.Id}/Images/Backdrop/0?maxWidth=960&quality=70`).toString(),
},
producer: item.Studios?.at(0)?.Name ?? undefined,
rating: item.CommunityRating?.toFixed(1),
tags: item.Genres ?? [],
href: super.url(`/web/index.html#!/details?id=${item.Id}&serverId=${item.ServerId}`).toString(),
href: super.externalUrl(`/web/index.html#!/details?id=${item.Id}&serverId=${item.ServerId}`).toString(),
}));
}

View File

@@ -67,7 +67,7 @@ export class RadarrIntegration extends Integration implements ICalendarIntegrati
private getLinksForRadarrCalendarEvent = (event: z.infer<typeof radarrCalendarEventSchema>) => {
const links: CalendarLink[] = [
{
href: this.url(`/movie/${event.titleSlug}`).toString(),
href: this.externalUrl(`/movie/${event.titleSlug}`).toString(),
name: "Radarr",
logo: "/images/apps/radarr.svg",
color: undefined,

View File

@@ -74,7 +74,7 @@ export class ReadarrIntegration extends Integration implements ICalendarIntegrat
private getLinksForReadarrCalendarEvent = (event: z.infer<typeof readarrCalendarEventSchema>) => {
return [
{
href: this.url(`/author/${event.author.foreignAuthorId}`).toString(),
href: this.externalUrl(`/author/${event.author.foreignAuthorId}`).toString(),
color: "#f5c518",
isDark: false,
logo: "/images/apps/readarr.svg",
@@ -101,7 +101,7 @@ export class ReadarrIntegration extends Integration implements ICalendarIntegrat
if (!bestImage) {
return undefined;
}
return this.url(bestImage.url as `/${string}`).toString();
return this.externalUrl(bestImage.url as `/${string}`).toString();
};
}

View File

@@ -63,7 +63,7 @@ export class SonarrIntegration extends Integration implements ICalendarIntegrati
private getLinksForSonarrCalendarEvent = (event: z.infer<typeof sonarrCalendarEventSchema>) => {
const links: CalendarLink[] = [
{
href: this.url(`/series/${event.series.titleSlug}`).toString(),
href: this.externalUrl(`/series/${event.series.titleSlug}`).toString(),
name: "Sonarr",
logo: "/images/apps/sonarr.svg",
color: undefined,

View File

@@ -87,7 +87,7 @@ export class NextcloudIntegration extends Integration implements ICalendarIntegr
"color" in veventObject && typeof veventObject.color === "string" ? veventObject.color : "#ff8600",
links: [
{
href: this.url(
href: this.externalUrl(
`/apps/calendar/timeGridWeek/now/edit/sidebar/${eventSlug}/${dateInMillis / 1000}`,
).toString(),
name: "Nextcloud",

View File

@@ -54,7 +54,7 @@ export class NTFYIntegration extends Integration implements INotificationsIntegr
return notifications
.filter((notification) => notification !== null)
.map((notification): Notification => {
const topicURL = this.url(`/${notification.topic}`);
const topicURL = this.externalUrl(`/${notification.topic}`);
return {
id: notification.id,
time: new Date(notification.time * 1000),

View File

@@ -43,7 +43,7 @@ export class OverseerrIntegration
return schemaData.results.map((result) => ({
id: result.id,
name: "name" in result ? result.name : result.title,
link: this.url(`/${result.mediaType}/${result.id}`).toString(),
link: this.externalUrl(`/${result.mediaType}/${result.id}`).toString(),
image: constructSearchResultImage(result),
text: "overview" in result ? result.overview : undefined,
type: result.mediaType,
@@ -144,7 +144,7 @@ export class OverseerrIntegration
availability: request.media.status,
backdropImageUrl: `https://image.tmdb.org/t/p/original/${information.backdropPath}`,
posterImagePath: `https://image.tmdb.org/t/p/w600_and_h900_bestv2/${information.posterPath}`,
href: this.url(`/${request.type}/${request.media.tmdbId}`).toString(),
href: this.externalUrl(`/${request.type}/${request.media.tmdbId}`).toString(),
type: request.type,
createdAt: request.createdAt,
airDate: new Date(information.airDate),
@@ -152,7 +152,7 @@ export class OverseerrIntegration
? ({
...request.requestedBy,
displayName: request.requestedBy.displayName,
link: this.url(`/users/${request.requestedBy.id}`).toString(),
link: this.externalUrl(`/users/${request.requestedBy.id}`).toString(),
avatar: this.constructAvatarUrl(request.requestedBy.avatar).toString(),
} satisfies Omit<RequestUser, "requestCount">)
: undefined,
@@ -180,7 +180,7 @@ export class OverseerrIntegration
return users.map((user): RequestUser => {
return {
...user,
link: this.url(`/users/${user.id}`).toString(),
link: this.externalUrl(`/users/${user.id}`).toString(),
avatar: this.constructAvatarUrl(user.avatar).toString(),
};
});
@@ -255,7 +255,7 @@ export class OverseerrIntegration
return avatar;
}
return this.url(`/${avatar}`);
return this.externalUrl(`/${avatar}`);
}
}