feat: add Uptime Kuma, Audiobookshelf, Paperless-ngx, and Navidrome integrations

Add four new integrations with full widget support:

- Uptime Kuma: dashboard monitors, uptime percentage ring, status counts
- Audiobookshelf: library stats, audiobooks/podcasts counts, listening time
- Paperless-ngx: document stats, inbox ratio ring, correspondents/tags
- Navidrome: artist/album/song counts, now playing with track list

Also adds a unified "Audio Stats" widget that merges Navidrome and
Audiobookshelf into a single configurable widget with backend-specific
option visibility.

Includes Vitest specs for pagination, status mapping, and shared utils.
This commit is contained in:
Thomas Camlong committed 2026-06-09 18:09:22 +02:00
1 parent a447a9f615
commit c9f003dadd
53 files changed
+3370 -17

No files matched your search

@@ -0,0 +1,40 @@
import type { AudiobookshelfDashboardData, NavidromeDashboardData } from "@homarr/integrations/types";
import { audiobookshelfRequestHandler } from "@homarr/request-handler/audiobookshelf";
import { navidromeRequestHandler } from "@homarr/request-handler/navidrome";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const requestHandlerByKind = {
navidrome: navidromeRequestHandler,
audiobookshelf: audiobookshelfRequestHandler,
} as const;
export const audioStatsRouter = createTRPCRouter({
getStats: publicProcedure
.concat(createOneIntegrationMiddleware("query", "navidrome", "audiobookshelf"))
.query(async ({ ctx }) => {
const fetchStatsByKind = {
navidrome: async () => {
const navidromeIntegration = { ...ctx.integration, kind: "navidrome" as const };
const innerHandler = requestHandlerByKind.navidrome.handler(navidromeIntegration, {});
const data = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
kind: "navidrome" as const,
data: data.data as NavidromeDashboardData,
};
},
audiobookshelf: async () => {
const audiobookshelfIntegration = { ...ctx.integration, kind: "audiobookshelf" as const };
const innerHandler = requestHandlerByKind.audiobookshelf.handler(audiobookshelfIntegration, {});
const data = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
kind: "audiobookshelf" as const,
data: data.data as AudiobookshelfDashboardData,
};
},
} as const;
return fetchStatsByKind[ctx.integration.kind]();
}),
});
@@ -0,0 +1,12 @@
import { audiobookshelfRequestHandler } from "@homarr/request-handler/audiobookshelf";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const audiobookshelfRouter = createTRPCRouter({
getStats: publicProcedure.concat(createOneIntegrationMiddleware("query", "audiobookshelf")).query(async ({ ctx }) => {
const innerHandler = audiobookshelfRequestHandler.handler(ctx.integration, {});
const data = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data.data;
}),
});
+5
View File
@@ -6,6 +6,7 @@ export const widgetRouter = createTRPCRouter({
anchorNotes: lazy(() => import("./anchor-notes").then((mod) => mod.anchorNotesRouter)),
coolify: lazy(() => import("./coolify").then((mod) => mod.coolifyRouter)),
immich: lazy(() => import("./immich").then((mod) => mod.immichRouter)),
paperlessNgx: lazy(() => import("./paperless-ngx").then((mod) => mod.paperlessNgxRouter)),
notebook: lazy(() => import("./notebook").then((mod) => mod.notebookRouter)),
weather: lazy(() => import("./weather").then((mod) => mod.weatherRouter)),
app: lazy(() => import("./app").then((mod) => mod.appRouter)),
@@ -30,6 +31,10 @@ export const widgetRouter = createTRPCRouter({
timetable: lazy(() => import("./timetable").then((mod) => mod.timetableRouter)),
tracearr: lazy(() => import("./tracearr").then((mod) => mod.tracearrRouter)),
speedtestTracker: lazy(() => import("./speedtest-tracker").then((mod) => mod.speedtestTrackerRouter)),
uptimeKuma: lazy(() => import("./uptime-kuma").then((mod) => mod.uptimeKumaRouter)),
audioStats: lazy(() => import("./audio-stats").then((mod) => mod.audioStatsRouter)),
audiobookshelf: lazy(() => import("./audiobookshelf").then((mod) => mod.audiobookshelfRouter)),
navidrome: lazy(() => import("./navidrome").then((mod) => mod.navidromeRouter)),
umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)),
ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)),
});
@@ -0,0 +1,21 @@
import { z } from "zod/v4";
import { navidromeRequestHandler } from "@homarr/request-handler/navidrome";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const navidromeRouter = createTRPCRouter({
getStats: publicProcedure
.concat(createOneIntegrationMiddleware("query", "navidrome"))
.input(
z.object({
integrationId: z.string(),
}),
)
.query(async ({ ctx }) => {
const innerHandler = navidromeRequestHandler.handler(ctx.integration, {});
const data = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data.data;
}),
});
@@ -0,0 +1,12 @@
import { paperlessNgxStatsRequestHandler } from "@homarr/request-handler/paperless-ngx";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const paperlessNgxRouter = createTRPCRouter({
getStats: publicProcedure.concat(createOneIntegrationMiddleware("query", "paperlessNgx")).query(async ({ ctx }) => {
const innerHandler = paperlessNgxStatsRequestHandler.handler(ctx.integration, {});
const data = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data.data;
}),
});
@@ -0,0 +1,55 @@
import { observable } from "@trpc/server/observable";
import type { UptimeKumaDashboardData } from "@homarr/integrations/types";
import { uptimeKumaRequestHandler } from "@homarr/request-handler/uptime-kuma";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const uptimeKumaRouter = createTRPCRouter({
getDashboard: publicProcedure
.concat(createManyIntegrationMiddleware("query", "uptimeKuma"))
.query(async ({ ctx }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = uptimeKumaRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: data,
updatedAt: timestamp,
};
}),
);
return results;
}),
subscribeToDashboard: publicProcedure
.concat(createManyIntegrationMiddleware("query", "uptimeKuma"))
.subscription(({ ctx }) => {
return observable<{
integrationId: string;
dashboard: UptimeKumaDashboardData;
timestamp: Date;
}>((emit) => {
const unsubscribes = ctx.integrations.map((integration) => {
const innerHandler = uptimeKumaRequestHandler.handler(integration, {});
return innerHandler.subscribe((dashboard) => {
emit.next({
integrationId: integration.id,
dashboard,
timestamp: new Date(),
});
});
});
return () => {
unsubscribes.forEach((unsubscribe) => unsubscribe());
};
});
}),
});