feat: add Bazarr integration and subtitle status widget (#6156)
Co-authored-by: Thomas <thomas@ajnart.fr>
This commit is contained in:
1 parent
7d93df04dd
commit
2c5368dd4e
28 files changed
+635
No files matched your search
@@ -19,6 +19,14 @@ packages/integrations/src/anchor @ohmzi
|
||||
apps/docs/docs/integrations/archive-team-warrior @darbyjack
|
||||
packages/integrations/src/archive-team-warrior @darbyjack
|
||||
|
||||
# Bazarr - introduced in https://github.com/homarr-labs/homarr/pull/6156 by @Ayush7614
|
||||
apps/docs/docs/integrations/bazarr @Ayush7614
|
||||
apps/docs/docs/widgets/bazarr @Ayush7614
|
||||
packages/api/src/router/widgets/bazarr.ts @Ayush7614
|
||||
packages/integrations/src/bazarr @Ayush7614
|
||||
packages/request-handler/src/bazarr.ts @Ayush7614
|
||||
packages/widgets/src/bazarr @Ayush7614
|
||||
|
||||
# Audiobookshelf - introduced in https://github.com/homarr-labs/homarr/pull/5910 by @ajnart
|
||||
apps/docs/docs/integrations/audiobookshelf @ajnart
|
||||
packages/integrations/src/audiobookshelf @ajnart
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@@ -0,0 +1,54 @@
|
||||
---
|
||||
title: 'Bazarr'
|
||||
description: "Bazarr is a companion application to Sonarr and Radarr that manages and downloads subtitles."
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { IntegrationHeader } from '@site/src/components/integrations/header';
|
||||
import { IntegrationCapabilites } from '@site/src/components/integrations/widgets';
|
||||
import { AddingIntegration } from '@site/src/components/integrations/adding';
|
||||
import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
|
||||
import { bazarrIntegration } from '.';
|
||||
import { bazarrWidget } from '@site/docs/widgets/bazarr';
|
||||
|
||||
<IntegrationHeader
|
||||
integration={bazarrIntegration}
|
||||
categories={['Subtitles', 'Media']}
|
||||
/>
|
||||
|
||||
Bazarr complements Sonarr and Radarr by managing subtitle downloads. The Homarr integration uses your Bazarr API key to fetch missing subtitle counts and health badges.
|
||||
|
||||
### Screenshots
|
||||
|
||||
import apiKeySettings from './img/api-key-settings.png';
|
||||
import widgetOverview from '@site/docs/widgets/bazarr/img/widget-overview.png';
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<p>API key location in Bazarr (<strong>Settings → General → Security</strong>):</p>
|
||||
<img src={apiKeySettings} alt="bazarr-api-key-settings" width={128 * 4} />
|
||||
</div>
|
||||
<div>
|
||||
<p>Bazarr widget on a Homarr board:</p>
|
||||
<img src={widgetOverview} alt="bazarr-widget-on-board" width={128 * 4} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
### Widgets & Capabilities
|
||||
<IntegrationCapabilites
|
||||
items={[{ widget: bazarrWidget }]}
|
||||
/>
|
||||
|
||||
### Adding the integration
|
||||
<AddingIntegration />
|
||||
|
||||
### Secrets
|
||||
You can find your Bazarr API key under **Settings → General → Security**.
|
||||
|
||||
<IntegrationSecrets secrets={[{
|
||||
credentials: ['apiKey'],
|
||||
steps: [
|
||||
"Open Bazarr and go to Settings → General → Security.",
|
||||
"Copy the API key and paste it into Homarr when creating the integration.",
|
||||
],
|
||||
}]} />
|
||||
@@ -0,0 +1,9 @@
|
||||
import { IntegrationDefinition } from "@site/src/types";
|
||||
|
||||
export const bazarrIntegration: IntegrationDefinition = {
|
||||
name: "Bazarr",
|
||||
description:
|
||||
"Bazarr is a companion application to Sonarr and Radarr that manages and downloads subtitles for your media library.",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/bazarr.svg",
|
||||
path: "../../integrations/bazarr",
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
@@ -0,0 +1,41 @@
|
||||
---
|
||||
title: 'Bazarr'
|
||||
description: 'Displays missing subtitle counts and health indicators from your Bazarr instance.'
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { WidgetHeader } from '@site/src/components/widgets/header';
|
||||
import { AddingWidget } from '@site/src/components/widgets/adding';
|
||||
import { WidgetConfig } from '@site/src/components/widgets/configuration';
|
||||
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
|
||||
import { bazarrWidget } from '.';
|
||||
import { bazarrIntegration } from '@site/docs/integrations/bazarr';
|
||||
|
||||
<WidgetHeader
|
||||
widget={bazarrWidget}
|
||||
categories={['Subtitles', 'Media']}
|
||||
/>
|
||||
|
||||
This widget shows subtitle status from your Bazarr instance, including missing episode and movie subtitles, provider issues, and health warnings.
|
||||
|
||||
### Screenshots
|
||||
|
||||
import widgetOverview from './img/widget-overview.png';
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<img src={widgetOverview} alt="bazarr-widget-overview" width={128 * 4} />
|
||||
</div>
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations items={[{
|
||||
integration: bazarrIntegration,
|
||||
note: "Displays missing subtitle counts and health indicators"
|
||||
}]} />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
<WidgetConfig configuration={bazarrWidget.configuration} />
|
||||
@@ -0,0 +1,37 @@
|
||||
import { WidgetDefinition } from "@site/src/types";
|
||||
import { IconBadgeCc } from "@tabler/icons-react";
|
||||
|
||||
export const bazarrWidget: WidgetDefinition = {
|
||||
icon: IconBadgeCc,
|
||||
name: "Bazarr",
|
||||
description: "Displays missing subtitle counts and health indicators from your Bazarr instance.",
|
||||
path: "../../widgets/bazarr",
|
||||
configuration: {
|
||||
items: [
|
||||
{
|
||||
name: "Show missing episodes",
|
||||
description: "Displays the number of episodes with missing subtitles",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show missing movies",
|
||||
description: "Displays the number of movies with missing subtitles",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show provider issues",
|
||||
description: "Displays the number of subtitle providers with issues",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show health issues",
|
||||
description: "Displays the number of Bazarr health issues",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import { iconsRouter } from "./router/icons";
|
||||
import { infoRouter } from "./router/info";
|
||||
import { integrationRouter } from "./router/integration/integration-router";
|
||||
import { inviteRouter } from "./router/invite";
|
||||
import { bazarrRouter } from "./router/widgets/bazarr";
|
||||
import { serverSettingsRouter } from "./router/serverSettings";
|
||||
import { beszelRouter } from "./router/widgets/beszel";
|
||||
import { calendarRouter } from "./router/widgets/calendar";
|
||||
@@ -29,6 +30,7 @@ export const mcpRouter = createTRPCRouter({
|
||||
info: infoRouter,
|
||||
integration: integrationRouter,
|
||||
invite: inviteRouter,
|
||||
bazarr: bazarrRouter,
|
||||
serverSettings: serverSettingsRouter,
|
||||
beszel: beszelRouter,
|
||||
calendar: calendarRouter,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { bazarrBadgesRequestHandler } from "@homarr/request-handler/bazarr";
|
||||
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const bazarrRouter = createTRPCRouter({
|
||||
getBadges: publicProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Get missing subtitle counts, provider issues, and health warnings for a Bazarr integration. REQUIRED: integrationId from integration_all",
|
||||
},
|
||||
})
|
||||
.concat(createOneIntegrationMiddleware("query", "bazarr"))
|
||||
.query(async ({ ctx }) => {
|
||||
const innerHandler = bazarrBadgesRequestHandler.handler(ctx.integration, {});
|
||||
const data = await innerHandler.getDataAsync();
|
||||
return data.data;
|
||||
}),
|
||||
});
|
||||
@@ -9,6 +9,7 @@ export const widgetRouter = createTRPCRouter({
|
||||
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)),
|
||||
bazarr: lazy(() => import("./bazarr").then((mod) => mod.bazarrRouter)),
|
||||
notebook: lazy(() => import("./notebook").then((mod) => mod.notebookRouter)),
|
||||
weather: lazy(() => import("./weather").then((mod) => mod.weatherRouter)),
|
||||
app: lazy(() => import("./app").then((mod) => mod.appRouter)),
|
||||
|
||||
@@ -33,6 +33,7 @@ const integrationAliases: Partial<Record<IntegrationKind, readonly string[]>> =
|
||||
paperlessNgx: ["paperless-ngx", "paperless"],
|
||||
coolify: ["coolify"],
|
||||
truenas: ["truenas"],
|
||||
bazarr: ["bazarr"],
|
||||
synology: ["synology", "diskstation"],
|
||||
};
|
||||
|
||||
|
||||
@@ -284,6 +284,7 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/integrations/lidarr"
|
||||
| "/docs/integrations/readarr"
|
||||
| "/docs/integrations/prowlarr"
|
||||
| "/docs/integrations/bazarr"
|
||||
| "/docs/integrations/jellyfin"
|
||||
| "/docs/integrations/emby"
|
||||
| "/docs/integrations/plex"
|
||||
@@ -359,6 +360,7 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/widgets/immich-server-stats"
|
||||
| "/docs/widgets/immich-album-carousel"
|
||||
| "/docs/widgets/paperless-ngx"
|
||||
| "/docs/widgets/bazarr"
|
||||
| "/docs/widgets/tracearr"
|
||||
| "/docs/widgets/speedtest-tracker"
|
||||
| "/docs/widgets/uptime-kuma"
|
||||
|
||||
@@ -13,6 +13,7 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
|
||||
lidarr: "lidarr",
|
||||
readarr: "readarr",
|
||||
prowlarr: "prowlarr",
|
||||
bazarr: "bazarr",
|
||||
jellyfin: "jellyfin",
|
||||
emby: "emby",
|
||||
plex: "plex",
|
||||
|
||||
@@ -38,6 +38,7 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
|
||||
"immich-serverStats": "immich-server-stats",
|
||||
"immich-albumCarousel": "immich-album-carousel",
|
||||
paperlessNgx: "paperless-ngx",
|
||||
bazarr: "bazarr",
|
||||
tracearr: "tracearr",
|
||||
speedtestTracker: "speedtest-tracker",
|
||||
uptimeKuma: "uptime-kuma",
|
||||
|
||||
@@ -137,6 +137,15 @@ export const integrationDefs = {
|
||||
defaultPort: 9696,
|
||||
apiKeySettingsPath: "/settings/general",
|
||||
},
|
||||
bazarr: {
|
||||
name: "Bazarr",
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/bazarr.svg",
|
||||
category: ["subtitleManager"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/bazarr"),
|
||||
defaultPort: 6767,
|
||||
apiKeySettingsPath: "/settings/general",
|
||||
},
|
||||
jellyfin: {
|
||||
name: "Jellyfin",
|
||||
secretKinds: [["username", "password"], ["apiKey"]],
|
||||
@@ -561,6 +570,7 @@ export const integrationCategories = [
|
||||
"documents",
|
||||
"mediaLibrary",
|
||||
"uptimeMonitoring",
|
||||
"subtitleManager",
|
||||
"reverseProxy",
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
|
||||
"immich-serverStats": ["immich"],
|
||||
"immich-albumCarousel": ["immich"],
|
||||
paperlessNgx: ["paperlessNgx"],
|
||||
bazarr: ["bazarr"],
|
||||
tracearr: ["tracearr"],
|
||||
speedtestTracker: ["speedtestTracker"],
|
||||
uptimeKuma: ["uptimeKuma"],
|
||||
|
||||
@@ -36,6 +36,7 @@ export const widgetKinds = [
|
||||
"immich-serverStats",
|
||||
"immich-albumCarousel",
|
||||
"paperlessNgx",
|
||||
"bazarr",
|
||||
"tracearr",
|
||||
"speedtestTracker",
|
||||
"uptimeKuma",
|
||||
@@ -57,4 +58,5 @@ export const widgetDefaultSizes: Partial<Record<WidgetKind, { width: number; hei
|
||||
uptimeKuma: { width: 2, height: 3 },
|
||||
audioStats: { width: 2, height: 2 },
|
||||
paperlessNgx: { width: 2, height: 2 },
|
||||
bazarr: { width: 2, height: 2 },
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import type { IntegrationKind } from "@homarr/definitions";
|
||||
|
||||
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
|
||||
import { BeszelIntegration } from "../beszel/beszel-integration";
|
||||
import { BazarrIntegration } from "../bazarr/bazarr-integration";
|
||||
import { AnchorIntegration } from "../anchor/anchor-integration";
|
||||
import { AudiobookshelfIntegration } from "../audiobookshelf/audiobookshelf-integration";
|
||||
import { CoolifyIntegration } from "../coolify/coolify-integration";
|
||||
@@ -128,6 +129,7 @@ export const integrationCreators = {
|
||||
uptimeKuma: UptimeKumaIntegration,
|
||||
peaNut: PeaNutIntegration,
|
||||
beszel: BeszelIntegration,
|
||||
bazarr: BazarrIntegration,
|
||||
traefik: TraefikIntegration,
|
||||
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
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 { BazarrBadges } from "./bazarr-types";
|
||||
import { bazarrBadgesSchema, bazarrSystemStatusSchema } from "./bazarr-types";
|
||||
|
||||
export class BazarrIntegration extends Integration {
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const response = await input.fetchAsync(this.url("/api/system/status"), {
|
||||
headers: this.getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return TestConnectionError.StatusResult(response);
|
||||
}
|
||||
|
||||
bazarrSystemStatusSchema.parse(await response.json());
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
public async getBadgesAsync(): Promise<BazarrBadges> {
|
||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/badges"), {
|
||||
headers: this.getAuthHeaders(),
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch Bazarr badges for ${this.integration.name} (${this.integration.id}): ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
return bazarrBadgesSchema.parse(await response.json());
|
||||
}
|
||||
|
||||
private getAuthHeaders(): Record<string, string> {
|
||||
return {
|
||||
"X-API-KEY": this.getSecretValue("apiKey"),
|
||||
Accept: "application/json",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const bazarrBadgesSchema = z.object({
|
||||
episodes: z.number(),
|
||||
movies: z.number(),
|
||||
providers: z.number(),
|
||||
status: z.number(),
|
||||
sonarr_signalr: z.string(),
|
||||
radarr_signalr: z.string(),
|
||||
announcements: z.number(),
|
||||
});
|
||||
|
||||
export type BazarrBadges = z.infer<typeof bazarrBadgesSchema>;
|
||||
|
||||
export const bazarrSystemStatusSchema = z.object({
|
||||
data: z.object({
|
||||
bazarr_version: z.string(),
|
||||
}),
|
||||
});
|
||||
@@ -42,6 +42,7 @@ export { NavidromeIntegration } from "./navidrome/navidrome-integration";
|
||||
export { UptimeKumaIntegration } from "./uptime-kuma/uptime-kuma-integration";
|
||||
export { UmamiIntegration } from "./umami/umami-integration";
|
||||
export { PeaNutIntegration } from "./peanut/peanut-integration";
|
||||
export { BazarrIntegration } from "./bazarr/bazarr-integration";
|
||||
export { TraefikIntegration } from "./traefik/traefik-integration";
|
||||
|
||||
// Types
|
||||
@@ -82,6 +83,7 @@ export type { AudiobookshelfDashboardData } from "./audiobookshelf/audiobookshel
|
||||
export type { NavidromeDashboardData } from "./navidrome/navidrome-types";
|
||||
export type { UptimeKumaDashboardData } from "./uptime-kuma/uptime-kuma-types";
|
||||
export type { UmamiVisitorStats } from "./umami/umami-types";
|
||||
export type { BazarrBadges } from "./bazarr/bazarr-types";
|
||||
export type { TraefikDashboardData, TraefikProtocolSummary, TraefikResourceSummary } from "./traefik/traefik-types";
|
||||
export type {
|
||||
ArchiveTeamWarriorBandwidth,
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { Response } from "undici";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import type { IntegrationSecret } from "../src/base/types";
|
||||
import { BazarrIntegration } from "../src/bazarr/bazarr-integration";
|
||||
|
||||
vi.mock("@homarr/db", async (importActual) => {
|
||||
const actual = await importActual<typeof import("@homarr/db")>();
|
||||
return { ...actual, db: createDb() };
|
||||
});
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/certificates", async (importActual) => {
|
||||
const actual = await importActual<typeof import("@homarr/core/infrastructure/certificates")>();
|
||||
return {
|
||||
...actual,
|
||||
getTrustedCertificateHostnamesAsync: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||
fetchWithTrustedCertificatesAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
const TEST_URL = "https://bazarr.example.com";
|
||||
const API_KEY = "test-api-key";
|
||||
|
||||
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
||||
|
||||
const createIntegration = (decryptedSecrets: IntegrationSecret[] = [{ kind: "apiKey", value: API_KEY }]) =>
|
||||
new BazarrIntegration({
|
||||
id: "test-bazarr",
|
||||
name: "Test Bazarr",
|
||||
url: TEST_URL,
|
||||
externalUrl: null,
|
||||
decryptedSecrets,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
describe("BazarrIntegration getBadgesAsync", () => {
|
||||
test("maps badge counts from the Bazarr API", async () => {
|
||||
mockFetch.mockImplementation(
|
||||
() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
episodes: 12,
|
||||
movies: 4,
|
||||
providers: 1,
|
||||
status: 2,
|
||||
sonarr_signalr: "LIVE",
|
||||
radarr_signalr: "",
|
||||
announcements: 0,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
),
|
||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>,
|
||||
);
|
||||
|
||||
const badges = await createIntegration().getBadgesAsync();
|
||||
|
||||
expect(badges).toStrictEqual({
|
||||
episodes: 12,
|
||||
movies: 4,
|
||||
providers: 1,
|
||||
status: 2,
|
||||
sonarr_signalr: "LIVE",
|
||||
radarr_signalr: "",
|
||||
announcements: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test("sends the API key in the X-API-KEY header", async () => {
|
||||
mockFetch.mockImplementation(
|
||||
() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
episodes: 0,
|
||||
movies: 0,
|
||||
providers: 0,
|
||||
status: 0,
|
||||
sonarr_signalr: "",
|
||||
radarr_signalr: "",
|
||||
announcements: 0,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
),
|
||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>,
|
||||
);
|
||||
|
||||
await createIntegration().getBadgesAsync();
|
||||
|
||||
const [, requestInit] = mockFetch.mock.calls[0] ?? [];
|
||||
expect(requestInit?.headers).toMatchObject({ "X-API-KEY": API_KEY });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { BazarrBadges } from "@homarr/integrations";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
export const bazarrBadgesRequestHandler = createIntegrationRequestHandler<
|
||||
BazarrBadges,
|
||||
"bazarr",
|
||||
Record<string, never>
|
||||
>({
|
||||
async requestAsync(integration) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getBadgesAsync();
|
||||
},
|
||||
});
|
||||
@@ -1335,6 +1335,10 @@
|
||||
"paperlessNgx": {
|
||||
"name": "Paperless-ngx",
|
||||
"description": "Document management system for archiving and organizing scanned documents"
|
||||
},
|
||||
"bazarr": {
|
||||
"name": "Bazarr",
|
||||
"description": "Companion application to Sonarr and Radarr that manages and downloads subtitles"
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
@@ -2758,6 +2762,35 @@
|
||||
"internalServerError": "Failed to fetch Paperless-ngx data"
|
||||
}
|
||||
},
|
||||
"bazarr": {
|
||||
"name": "Bazarr",
|
||||
"description": "Overview of missing subtitles and health status from your Bazarr instance",
|
||||
"episodes": "Missing Episodes",
|
||||
"movies": "Missing Movies",
|
||||
"providers": "Provider Issues",
|
||||
"status": "Health Issues",
|
||||
"option": {
|
||||
"showMissingEpisodes": {
|
||||
"label": "Show missing episodes",
|
||||
"description": "Display the number of episodes with missing subtitles"
|
||||
},
|
||||
"showMissingMovies": {
|
||||
"label": "Show missing movies",
|
||||
"description": "Display the number of movies with missing subtitles"
|
||||
},
|
||||
"showProviderIssues": {
|
||||
"label": "Show provider issues",
|
||||
"description": "Display the number of subtitle providers with issues"
|
||||
},
|
||||
"showHealthIssues": {
|
||||
"label": "Show health issues",
|
||||
"description": "Display the number of Bazarr health issues"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Failed to fetch Bazarr data"
|
||||
}
|
||||
},
|
||||
"immich-serverStats": {
|
||||
"name": "Immich Server Stats",
|
||||
"description": "Information about your Immich instance",
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
.root {
|
||||
container-type: size;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: clamp(6px, 3cqi, 14px);
|
||||
gap: clamp(4px, 2cqi, 10px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
gap: clamp(4px, 2cqi, 10px);
|
||||
grid-template-columns: repeat(var(--stat-cols, 2), minmax(0, 1fr));
|
||||
grid-auto-rows: 1fr;
|
||||
align-content: stretch;
|
||||
}
|
||||
|
||||
.statTile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
gap: clamp(2px, 1.5cqi, 5px);
|
||||
padding: clamp(4px, 2.5cqi, 10px);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.statTileWarning {
|
||||
background-color: light-dark(var(--mantine-color-orange-0), var(--mantine-color-dark-5));
|
||||
}
|
||||
|
||||
.statIcon {
|
||||
color: var(--mantine-color-dimmed);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-size: clamp(8px, 2.5cqi, 12px);
|
||||
color: var(--mantine-color-dimmed);
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.statValue {
|
||||
font-size: clamp(14px, 5.5cqi, 24px);
|
||||
font-weight: 700;
|
||||
color: var(--mantine-primary-color-filled);
|
||||
line-height: 1.1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.statValueWarning {
|
||||
color: var(--mantine-color-orange-6);
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 0;
|
||||
color: var(--mantine-color-dimmed);
|
||||
font-size: clamp(11px, 3cqi, 14px);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import { Text } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconDeviceTv, IconMovie, IconPlugConnected } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import classes from "./component.module.css";
|
||||
|
||||
const statVisibilityByOption = {
|
||||
showMissingEpisodes: "episodes",
|
||||
showMissingMovies: "movies",
|
||||
showProviderIssues: "providers",
|
||||
showHealthIssues: "status",
|
||||
} as const;
|
||||
|
||||
const statIcons = {
|
||||
episodes: IconDeviceTv,
|
||||
movies: IconMovie,
|
||||
providers: IconPlugConnected,
|
||||
status: IconAlertTriangle,
|
||||
} as const;
|
||||
|
||||
const gridColsByWidth = [
|
||||
{ minWidth: 380, cols: 2 },
|
||||
{ minWidth: 0, cols: 1 },
|
||||
] as const;
|
||||
|
||||
const iconSizeByWidth = [
|
||||
{ minWidth: 320, size: 22 },
|
||||
{ minWidth: 200, size: 18 },
|
||||
{ minWidth: 0, size: 16 },
|
||||
] as const;
|
||||
|
||||
export default function BazarrWidget({ integrationIds, options, width }: WidgetComponentProps<"bazarr">) {
|
||||
const t = useScopedI18n("widget.bazarr");
|
||||
const { data: badges } = clientApi.widget.bazarr.getBadges.useQuery(
|
||||
{ integrationId: integrationIds[0] ?? "" },
|
||||
{ enabled: Boolean(integrationIds[0]) },
|
||||
);
|
||||
|
||||
if (!badges) return <WidgetEmptyState />;
|
||||
|
||||
const statValues = {
|
||||
episodes: badges.episodes,
|
||||
movies: badges.movies,
|
||||
providers: badges.providers,
|
||||
status: badges.status,
|
||||
} as const;
|
||||
|
||||
const visibleStatKeys = Object.entries(statVisibilityByOption)
|
||||
.filter(([optionKey]) => options[optionKey as keyof typeof options])
|
||||
.map(([, statKey]) => statKey);
|
||||
|
||||
const gridCols = getGridCols(width);
|
||||
const iconSize = getIconSize(width);
|
||||
|
||||
if (visibleStatKeys.length === 0) {
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<div className={classes.emptyState}>
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<div className={classes.grid} style={{ "--stat-cols": gridCols } as CSSProperties}>
|
||||
{visibleStatKeys.map((statKey) => {
|
||||
const Icon = statIcons[statKey];
|
||||
const value = statValues[statKey];
|
||||
const isWarning = (statKey === "providers" || statKey === "status") && value > 0;
|
||||
|
||||
return (
|
||||
<div key={statKey} className={`${classes.statTile} ${isWarning ? classes.statTileWarning : ""}`}>
|
||||
<Icon className={classes.statIcon} size={iconSize} stroke={1.5} />
|
||||
<span className={`${classes.statValue} ${isWarning ? classes.statValueWarning : ""}`}>{value}</span>
|
||||
<span className={classes.statLabel}>{t(statKey)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getGridCols(width: number): number {
|
||||
const match = gridColsByWidth.find(({ minWidth }) => width >= minWidth);
|
||||
return match?.cols ?? 1;
|
||||
}
|
||||
|
||||
function getIconSize(width: number): number {
|
||||
const match = iconSizeByWidth.find(({ minWidth }) => width >= minWidth);
|
||||
return match?.size ?? 16;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { IconBadgeCc, IconServerOff } from "@tabler/icons-react";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("bazarr", {
|
||||
icon: IconBadgeCc,
|
||||
supportedIntegrations: ["bazarr"],
|
||||
integrationsRequired: true,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
showMissingEpisodes: factory.switch({
|
||||
defaultValue: true,
|
||||
withDescription: true,
|
||||
}),
|
||||
showMissingMovies: factory.switch({
|
||||
defaultValue: true,
|
||||
withDescription: true,
|
||||
}),
|
||||
showProviderIssues: factory.switch({
|
||||
defaultValue: true,
|
||||
withDescription: true,
|
||||
}),
|
||||
showHealthIssues: factory.switch({
|
||||
defaultValue: true,
|
||||
withDescription: true,
|
||||
}),
|
||||
}));
|
||||
},
|
||||
errors: {
|
||||
INTERNAL_SERVER_ERROR: {
|
||||
icon: IconServerOff,
|
||||
message: (t) => t("widget.bazarr.error.internalServerError"),
|
||||
},
|
||||
},
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
@@ -12,6 +12,7 @@ import * as audioStats from "./audio-stats";
|
||||
import * as app from "./app";
|
||||
import * as archiveTeamWarrior from "./archive-team-warrior";
|
||||
import * as bookmarks from "./bookmarks";
|
||||
import * as bazarr from "./bazarr";
|
||||
import * as calendar from "./calendar";
|
||||
import * as clock from "./clock";
|
||||
import * as coolify from "./coolify";
|
||||
@@ -94,6 +95,7 @@ export const widgetImports = {
|
||||
networkControllerStatus,
|
||||
rssFeed,
|
||||
bookmarks,
|
||||
bazarr,
|
||||
indexerManager,
|
||||
healthMonitoring,
|
||||
mediaTranscoding,
|
||||
|
||||
Reference in new issue
Block a user