feat: add Bazarr integration and subtitle status widget (#6156)

Co-authored-by: Thomas <thomas@ajnart.fr>
This commit is contained in:
Felix-AyushandThomas authored and GitHub committed 2026-07-10 17:29:10 +02:00
1 parent 7d93df04dd
commit 2c5368dd4e
28 files changed
+635

No files matched your search

+8
View File
@@ -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

+41
View File
@@ -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} />
+37
View File
@@ -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",
},
],
},
};
+2
View File
@@ -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,
+21
View File
@@ -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;
}),
});
+1
View File
@@ -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",
+10
View File
@@ -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"],
+2
View File
@@ -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(),
}),
});
+2
View File
@@ -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 });
});
});
+15
View File
@@ -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();
},
});
+33
View File
@@ -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;
}
+103
View File
@@ -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;
}
+36
View File
@@ -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"));
+2
View File
@@ -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,