From b277f444b2c01e251b53069c17674eb4188b5f8d Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Sat, 30 Nov 2024 10:54:50 +0100 Subject: [PATCH] fix: trailing slash integration url issues (#1571) --- packages/common/src/url.ts | 8 +-- .../adguard-home/adguard-home-integration.ts | 12 ++-- packages/integrations/src/base/integration.ts | 15 ++++- .../deluge/deluge-integration.ts | 3 +- .../nzbget/nzbget-integration.ts | 6 +- .../qbittorrent/qbittorrent-integration.ts | 3 +- .../sabnzbd/sabnzbd-integration.ts | 35 +++++------ .../transmission/transmission-integration.ts | 3 +- .../homeassistant-integration.ts | 11 ++-- .../src/jellyfin/jellyfin-integration.ts | 8 +-- .../lidarr/lidarr-integration.ts | 13 ++-- .../radarr/radarr-integration.ts | 15 ++--- .../readarr/readarr-integration.ts | 19 +++--- .../sonarr/sonarr-integration.ts | 21 ++++--- .../openmediavault-integration.ts | 2 +- .../src/overseerr/overseerr-integration.ts | 61 +++++++++---------- .../src/pi-hole/pi-hole-integration.ts | 8 +-- .../integrations/src/plex/plex-integration.ts | 4 +- .../src/prowlarr/prowlarr-integration.ts | 8 +-- 19 files changed, 126 insertions(+), 129 deletions(-) diff --git a/packages/common/src/url.ts b/packages/common/src/url.ts index e734ae6e8..aa15066f6 100644 --- a/packages/common/src/url.ts +++ b/packages/common/src/url.ts @@ -1,12 +1,6 @@ import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers"; -export const appendPath = (url: URL | string, path: string) => { - const newUrl = new URL(url); - newUrl.pathname = removeTrailingSlash(newUrl.pathname) + path; - return newUrl; -}; - -const removeTrailingSlash = (path: string) => { +export const removeTrailingSlash = (path: string) => { return path.at(-1) === "/" ? path.substring(0, path.length - 1) : path; }; diff --git a/packages/integrations/src/adguard-home/adguard-home-integration.ts b/packages/integrations/src/adguard-home/adguard-home-integration.ts index e8b6c5e1a..d7aca6add 100644 --- a/packages/integrations/src/adguard-home/adguard-home-integration.ts +++ b/packages/integrations/src/adguard-home/adguard-home-integration.ts @@ -6,7 +6,7 @@ import { filteringStatusSchema, statsResponseSchema, statusResponseSchema } from export class AdGuardHomeIntegration extends Integration implements DnsHoleSummaryIntegration { public async getSummaryAsync(): Promise { - const statsResponse = await fetch(`${this.integration.url}/control/stats`, { + const statsResponse = await fetch(this.url("/control/stats"), { headers: { Authorization: `Basic ${this.getAuthorizationHeaderValue()}`, }, @@ -18,7 +18,7 @@ export class AdGuardHomeIntegration extends Integration implements DnsHoleSummar ); } - const statusResponse = await fetch(`${this.integration.url}/control/status`, { + const statusResponse = await fetch(this.url("/control/status"), { headers: { Authorization: `Basic ${this.getAuthorizationHeaderValue()}`, }, @@ -30,7 +30,7 @@ export class AdGuardHomeIntegration extends Integration implements DnsHoleSummar ); } - const filteringStatusResponse = await fetch(`${this.integration.url}/control/filtering/status`, { + const filteringStatusResponse = await fetch(this.url("/control/filtering/status"), { headers: { Authorization: `Basic ${this.getAuthorizationHeaderValue()}`, }, @@ -86,7 +86,7 @@ export class AdGuardHomeIntegration extends Integration implements DnsHoleSummar public async testConnectionAsync(): Promise { await super.handleTestConnectionResponseAsync({ queryFunctionAsync: async () => { - return await fetch(`${this.integration.url}/control/status`, { + return await fetch(this.url("/control/status"), { headers: { Authorization: `Basic ${this.getAuthorizationHeaderValue()}`, }, @@ -106,7 +106,7 @@ export class AdGuardHomeIntegration extends Integration implements DnsHoleSummar } public async enableAsync(): Promise { - const response = await fetch(`${this.integration.url}/control/protection`, { + const response = await fetch(this.url("/control/protection"), { method: "POST", headers: { "Content-Type": "application/json", @@ -124,7 +124,7 @@ export class AdGuardHomeIntegration extends Integration implements DnsHoleSummar } public async disableAsync(duration = 0): Promise { - const response = await fetch(`${this.integration.url}/control/protection`, { + const response = await fetch(this.url("/control/protection"), { method: "POST", headers: { "Content-Type": "application/json", diff --git a/packages/integrations/src/base/integration.ts b/packages/integrations/src/base/integration.ts index 69b346e23..c9c953443 100644 --- a/packages/integrations/src/base/integration.ts +++ b/packages/integrations/src/base/integration.ts @@ -1,4 +1,4 @@ -import { extractErrorMessage } from "@homarr/common"; +import { extractErrorMessage, removeTrailingSlash } from "@homarr/common"; import type { IntegrationSecretKind } from "@homarr/definitions"; import { logger } from "@homarr/log"; import type { TranslationObject } from "@homarr/translation"; @@ -29,6 +29,19 @@ export abstract class Integration { return secret.value; } + protected url(path: `/${string}`, queryParams?: Record) { + const baseUrl = removeTrailingSlash(this.integration.url); + const url = new URL(`${baseUrl}${path}`); + + if (queryParams) { + for (const [key, value] of Object.entries(queryParams)) { + url.searchParams.set(key, value instanceof Date ? value.toISOString() : value.toString()); + } + } + + return url; + } + /** * Test the connection to the integration * @throws {IntegrationTestConnectionError} if the connection fails diff --git a/packages/integrations/src/download-client/deluge/deluge-integration.ts b/packages/integrations/src/download-client/deluge/deluge-integration.ts index ae7d18b36..29067f2c0 100644 --- a/packages/integrations/src/download-client/deluge/deluge-integration.ts +++ b/packages/integrations/src/download-client/deluge/deluge-integration.ts @@ -89,9 +89,8 @@ export class DelugeIntegration extends DownloadClientIntegration { } private getClient() { - const baseUrl = new URL(this.integration.url).href; return new Deluge({ - baseUrl, + baseUrl: this.url("/").toString(), password: this.getSecretValue("password"), }); } diff --git a/packages/integrations/src/download-client/nzbget/nzbget-integration.ts b/packages/integrations/src/download-client/nzbget/nzbget-integration.ts index 5e91f393b..b17af0a87 100644 --- a/packages/integrations/src/download-client/nzbget/nzbget-integration.ts +++ b/packages/integrations/src/download-client/nzbget/nzbget-integration.ts @@ -92,9 +92,9 @@ export class NzbGetIntegration extends DownloadClientIntegration { method: CallType, ...params: Parameters ): Promise> { - const url = new URL(this.integration.url); - url.pathname += `${this.getSecretValue("username")}:${this.getSecretValue("password")}`; - url.pathname += url.pathname.endsWith("/") ? "jsonrpc" : "/jsonrpc"; + const username = this.getSecretValue("username"); + const password = this.getSecretValue("password"); + const url = this.url(`/${username}:${password}/jsonrpc`); const body = JSON.stringify({ method, params }); return await fetch(url, { method: "POST", body }) .then(async (response) => { diff --git a/packages/integrations/src/download-client/qbittorrent/qbittorrent-integration.ts b/packages/integrations/src/download-client/qbittorrent/qbittorrent-integration.ts index 407790fbf..2932e47c4 100644 --- a/packages/integrations/src/download-client/qbittorrent/qbittorrent-integration.ts +++ b/packages/integrations/src/download-client/qbittorrent/qbittorrent-integration.ts @@ -70,9 +70,8 @@ export class QBitTorrentIntegration extends DownloadClientIntegration { } private getClient() { - const baseUrl = new URL(this.integration.url).href; return new QBittorrent({ - baseUrl, + baseUrl: this.url("/").toString(), username: this.getSecretValue("username"), password: this.getSecretValue("password"), }); diff --git a/packages/integrations/src/download-client/sabnzbd/sabnzbd-integration.ts b/packages/integrations/src/download-client/sabnzbd/sabnzbd-integration.ts index 22b65d1b3..644420644 100644 --- a/packages/integrations/src/download-client/sabnzbd/sabnzbd-integration.ts +++ b/packages/integrations/src/download-client/sabnzbd/sabnzbd-integration.ts @@ -12,7 +12,7 @@ dayjs.extend(duration); export class SabnzbdIntegration extends DownloadClientIntegration { public async testConnectionAsync(): Promise { //This is the one call that uses the least amount of data while requiring the api key - await this.sabNzbApiCallAsync("translate", new URLSearchParams({ value: "ping" })); + await this.sabNzbApiCallAsync("translate", { value: "ping" }); } public async getClientJobsAndStatusAsync(): Promise { @@ -75,7 +75,7 @@ export class SabnzbdIntegration extends DownloadClientIntegration { } public async pauseItemAsync({ id }: DownloadClientItem) { - await this.sabNzbApiCallAsync("queue", new URLSearchParams({ name: "pause", value: id })); + await this.sabNzbApiCallAsync("queue", { name: "pause", value: id }); } public async resumeQueueAsync() { @@ -83,32 +83,29 @@ export class SabnzbdIntegration extends DownloadClientIntegration { } public async resumeItemAsync({ id }: DownloadClientItem): Promise { - await this.sabNzbApiCallAsync("queue", new URLSearchParams({ name: "resume", value: id })); + await this.sabNzbApiCallAsync("queue", { name: "resume", value: id }); } //Delete files prevented on completed files. https://github.com/sabnzbd/sabnzbd/issues/2754 //Works on all other in downloading and post-processing. //Will stop working as soon as the finished files is moved to completed folder. public async deleteItemAsync({ id, progress }: DownloadClientItem, fromDisk: boolean): Promise { - await this.sabNzbApiCallAsync( - progress !== 1 ? "queue" : "history", - new URLSearchParams({ - name: "delete", - archive: fromDisk ? "0" : "1", - value: id, - del_files: fromDisk ? "1" : "0", - }), - ); + await this.sabNzbApiCallAsync(progress !== 1 ? "queue" : "history", { + name: "delete", + archive: fromDisk ? "0" : "1", + value: id, + del_files: fromDisk ? "1" : "0", + }); } - private async sabNzbApiCallAsync(mode: string, searchParams?: URLSearchParams): Promise { - const url = new URL("api", this.integration.url); - url.searchParams.append("output", "json"); - url.searchParams.append("mode", mode); - searchParams?.forEach((value, key) => { - url.searchParams.append(key, value); + private async sabNzbApiCallAsync(mode: string, searchParams?: Record): Promise { + const url = this.url("/api", { + ...searchParams, + output: "json", + mode, + apikey: this.getSecretValue("apiKey"), }); - url.searchParams.append("apikey", this.getSecretValue("apiKey")); + return await fetch(url) .then((response) => { if (!response.ok) { diff --git a/packages/integrations/src/download-client/transmission/transmission-integration.ts b/packages/integrations/src/download-client/transmission/transmission-integration.ts index 2258546b8..919da6815 100644 --- a/packages/integrations/src/download-client/transmission/transmission-integration.ts +++ b/packages/integrations/src/download-client/transmission/transmission-integration.ts @@ -71,9 +71,8 @@ export class TransmissionIntegration extends DownloadClientIntegration { } private getClient() { - const baseUrl = new URL(this.integration.url).href; return new Transmission({ - baseUrl, + baseUrl: this.url("/").toString(), username: this.getSecretValue("username"), password: this.getSecretValue("password"), }); diff --git a/packages/integrations/src/homeassistant/homeassistant-integration.ts b/packages/integrations/src/homeassistant/homeassistant-integration.ts index 47f10ce3e..7d69d40b1 100644 --- a/packages/integrations/src/homeassistant/homeassistant-integration.ts +++ b/packages/integrations/src/homeassistant/homeassistant-integration.ts @@ -1,4 +1,3 @@ -import { appendPath } from "@homarr/common"; import { logger } from "@homarr/log"; import { Integration } from "../base/integration"; @@ -17,7 +16,7 @@ export class HomeAssistantIntegration extends Integration { } return entityStateSchema.safeParseAsync(body); } catch (err) { - logger.error(`Failed to fetch from ${this.integration.url}: ${err as string}`); + logger.error(`Failed to fetch from ${this.url("/")}: ${err as string}`); return { success: false as const, error: err, @@ -33,7 +32,7 @@ export class HomeAssistantIntegration extends Integration { return response.ok; } catch (err) { - logger.error(`Failed to fetch from '${this.integration.url}': ${err as string}`); + logger.error(`Failed to fetch from '${this.url("/")}': ${err as string}`); return false; } } @@ -52,7 +51,7 @@ export class HomeAssistantIntegration extends Integration { return response.ok; } catch (err) { - logger.error(`Failed to fetch from '${this.integration.url}': ${err as string}`); + logger.error(`Failed to fetch from '${this.url("/")}': ${err as string}`); return false; } } @@ -72,7 +71,7 @@ export class HomeAssistantIntegration extends Integration { * @returns the response from the API */ private async getAsync(path: `/api/${string}`) { - return await fetch(appendPath(this.integration.url, path), { + return await fetch(this.url(path), { headers: this.getAuthHeaders(), }); } @@ -85,7 +84,7 @@ export class HomeAssistantIntegration extends Integration { * @returns the response from the API */ private async postAsync(path: `/api/${string}`, body: Record) { - return await fetch(appendPath(this.integration.url, path), { + return await fetch(this.url(path), { headers: this.getAuthHeaders(), body: JSON.stringify(body), method: "POST", diff --git a/packages/integrations/src/jellyfin/jellyfin-integration.ts b/packages/integrations/src/jellyfin/jellyfin-integration.ts index 2cdd79f78..4a603d6c2 100644 --- a/packages/integrations/src/jellyfin/jellyfin-integration.ts +++ b/packages/integrations/src/jellyfin/jellyfin-integration.ts @@ -29,9 +29,7 @@ export class JellyfinIntegration extends Integration { const sessions = await sessionApi.getSessions(); if (sessions.status !== 200) { - throw new Error( - `Jellyfin server ${this.integration.url} returned a non successful status code: ${sessions.status}`, - ); + throw new Error(`Jellyfin server ${this.url("/")} returned a non successful status code: ${sessions.status}`); } return sessions.data.map((sessionInfo): StreamSession => { @@ -52,7 +50,7 @@ export class JellyfinIntegration extends Integration { sessionId: `${sessionInfo.Id}`, sessionName: `${sessionInfo.Client} (${sessionInfo.DeviceName})`, user: { - profilePictureUrl: `${this.integration.url}/Users/${sessionInfo.UserId}/Images/Primary`, + profilePictureUrl: this.url(`/Users/${sessionInfo.UserId}/Images/Primary`).toString(), userId: sessionInfo.UserId ?? "", username: sessionInfo.UserName ?? "", }, @@ -63,6 +61,6 @@ export class JellyfinIntegration extends Integration { private getApi() { const apiKey = this.getSecretValue("apiKey"); - return this.jellyfin.createApi(this.integration.url, apiKey); + return this.jellyfin.createApi(this.url("/").toString(), apiKey); } } diff --git a/packages/integrations/src/media-organizer/lidarr/lidarr-integration.ts b/packages/integrations/src/media-organizer/lidarr/lidarr-integration.ts index dcf6f5913..b89d552ea 100644 --- a/packages/integrations/src/media-organizer/lidarr/lidarr-integration.ts +++ b/packages/integrations/src/media-organizer/lidarr/lidarr-integration.ts @@ -8,7 +8,7 @@ export class LidarrIntegration extends MediaOrganizerIntegration { public async testConnectionAsync(): Promise { await super.handleTestConnectionResponseAsync({ queryFunctionAsync: async () => { - return await fetch(`${this.integration.url}/api`, { + return await fetch(this.url("/api"), { headers: { "X-Api-Key": super.getSecretValue("apiKey") }, }); }, @@ -22,11 +22,12 @@ export class LidarrIntegration extends MediaOrganizerIntegration { * @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 url = this.url("/api/v1/calendar", { + start, + end, + unmonitored: includeUnmonitored, + }); + const response = await fetch(url, { headers: { "X-Api-Key": super.getSecretValue("apiKey"), diff --git a/packages/integrations/src/media-organizer/radarr/radarr-integration.ts b/packages/integrations/src/media-organizer/radarr/radarr-integration.ts index e1387408c..e11400b1e 100644 --- a/packages/integrations/src/media-organizer/radarr/radarr-integration.ts +++ b/packages/integrations/src/media-organizer/radarr/radarr-integration.ts @@ -14,11 +14,12 @@ export class RadarrIntegration extends MediaOrganizerIntegration { * @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/v3/calendar"; - url.searchParams.append("start", start.toISOString()); - url.searchParams.append("end", end.toISOString()); - url.searchParams.append("unmonitored", includeUnmonitored ? "true" : "false"); + const url = this.url("/api/v3/calendar", { + start, + end, + unmonitored: includeUnmonitored, + }); + const response = await fetch(url, { headers: { "X-Api-Key": super.getSecretValue("apiKey"), @@ -48,7 +49,7 @@ export class RadarrIntegration extends MediaOrganizerIntegration { private getLinksForRadarrCalendarEvent = (event: z.infer) => { const links: CalendarEvent["links"] = [ { - href: `${this.integration.url}/movie/${event.titleSlug}`, + href: this.url(`/movie/${event.titleSlug}`).toString(), name: "Radarr", logo: "/images/apps/radarr.svg", color: undefined, @@ -93,7 +94,7 @@ export class RadarrIntegration extends MediaOrganizerIntegration { public async testConnectionAsync(): Promise { await super.handleTestConnectionResponseAsync({ queryFunctionAsync: async () => { - return await fetch(`${this.integration.url}/api`, { + return await fetch(this.url("/api"), { headers: { "X-Api-Key": super.getSecretValue("apiKey") }, }); }, diff --git a/packages/integrations/src/media-organizer/readarr/readarr-integration.ts b/packages/integrations/src/media-organizer/readarr/readarr-integration.ts index 4283c39fa..13d9eaccc 100644 --- a/packages/integrations/src/media-organizer/readarr/readarr-integration.ts +++ b/packages/integrations/src/media-organizer/readarr/readarr-integration.ts @@ -8,7 +8,7 @@ export class ReadarrIntegration extends MediaOrganizerIntegration { public async testConnectionAsync(): Promise { await super.handleTestConnectionResponseAsync({ queryFunctionAsync: async () => { - return await fetch(`${this.integration.url}/api`, { + return await fetch(this.url("/api"), { headers: { "X-Api-Key": super.getSecretValue("apiKey") }, }); }, @@ -27,12 +27,13 @@ export class ReadarrIntegration extends MediaOrganizerIntegration { includeUnmonitored = true, includeAuthor = 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.toString()); - url.searchParams.append("includeAuthor", includeAuthor.toString()); + const url = this.url("/api/v1/calendar", { + start, + end, + unmonitored: includeUnmonitored, + includeAuthor, + }); + const response = await fetch(url, { headers: { "X-Api-Key": super.getSecretValue("apiKey"), @@ -58,7 +59,7 @@ export class ReadarrIntegration extends MediaOrganizerIntegration { private getLinksForReadarrCalendarEvent = (event: z.infer) => { return [ { - href: `${this.integration.url}/author/${event.author.foreignAuthorId}`, + href: this.url(`/author/${event.author.foreignAuthorId}`).toString(), color: "#f5c518", isDark: false, logo: "/images/apps/readarr.svg", @@ -85,7 +86,7 @@ export class ReadarrIntegration extends MediaOrganizerIntegration { if (!bestImage) { return undefined; } - return `${this.integration.url}${bestImage.url}`; + return this.url(bestImage.url as `/${string}`).toString(); }; } diff --git a/packages/integrations/src/media-organizer/sonarr/sonarr-integration.ts b/packages/integrations/src/media-organizer/sonarr/sonarr-integration.ts index aeb54fe4d..c13c65620 100644 --- a/packages/integrations/src/media-organizer/sonarr/sonarr-integration.ts +++ b/packages/integrations/src/media-organizer/sonarr/sonarr-integration.ts @@ -12,14 +12,15 @@ export class SonarrIntegration extends MediaOrganizerIntegration { * @param includeUnmonitored When true results will include unmonitored items of the Sonarr library. */ async getCalendarEventsAsync(start: Date, end: Date, includeUnmonitored = true): Promise { - 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 url = this.url("/api/v3/calendar", { + start, + end, + unmonitored: includeUnmonitored, + includeSeries: true, + includeEpisodeFile: true, + includeEpisodeImages: true, + }); + const response = await fetch(url, { headers: { "X-Api-Key": super.getSecretValue("apiKey"), @@ -47,7 +48,7 @@ export class SonarrIntegration extends MediaOrganizerIntegration { private getLinksForSonarCalendarEvent = (event: z.infer) => { const links: CalendarEvent["links"] = [ { - href: `${this.integration.url}/series/${event.series.titleSlug}`, + href: this.url(`/series/${event.series.titleSlug}`).toString(), name: "Sonarr", logo: "/images/apps/sonarr.svg", color: undefined, @@ -92,7 +93,7 @@ export class SonarrIntegration extends MediaOrganizerIntegration { public async testConnectionAsync(): Promise { await super.handleTestConnectionResponseAsync({ queryFunctionAsync: async () => { - return await fetch(`${this.integration.url}/api`, { + return await fetch(this.url("/api"), { headers: { "X-Api-Key": super.getSecretValue("apiKey") }, }); }, diff --git a/packages/integrations/src/openmediavault/openmediavault-integration.ts b/packages/integrations/src/openmediavault/openmediavault-integration.ts index fc73a2e31..2296ac124 100644 --- a/packages/integrations/src/openmediavault/openmediavault-integration.ts +++ b/packages/integrations/src/openmediavault/openmediavault-integration.ts @@ -117,7 +117,7 @@ export class OpenMediaVaultIntegration extends Integration { params: Record, headers: Record = {}, ): Promise { - return await fetch(`${this.integration.url}/rpc.php`, { + return await fetch(this.url("/rpc.php"), { method: "POST", headers: { "Content-Type": "application/json", diff --git a/packages/integrations/src/overseerr/overseerr-integration.ts b/packages/integrations/src/overseerr/overseerr-integration.ts index db5ab2b56..18101e778 100644 --- a/packages/integrations/src/overseerr/overseerr-integration.ts +++ b/packages/integrations/src/overseerr/overseerr-integration.ts @@ -11,7 +11,7 @@ import { MediaAvailability, MediaRequestStatus } from "../interfaces/media-reque */ export class OverseerrIntegration extends Integration implements ISearchableIntegration { public async searchAsync(query: string): Promise<{ image?: string; name: string; link: string; text?: string }[]> { - const response = await fetch(`${this.integration.url}/api/v1/search?query=${query}`, { + const response = await fetch(this.url("/api/v1/search", { query }), { headers: { "X-Api-Key": this.getSecretValue("apiKey"), }, @@ -24,13 +24,14 @@ export class OverseerrIntegration extends Integration implements ISearchableInte return schemaData.results.map((result) => ({ name: "name" in result ? result.name : result.title, - link: `${this.integration.url}/${result.mediaType}/${result.id}`, - image: constructSearchResultImage(this.integration.url, result), + link: this.url(`/${result.mediaType}/${result.id}`).toString(), + image: constructSearchResultImage(result), text: "overview" in result ? result.overview : undefined, })); } + public async testConnectionAsync(): Promise { - const response = await fetch(`${this.integration.url}/api/v1/auth/me`, { + const response = await fetch(this.url("/api/v1/auth/me"), { headers: { "X-Api-Key": this.getSecretValue("apiKey"), }, @@ -46,14 +47,14 @@ export class OverseerrIntegration extends Integration implements ISearchableInte public async getRequestsAsync(): Promise { //Ensure to get all pending request first - const pendingRequests = await fetch(`${this.integration.url}/api/v1/request?take=-1&filter=pending`, { + const pendingRequests = await fetch(this.url("/api/v1/request", { take: -1, filter: "pending" }), { headers: { "X-Api-Key": this.getSecretValue("apiKey"), }, }); //Change 20 to integration setting (set to -1 for all) - const allRequests = await fetch(`${this.integration.url}/api/v1/request?take=20`, { + const allRequests = await fetch(this.url("/api/v1/request", { take: 20 }), { headers: { "X-Api-Key": this.getSecretValue("apiKey"), }, @@ -83,7 +84,7 @@ export class OverseerrIntegration extends Integration implements ISearchableInte 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.integration.url}/${request.type}/${request.media.tmdbId}`, + href: this.url(`/${request.type}/${request.media.tmdbId}`).toString(), type: request.type, createdAt: request.createdAt, airDate: new Date(information.airDate), @@ -91,8 +92,8 @@ export class OverseerrIntegration extends Integration implements ISearchableInte ? ({ ...request.requestedBy, displayName: request.requestedBy.displayName, - link: `${this.integration.url}/users/${request.requestedBy.id}`, - avatar: constructAvatarUrl(this.integration.url, request.requestedBy.avatar), + link: this.url(`/users/${request.requestedBy.id}`).toString(), + avatar: this.constructAvatarUrl(request.requestedBy.avatar).toString(), } satisfies Omit) : undefined, }; @@ -101,7 +102,7 @@ export class OverseerrIntegration extends Integration implements ISearchableInte } public async getStatsAsync(): Promise { - const response = await fetch(`${this.integration.url}/api/v1/request/count`, { + const response = await fetch(this.url("/api/v1/request/count"), { headers: { "X-Api-Key": this.getSecretValue("apiKey"), }, @@ -110,7 +111,7 @@ export class OverseerrIntegration extends Integration implements ISearchableInte } public async getUsersAsync(): Promise { - const response = await fetch(`${this.integration.url}/api/v1/user?take=-1`, { + const response = await fetch(this.url("/api/v1/user", { take: -1 }), { headers: { "X-Api-Key": this.getSecretValue("apiKey"), }, @@ -119,15 +120,15 @@ export class OverseerrIntegration extends Integration implements ISearchableInte return users.map((user): RequestUser => { return { ...user, - link: `${this.integration.url}/users/${user.id}`, - avatar: constructAvatarUrl(this.integration.url, user.avatar), + link: this.url(`/users/${user.id}`).toString(), + avatar: this.constructAvatarUrl(user.avatar).toString(), }; }); } public async approveRequestAsync(requestId: number): Promise { logger.info(`Approving media request id='${requestId}' integration='${this.integration.name}'`); - await fetch(`${this.integration.url}/api/v1/request/${requestId}/approve`, { + await fetch(this.url(`/api/v1/request/${requestId}/approve`), { method: "POST", headers: { "X-Api-Key": this.getSecretValue("apiKey"), @@ -145,7 +146,7 @@ export class OverseerrIntegration extends Integration implements ISearchableInte public async declineRequestAsync(requestId: number): Promise { logger.info(`Declining media request id='${requestId}' integration='${this.integration.name}'`); - await fetch(`${this.integration.url}/api/v1/request/${requestId}/decline`, { + await fetch(this.url(`/api/v1/request/${requestId}/decline`), { method: "POST", headers: { "X-Api-Key": this.getSecretValue("apiKey"), @@ -162,7 +163,7 @@ export class OverseerrIntegration extends Integration implements ISearchableInte } private async getItemInformationAsync(id: number, type: MediaRequest["type"]): Promise { - const response = await fetch(`${this.integration.url}/api/v1/${type}/${id}`, { + const response = await fetch(this.url(`/api/v1/${type}/${id}`), { headers: { "X-Api-Key": this.getSecretValue("apiKey"), }, @@ -186,17 +187,17 @@ export class OverseerrIntegration extends Integration implements ISearchableInte airDate: movie.releaseDate, } satisfies MediaInformation; } -} -const constructAvatarUrl = (appUrl: string, avatar: string) => { - const isAbsolute = avatar.startsWith("http://") || avatar.startsWith("https://"); + private constructAvatarUrl(avatar: string) { + const isAbsolute = avatar.startsWith("http://") || avatar.startsWith("https://"); - if (isAbsolute) { - return avatar; + if (isAbsolute) { + return avatar; + } + + return this.url(`/${avatar}`); } - - return `${appUrl}/${avatar}`; -}; +} interface MediaInformation { name: string; @@ -308,11 +309,8 @@ const getUsersSchema = z.object({ }), }); -const constructSearchResultImage = ( - appUrl: string, - result: Exclude["results"], undefined>[number], -) => { - const path = getResultImagePath(appUrl, result); +const constructSearchResultImage = (result: Exclude["results"], undefined>[number]) => { + const path = getResultImagePath(result); if (!path) { return undefined; } @@ -320,10 +318,7 @@ const constructSearchResultImage = ( return `https://image.tmdb.org/t/p/w600_and_h900_bestv2${path}`; }; -const getResultImagePath = ( - appUrl: string, - result: Exclude["results"], undefined>[number], -) => { +const getResultImagePath = (result: Exclude["results"], undefined>[number]) => { switch (result.mediaType) { case "person": return result.profilePath; diff --git a/packages/integrations/src/pi-hole/pi-hole-integration.ts b/packages/integrations/src/pi-hole/pi-hole-integration.ts index b1f25aa6b..5549ab91a 100644 --- a/packages/integrations/src/pi-hole/pi-hole-integration.ts +++ b/packages/integrations/src/pi-hole/pi-hole-integration.ts @@ -7,7 +7,7 @@ import { summaryResponseSchema } from "./pi-hole-types"; export class PiHoleIntegration extends Integration implements DnsHoleSummaryIntegration { public async getSummaryAsync(): Promise { const apiKey = super.getSecretValue("apiKey"); - const response = await fetch(`${this.integration.url}/admin/api.php?summaryRaw&auth=${apiKey}`); + const response = await fetch(this.url("/admin/api.php?summaryRaw", { auth: apiKey })); if (!response.ok) { throw new Error( `Failed to fetch summary for ${this.integration.name} (${this.integration.id}): ${response.statusText}`, @@ -36,7 +36,7 @@ export class PiHoleIntegration extends Integration implements DnsHoleSummaryInte await super.handleTestConnectionResponseAsync({ queryFunctionAsync: async () => { - return await fetch(`${this.integration.url}/admin/api.php?status&auth=${apiKey}`); + return await fetch(this.url("/admin/api.php?status", { auth: apiKey })); }, handleResponseAsync: async (response) => { try { @@ -53,7 +53,7 @@ export class PiHoleIntegration extends Integration implements DnsHoleSummaryInte public async enableAsync(): Promise { const apiKey = super.getSecretValue("apiKey"); - const response = await fetch(`${this.integration.url}/admin/api.php?enable&auth=${apiKey}`); + const response = await fetch(this.url("/admin/api.php?enable", { auth: apiKey })); if (!response.ok) { throw new Error( `Failed to enable PiHole for ${this.integration.name} (${this.integration.id}): ${response.statusText}`, @@ -63,7 +63,7 @@ export class PiHoleIntegration extends Integration implements DnsHoleSummaryInte public async disableAsync(duration?: number): Promise { const apiKey = super.getSecretValue("apiKey"); - const url = `${this.integration.url}/admin/api.php?disable${duration ? `=${duration}` : ""}&auth=${apiKey}`; + const url = this.url(`/admin/api.php?disable${duration ? `=${duration}` : ""}`, { auth: apiKey }); const response = await fetch(url); if (!response.ok) { throw new Error( diff --git a/packages/integrations/src/plex/plex-integration.ts b/packages/integrations/src/plex/plex-integration.ts index cdbad1d41..d33e9457b 100644 --- a/packages/integrations/src/plex/plex-integration.ts +++ b/packages/integrations/src/plex/plex-integration.ts @@ -11,7 +11,7 @@ export class PlexIntegration extends Integration { public async getCurrentSessionsAsync(): Promise { const token = super.getSecretValue("apiKey"); - const response = await fetch(`${this.integration.url}/status/sessions`, { + const response = await fetch(this.url("/status/sessions"), { headers: { "X-Plex-Token": token, }, @@ -66,7 +66,7 @@ export class PlexIntegration extends Integration { await super.handleTestConnectionResponseAsync({ queryFunctionAsync: async () => { - return await fetch(this.integration.url, { + return await fetch(this.url("/"), { headers: { "X-Plex-Token": token, }, diff --git a/packages/integrations/src/prowlarr/prowlarr-integration.ts b/packages/integrations/src/prowlarr/prowlarr-integration.ts index 8768073c4..ee76a8ed1 100644 --- a/packages/integrations/src/prowlarr/prowlarr-integration.ts +++ b/packages/integrations/src/prowlarr/prowlarr-integration.ts @@ -7,7 +7,7 @@ export class ProwlarrIntegration extends Integration { public async getIndexersAsync(): Promise { const apiKey = super.getSecretValue("apiKey"); - const indexerResponse = await fetch(`${this.integration.url}/api/v1/indexer`, { + const indexerResponse = await fetch(this.url("/api/v1/indexer"), { headers: { "X-Api-Key": apiKey, }, @@ -18,7 +18,7 @@ export class ProwlarrIntegration extends Integration { ); } - const statusResponse = await fetch(`${this.integration.url}/api/v1/indexerstatus`, { + const statusResponse = await fetch(this.url("/api/v1/indexerstatus"), { headers: { "X-Api-Key": apiKey, }, @@ -60,7 +60,7 @@ export class ProwlarrIntegration extends Integration { public async testAllAsync(): Promise { const apiKey = super.getSecretValue("apiKey"); - const response = await fetch(`${this.integration.url}/api/v1/indexer/testall`, { + const response = await fetch(this.url("/api/v1/indexer/testall"), { headers: { "X-Api-Key": apiKey, }, @@ -78,7 +78,7 @@ export class ProwlarrIntegration extends Integration { await super.handleTestConnectionResponseAsync({ queryFunctionAsync: async () => { - return await fetch(`${this.integration.url}/api`, { + return await fetch(this.url("/api"), { headers: { "X-Api-Key": apiKey, },