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
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user