diff --git a/CODEOWNERS b/CODEOWNERS index addd37e8c..ddaa51316 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/apps/docs/docs/integrations/bazarr/img/api-key-settings.png b/apps/docs/docs/integrations/bazarr/img/api-key-settings.png new file mode 100644 index 000000000..c3f203b58 Binary files /dev/null and b/apps/docs/docs/integrations/bazarr/img/api-key-settings.png differ diff --git a/apps/docs/docs/integrations/bazarr/index.mdx b/apps/docs/docs/integrations/bazarr/index.mdx new file mode 100644 index 000000000..f9400a45e --- /dev/null +++ b/apps/docs/docs/integrations/bazarr/index.mdx @@ -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'; + + + +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'; + +
+
+

API key location in Bazarr (Settings → General → Security):

+ bazarr-api-key-settings +
+
+

Bazarr widget on a Homarr board:

+ bazarr-widget-on-board +
+
+ +### Widgets & Capabilities + + +### Adding the integration + + +### Secrets +You can find your Bazarr API key under **Settings → General → Security**. + + diff --git a/apps/docs/docs/integrations/bazarr/index.ts b/apps/docs/docs/integrations/bazarr/index.ts new file mode 100644 index 000000000..528f7ae77 --- /dev/null +++ b/apps/docs/docs/integrations/bazarr/index.ts @@ -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", +}; diff --git a/apps/docs/docs/widgets/bazarr/img/widget-overview.png b/apps/docs/docs/widgets/bazarr/img/widget-overview.png new file mode 100644 index 000000000..d5af06b69 Binary files /dev/null and b/apps/docs/docs/widgets/bazarr/img/widget-overview.png differ diff --git a/apps/docs/docs/widgets/bazarr/index.mdx b/apps/docs/docs/widgets/bazarr/index.mdx new file mode 100644 index 000000000..e262cfa75 --- /dev/null +++ b/apps/docs/docs/widgets/bazarr/index.mdx @@ -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'; + + + +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'; + +
+ bazarr-widget-overview +
+ +### Supported Integrations + + + +### Adding the widget + + + +### Configuration + diff --git a/apps/docs/docs/widgets/bazarr/index.ts b/apps/docs/docs/widgets/bazarr/index.ts new file mode 100644 index 000000000..8b5c27ef8 --- /dev/null +++ b/apps/docs/docs/widgets/bazarr/index.ts @@ -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", + }, + ], + }, +}; diff --git a/packages/api/src/mcp.ts b/packages/api/src/mcp.ts index faf7a62be..266db0622 100644 --- a/packages/api/src/mcp.ts +++ b/packages/api/src/mcp.ts @@ -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, diff --git a/packages/api/src/router/widgets/bazarr.ts b/packages/api/src/router/widgets/bazarr.ts new file mode 100644 index 000000000..c47ad4173 --- /dev/null +++ b/packages/api/src/router/widgets/bazarr.ts @@ -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; + }), +}); diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts index 88aa78d8e..27907e924 100644 --- a/packages/api/src/router/widgets/index.ts +++ b/packages/api/src/router/widgets/index.ts @@ -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)), diff --git a/packages/definitions/src/docker-integration-match.ts b/packages/definitions/src/docker-integration-match.ts index eb6476bc8..7b0ea4c10 100644 --- a/packages/definitions/src/docker-integration-match.ts +++ b/packages/definitions/src/docker-integration-match.ts @@ -33,6 +33,7 @@ const integrationAliases: Partial> = paperlessNgx: ["paperless-ngx", "paperless"], coolify: ["coolify"], truenas: ["truenas"], + bazarr: ["bazarr"], synology: ["synology", "diskstation"], }; diff --git a/packages/definitions/src/docs/homarr-docs-sitemap.ts b/packages/definitions/src/docs/homarr-docs-sitemap.ts index 2e87554c7..b83fff39b 100644 --- a/packages/definitions/src/docs/homarr-docs-sitemap.ts +++ b/packages/definitions/src/docs/homarr-docs-sitemap.ts @@ -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" diff --git a/packages/definitions/src/docs/integration-doc-slugs.ts b/packages/definitions/src/docs/integration-doc-slugs.ts index 7d7b19e3b..9fd20050a 100644 --- a/packages/definitions/src/docs/integration-doc-slugs.ts +++ b/packages/definitions/src/docs/integration-doc-slugs.ts @@ -13,6 +13,7 @@ export const integrationDocSlugs: Record = { lidarr: "lidarr", readarr: "readarr", prowlarr: "prowlarr", + bazarr: "bazarr", jellyfin: "jellyfin", emby: "emby", plex: "plex", diff --git a/packages/definitions/src/docs/widget-doc-slugs.ts b/packages/definitions/src/docs/widget-doc-slugs.ts index 7863aadeb..635badb49 100644 --- a/packages/definitions/src/docs/widget-doc-slugs.ts +++ b/packages/definitions/src/docs/widget-doc-slugs.ts @@ -38,6 +38,7 @@ export const widgetDocSlugs: Record = { "immich-serverStats": "immich-server-stats", "immich-albumCarousel": "immich-album-carousel", paperlessNgx: "paperless-ngx", + bazarr: "bazarr", tracearr: "tracearr", speedtestTracker: "speedtest-tracker", uptimeKuma: "uptime-kuma", diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index 0d548f81a..c4d216024 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -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; diff --git a/packages/definitions/src/widget-integration-map.ts b/packages/definitions/src/widget-integration-map.ts index 781c37ecd..8f59060e3 100644 --- a/packages/definitions/src/widget-integration-map.ts +++ b/packages/definitions/src/widget-integration-map.ts @@ -33,6 +33,7 @@ export const widgetIntegrationSupport: Partial Promise]>; diff --git a/packages/integrations/src/bazarr/bazarr-integration.ts b/packages/integrations/src/bazarr/bazarr-integration.ts new file mode 100644 index 000000000..8f224d6ea --- /dev/null +++ b/packages/integrations/src/bazarr/bazarr-integration.ts @@ -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 { + 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 { + 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 { + return { + "X-API-KEY": this.getSecretValue("apiKey"), + Accept: "application/json", + }; + } +} diff --git a/packages/integrations/src/bazarr/bazarr-types.ts b/packages/integrations/src/bazarr/bazarr-types.ts new file mode 100644 index 000000000..fe16dedba --- /dev/null +++ b/packages/integrations/src/bazarr/bazarr-types.ts @@ -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; + +export const bazarrSystemStatusSchema = z.object({ + data: z.object({ + bazarr_version: z.string(), + }), +}); diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index fe79f2159..c07f875eb 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -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, diff --git a/packages/integrations/test/bazarr-unit.spec.ts b/packages/integrations/test/bazarr-unit.spec.ts new file mode 100644 index 000000000..eed0f9082 --- /dev/null +++ b/packages/integrations/test/bazarr-unit.spec.ts @@ -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(); + return { ...actual, db: createDb() }; +}); + +vi.mock("@homarr/core/infrastructure/certificates", async (importActual) => { + const actual = await importActual(); + 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, + ); + + 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, + ); + + await createIntegration().getBadgesAsync(); + + const [, requestInit] = mockFetch.mock.calls[0] ?? []; + expect(requestInit?.headers).toMatchObject({ "X-API-KEY": API_KEY }); + }); +}); diff --git a/packages/request-handler/src/bazarr.ts b/packages/request-handler/src/bazarr.ts new file mode 100644 index 000000000..56b391658 --- /dev/null +++ b/packages/request-handler/src/bazarr.ts @@ -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 +>({ + async requestAsync(integration) { + const integrationInstance = await createIntegrationAsync(integration); + return await integrationInstance.getBadgesAsync(); + }, +}); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 50c516092..6b09833d4 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -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", diff --git a/packages/widgets/src/bazarr/component.module.css b/packages/widgets/src/bazarr/component.module.css new file mode 100644 index 000000000..9b5857379 --- /dev/null +++ b/packages/widgets/src/bazarr/component.module.css @@ -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; +} diff --git a/packages/widgets/src/bazarr/component.tsx b/packages/widgets/src/bazarr/component.tsx new file mode 100644 index 000000000..094c32ee1 --- /dev/null +++ b/packages/widgets/src/bazarr/component.tsx @@ -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 ; + + 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 ( +
+
+ + — + +
+
+ ); + } + + return ( +
+
+ {visibleStatKeys.map((statKey) => { + const Icon = statIcons[statKey]; + const value = statValues[statKey]; + const isWarning = (statKey === "providers" || statKey === "status") && value > 0; + + return ( +
+ + {value} + {t(statKey)} +
+ ); + })} +
+
+ ); +} + +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; +} diff --git a/packages/widgets/src/bazarr/index.ts b/packages/widgets/src/bazarr/index.ts new file mode 100644 index 000000000..f2e79f570 --- /dev/null +++ b/packages/widgets/src/bazarr/index.ts @@ -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")); diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index 2b843425d..ca20ff3a1 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -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,