feat: add Uptime Kuma, Audiobookshelf, Paperless-ngx, and Navidrome integrations (#5910)

This commit is contained in:
Thomas authored and GitHub committed 2026-06-10 18:27:24 +02:00
1 parent a4db9cf0dc
commit d77c6f8d4e
46 files changed
+2994 -16

No files matched your search

@@ -0,0 +1,171 @@
import { ParseError, ResponseError } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
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 { NavidromeDashboardData, NavidromeNowPlayingEntry, SubsonicResponseBody } from "./navidrome-types";
import { subsonicResponseSchema } from "./navidrome-types";
const logger = createLogger({ module: "navidromeIntegration" });
const subsonicVersion = "1.16.1";
const subsonicClient = "homarr";
const albumListPageSize = 500;
const maxAlbumListPages = 20;
const emptyLibraryMessage = "Library not found or empty";
const subsonicAuthErrorCodes = new Set([40, 41]);
const asArray = <TValue>(value: TValue | TValue[] | undefined): TValue[] =>
[value].flat().filter((item): item is TValue => item !== undefined);
export class NavidromeIntegration extends Integration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const url = this.url("/rest/ping.view", this.getAuthParams());
const response = await input.fetchAsync(url);
if (!response.ok) {
return TestConnectionError.StatusResult(response);
}
const parsed = subsonicResponseSchema.safeParse(await response.json());
if (!parsed.success) {
return TestConnectionError.ParseResult(new ParseError("Invalid Subsonic response", { cause: parsed.error }));
}
const subsonicResponse = parsed.data["subsonic-response"];
if (subsonicResponse.status === "failed") {
const errorCode = subsonicResponse.error?.code;
if (errorCode !== undefined && subsonicAuthErrorCodes.has(errorCode)) {
return TestConnectionError.UnauthorizedResult(401);
}
return TestConnectionError.UnknownResult(new Error(subsonicResponse.error?.message ?? "Subsonic request failed"));
}
return { success: true };
}
public async getDashboardDataAsync(): Promise<NavidromeDashboardData> {
const [artistCount, libraryCounts, nowPlaying] = await Promise.all([
this.getArtistCountAsync(),
this.getAlbumAndSongCountsAsync(),
this.getNowPlayingAsync(),
]);
return {
artistCount,
albumCount: libraryCounts.albumCount,
songCount: libraryCounts.songCount,
nowPlaying,
};
}
private async getArtistCountAsync(): Promise<number> {
const response = await this.subsonicRequestAsync("/rest/getArtists.view", {}, { tolerateEmptyLibrary: true });
return asArray(response.artists?.index).reduce((count, index) => count + asArray(index.artist).length, 0);
}
private async getAlbumAndSongCountsAsync(): Promise<{ albumCount: number; songCount: number }> {
let offset = 0;
let albumCount = 0;
let songCount = 0;
let page = 0;
while (page < maxAlbumListPages) {
const response = await this.subsonicRequestAsync(
"/rest/getAlbumList2.view",
{
type: "alphabeticalByName",
size: albumListPageSize,
offset,
},
{ tolerateEmptyLibrary: true },
);
const albums = asArray(response.albumList2?.album);
if (albums.length === 0) {
break;
}
albumCount += albums.length;
for (const album of albums) {
songCount += album.songCount ?? 0;
}
if (albums.length < albumListPageSize) {
break;
}
offset += albumListPageSize;
page += 1;
}
if (page >= maxAlbumListPages) {
logger.warn("Reached Navidrome album list page limit", {
integrationId: this.integration.id,
maxPages: maxAlbumListPages,
albumCount,
});
}
return { albumCount, songCount };
}
private async getNowPlayingAsync(): Promise<NavidromeNowPlayingEntry[]> {
const response = await this.subsonicRequestAsync("/rest/getNowPlaying.view", {}, { tolerateEmptyLibrary: true });
return asArray(response.nowPlaying?.entry).map((entry) => ({
title: entry.title ?? "",
artist: entry.artist ?? "",
album: entry.album ?? "",
username: entry.username ?? "",
playerName: entry.playerName ?? "",
}));
}
private async subsonicRequestAsync(
path: `/rest/${string}`,
params: Record<string, string | number> = {},
options: { tolerateEmptyLibrary?: boolean } = {},
): Promise<SubsonicResponseBody> {
const url = this.url(path, { ...this.getAuthParams(), ...params });
const response = await fetchWithTrustedCertificatesAsync(url);
if (!response.ok) {
throw new ResponseError(response);
}
const parsed = subsonicResponseSchema.parse(await response.json());
const subsonicResponse = parsed["subsonic-response"];
if (subsonicResponse.status === "failed") {
const message = subsonicResponse.error?.message ?? "Subsonic request failed";
if (options.tolerateEmptyLibrary && message === emptyLibraryMessage) {
return subsonicResponse;
}
throw new Error(message);
}
return subsonicResponse;
}
private getAuthParams(): Record<string, string> {
return {
u: this.getSecretValue("username"),
p: this.getSecretValue("password"),
v: subsonicVersion,
c: subsonicClient,
f: "json",
};
}
}
@@ -0,0 +1,71 @@
import { z } from "zod/v4";
const subsonicArtistSchema = z.object({
id: z.string(),
name: z.string(),
albumCount: z.number().optional(),
});
const subsonicIndexSchema = z.object({
name: z.string(),
artist: z.union([subsonicArtistSchema, z.array(subsonicArtistSchema)]).optional(),
});
const subsonicAlbumSchema = z.object({
id: z.string(),
name: z.string(),
songCount: z.number().optional(),
});
const subsonicNowPlayingEntrySchema = z.object({
title: z.string().optional(),
artist: z.string().optional(),
album: z.string().optional(),
username: z.string().optional(),
playerName: z.string().optional(),
});
export const subsonicResponseSchema = z.object({
"subsonic-response": z.object({
status: z.enum(["ok", "failed"]),
version: z.string().optional(),
error: z
.object({
code: z.number(),
message: z.string(),
})
.optional(),
artists: z
.object({
index: z.union([subsonicIndexSchema, z.array(subsonicIndexSchema)]).optional(),
})
.optional(),
albumList2: z
.object({
album: z.union([subsonicAlbumSchema, z.array(subsonicAlbumSchema)]).optional(),
})
.optional(),
nowPlaying: z
.object({
entry: z.union([subsonicNowPlayingEntrySchema, z.array(subsonicNowPlayingEntrySchema)]).optional(),
})
.optional(),
}),
});
export type SubsonicResponseBody = z.infer<typeof subsonicResponseSchema>["subsonic-response"];
export interface NavidromeNowPlayingEntry {
title: string;
artist: string;
album: string;
username: string;
playerName: string;
}
export interface NavidromeDashboardData {
artistCount: number;
albumCount: number;
songCount: number;
nowPlaying: NavidromeNowPlayingEntry[];
}
@@ -0,0 +1,201 @@
import { Response } from "undici";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationSecret } from "../../base/types";
import { NavidromeIntegration } from "../navidrome-integration";
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
const TEST_URL = "https://navidrome.example.com";
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const secrets: IntegrationSecret[] = [
{ kind: "username", value: "admin" },
{ kind: "password", value: "secret" },
];
const toUrlString = (url: unknown): string => String(url);
const createIntegration = () =>
new NavidromeIntegration({
id: "test-navidrome",
name: "Test Navidrome",
url: TEST_URL,
externalUrl: null,
decryptedSecrets: secrets,
});
const subsonicOk = <T extends Record<string, unknown>>(data: T) =>
JSON.stringify({
"subsonic-response": { status: "ok", version: "1.16.1", ...data },
});
const subsonicFailed = (message: string) =>
JSON.stringify({
"subsonic-response": { status: "failed", error: { code: 70, message } },
});
beforeEach(() => {
mockFetch.mockReset();
});
describe("NavidromeIntegration.getDashboardDataAsync", () => {
test("paginates album list and counts songs", async () => {
const makeAlbums = (count: number, songCount: number) =>
Array.from({ length: count }, (_, i) => ({
id: `album-${i}`,
name: `Album ${i}`,
songCount,
}));
mockFetch.mockImplementation((url) => {
const urlStr = toUrlString(url);
if (urlStr.includes("getArtists")) {
return Promise.resolve(
new Response(subsonicOk({ artists: { index: [{ name: "A", artist: [{ id: "1", name: "Artist" }] }] } }), {
status: 200,
}),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (urlStr.includes("getAlbumList2")) {
const offsetMatch = urlStr.match(/offset=(\d+)/);
const offset = Number(offsetMatch?.[1] ?? 0);
if (offset === 0) {
return Promise.resolve(
new Response(subsonicOk({ albumList2: { album: makeAlbums(500, 10) } }), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (offset === 500) {
return Promise.resolve(
new Response(subsonicOk({ albumList2: { album: makeAlbums(200, 5) } }), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
return Promise.resolve(new Response(subsonicOk({ albumList2: {} }), { status: 200 })) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>;
}
if (urlStr.includes("getNowPlaying")) {
return Promise.resolve(new Response(subsonicOk({ nowPlaying: {} }), { status: 200 })) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>;
}
return Promise.resolve(new Response(subsonicOk({}), { status: 200 })) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>;
});
const integration = createIntegration();
const result = await integration.getDashboardDataAsync();
expect(result.albumCount).toBe(700);
expect(result.songCount).toBe(500 * 10 + 200 * 5);
expect(result.artistCount).toBe(1);
expect(result.nowPlaying).toEqual([]);
});
test("handles empty library gracefully", async () => {
mockFetch.mockImplementation((url) => {
const urlStr = toUrlString(url);
if (urlStr.includes("getArtists")) {
return Promise.resolve(
new Response(subsonicFailed("Library not found or empty"), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (urlStr.includes("getAlbumList2")) {
return Promise.resolve(
new Response(subsonicFailed("Library not found or empty"), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (urlStr.includes("getNowPlaying")) {
return Promise.resolve(
new Response(subsonicFailed("Library not found or empty"), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
return Promise.resolve(new Response(subsonicOk({}), { status: 200 })) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>;
});
const integration = createIntegration();
const result = await integration.getDashboardDataAsync();
expect(result.artistCount).toBe(0);
expect(result.albumCount).toBe(0);
expect(result.songCount).toBe(0);
expect(result.nowPlaying).toEqual([]);
});
test("throws on non-empty-library subsonic error", async () => {
mockFetch.mockImplementation(
() =>
Promise.resolve(new Response(subsonicFailed("Permission denied"), { status: 200 })) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>,
);
const integration = createIntegration();
await expect(integration.getDashboardDataAsync()).rejects.toThrow();
});
test("now playing maps entries correctly", async () => {
mockFetch.mockImplementation((url) => {
const urlStr = toUrlString(url);
if (urlStr.includes("getNowPlaying")) {
return Promise.resolve(
new Response(
subsonicOk({
nowPlaying: {
entry: { title: "Song", artist: "Band", album: "LP", username: "user1", playerName: "Chrome" },
},
}),
{ status: 200 },
),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (urlStr.includes("getArtists")) {
return Promise.resolve(new Response(subsonicOk({ artists: {} }), { status: 200 })) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>;
}
if (urlStr.includes("getAlbumList2")) {
return Promise.resolve(new Response(subsonicOk({ albumList2: {} }), { status: 200 })) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>;
}
return Promise.resolve(new Response(subsonicOk({}), { status: 200 })) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>;
});
const integration = createIntegration();
const result = await integration.getDashboardDataAsync();
expect(result.nowPlaying).toHaveLength(1);
expect(result.nowPlaying[0]).toEqual({
title: "Song",
artist: "Band",
album: "LP",
username: "user1",
playerName: "Chrome",
});
});
});