From 6b2acb6ed9f1a4de4e43940cb0a49cf81cbfac56 Mon Sep 17 00:00:00 2001 From: Laszlo Toth Date: Mon, 6 Jul 2026 17:00:41 +0200 Subject: [PATCH 1/7] feat(bazarr): drop the standalone opensubtitles-scraper service --- src/catalog/services.yaml | 22 +--------------------- src/ui/wizard/useWizardState.ts | 6 +++--- tests/renderer/compose.test.ts | 9 +++++++++ tests/ui/wizard/useWizardState.test.ts | 8 ++++++++ 4 files changed, 21 insertions(+), 24 deletions(-) diff --git a/src/catalog/services.yaml b/src/catalog/services.yaml index d93f429..e8140ee 100644 --- a/src/catalog/services.yaml +++ b/src/catalog/services.yaml @@ -113,12 +113,11 @@ services: mounts: data: /data envVars: - OPENSUBTITLES_SCRAPER_URL: "http://opensubtitles-scraper:8000" # Read from .env at compose time. Shared with ai-subtitle-translator # so API keys can be AES-GCM-encrypted in transit between them. ENCRYPTION_KEY: "${ENCRYPTION_KEY}" OPENROUTER_API_KEY: "${OPENROUTER_API_KEY}" - dependsOn: [sonarr, radarr, flaresolverr, opensubtitles-scraper] + dependsOn: [sonarr, radarr, flaresolverr] health: # /api/system/ping is the documented unauthenticated readiness probe # (bazarr/api/system/ping.py). Returns 200 {"status":"OK"} once the @@ -130,25 +129,6 @@ services: default: true requiresAdminAuth: false - - id: opensubtitles-scraper - name: OpenSubtitles Scraper - description: Scrapes subtitles from OpenSubtitles on a schedule for Bazarr+ - category: subtitle - image: ghcr.io/lavx/opensubtitles-scraper - tag: "latest" - ports: [8000] - configPath: /config - mounts: {} - envVars: - FLARESOLVERR_URL: "http://flaresolverr:8191/v1" - dependsOn: [flaresolverr] - health: - type: http - path: /health - port: 8000 - default: true - requiresAdminAuth: false - - id: ai-subtitle-translator name: AI Subtitle Translator description: Translates subtitles using AI models via OpenRouter diff --git a/src/ui/wizard/useWizardState.ts b/src/ui/wizard/useWizardState.ts index 38c3c4a..016875a 100644 --- a/src/ui/wizard/useWizardState.ts +++ b/src/ui/wizard/useWizardState.ts @@ -105,7 +105,7 @@ export function buildStateFromWizard( // Bazarr+ bundle: when bazarr is checked, auto-add its dependencies if (enabledIds.includes("bazarr")) { - for (const dep of ["flaresolverr", "opensubtitles-scraper", "ai-subtitle-translator"]) { + for (const dep of ["flaresolverr", "ai-subtitle-translator"]) { if (!enabledIds.includes(dep)) enabledIds.push(dep); } } @@ -225,10 +225,10 @@ function readExistingAdminPassword(installDir: string): string | null { // Services managed automatically by the installer, hidden from the user grid // Infrastructure: caddy, ddns containers, dnsmasq -// Bazarr+ deps: flaresolverr, opensubtitles-scraper, ai-subtitle-translator (bundled with Bazarr+) +// Bazarr+ deps: flaresolverr, ai-subtitle-translator (bundled with Bazarr+) const AUTO_MANAGED_SERVICES = new Set([ "caddy", "cloudflare-ddns", "duckdns-updater", "dnsmasq", "deunhealth", - "flaresolverr", "opensubtitles-scraper", "ai-subtitle-translator", + "flaresolverr", "ai-subtitle-translator", ]); function buildInitialServices(existingEnabled?: string[]): WizardServiceItem[] { diff --git a/tests/renderer/compose.test.ts b/tests/renderer/compose.test.ts index 8b1a4de..885db14 100644 --- a/tests/renderer/compose.test.ts +++ b/tests/renderer/compose.test.ts @@ -39,6 +39,15 @@ describe("renderCompose", () => { expect(output).toContain("max-file"); }); + test("opensubtitles-scraper is gone; bazarr no longer depends on it", () => { + const output = renderCompose(getServices(["bazarr", "flaresolverr"]), baseOpts); + expect(output).not.toContain("opensubtitles-scraper"); + expect(output).not.toContain("OPENSUBTITLES_SCRAPER_URL"); + const ctx = buildComposeContext(getServices(["bazarr"]), baseOpts); + const bazarr = ctx.services.find((s) => s.id === "bazarr")!; + expect(bazarr.dependsOn.map((d) => d.service)).not.toContain("opensubtitles-scraper"); + }); + test("arrstack network is defined", () => { const services = getServices(["sonarr"]); const output = renderCompose(services, baseOpts); diff --git a/tests/ui/wizard/useWizardState.test.ts b/tests/ui/wizard/useWizardState.test.ts index dafd812..99ecba3 100644 --- a/tests/ui/wizard/useWizardState.test.ts +++ b/tests/ui/wizard/useWizardState.test.ts @@ -383,6 +383,14 @@ describe("buildStateFromWizard", () => { expect(state.services_enabled).not.toContain("deunhealth"); }); + test("enabling bazarr no longer auto-adds opensubtitles-scraper", () => { + const state = buildStateFromWizard( + makeWizardState({ services: [{ id: "bazarr", name: "Bazarr+", checked: true }] }) + ); + expect(state.services_enabled).not.toContain("opensubtitles-scraper"); + expect(state.services_enabled).toContain("flaresolverr"); // still bundled + }); + test("installer_version is a non-empty string", () => { const state = buildStateFromWizard(makeWizardState()); expect(typeof state.installer_version).toBe("string"); From 55fb1cd3c82c7b893f25cbd30feca290af78edd9 Mon Sep 17 00:00:00 2001 From: Laszlo Toth Date: Mon, 6 Jul 2026 17:02:44 +0200 Subject: [PATCH 2/7] feat(bazarr): config drops podnapisi + the opensubtitles scraper block --- templates/bazarr-config.yaml.hbs | 6 ------ tests/renderer/configs.test.ts | 12 ++++++++++-- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/templates/bazarr-config.yaml.hbs b/templates/bazarr-config.yaml.hbs index 6e1a855..4afd224 100644 --- a/templates/bazarr-config.yaml.hbs +++ b/templates/bazarr-config.yaml.hbs @@ -23,7 +23,6 @@ general: - opensubtitlescom - opensubtitles - embeddedsubtitles - - podnapisi - addic7ed - yifysubtitles flask_secret_key: {{flaskSecretKey}} @@ -54,11 +53,6 @@ general: utf8_encode: true wanted_search_frequency: 6 wanted_search_frequency_movie: 6 -opensubtitles: - scraper_service_url: opensubtitles-scraper:8000 - use_web_scraper: true - ssl: false - timeout: 15 sonarr: apikey: '{{sonarrApiKey}}' base_url: '/' diff --git a/tests/renderer/configs.test.ts b/tests/renderer/configs.test.ts index 8d38c56..c9df811 100644 --- a/tests/renderer/configs.test.ts +++ b/tests/renderer/configs.test.ts @@ -60,11 +60,19 @@ describe("bazarr config.yaml", () => { expect(output).toContain("port: 7878"); }); - test("enabled subtitle providers are listed", () => { + test("enabled subtitle providers are the migrated set (no podnapisi)", () => { const output = renderBazarrConfig(opts); expect(output).toContain("opensubtitles"); - expect(output).toContain("podnapisi"); expect(output).toContain("embeddedsubtitles"); + expect(output).toContain("yifysubtitles"); + // podnapisi is dead and not in the Provider Hub catalog; dropped. + expect(output).not.toContain("podnapisi"); + }); + + test("config drops the standalone opensubtitles scraper block", () => { + const output = renderBazarrConfig(opts); + expect(output).not.toContain("scraper_service_url"); + expect(output).not.toContain("use_web_scraper"); }); test("AI translator encryption key is written so it matches the .env", () => { From 761b10c115e1e81678a4b7bc43afbc096cdab0a1 Mon Sep 17 00:00:00 2001 From: Laszlo Toth Date: Mon, 6 Jul 2026 17:04:39 +0200 Subject: [PATCH 3/7] feat(bazarr): Provider Hub wiring to install + enable the default providers --- src/wiring/bazarr-providerhub.ts | 120 ++++++++++++++++++++++++ tests/wiring/bazarr-providerhub.test.ts | 119 +++++++++++++++++++++++ 2 files changed, 239 insertions(+) create mode 100644 src/wiring/bazarr-providerhub.ts create mode 100644 tests/wiring/bazarr-providerhub.test.ts diff --git a/src/wiring/bazarr-providerhub.ts b/src/wiring/bazarr-providerhub.ts new file mode 100644 index 0000000..c74df0a --- /dev/null +++ b/src/wiring/bazarr-providerhub.ts @@ -0,0 +1,120 @@ +import { withRetry } from "../lib/retry.js"; + +// Bazarr+ 2.4.0 loads subtitle providers from the Provider Hub (the built-in +// providers are being retired). This wires the installer's default set via the +// Hub REST API: GET /catalog for each provider's manifest, POST /installations +// to stage the install (async: downloads a bundle + builds an isolated venv), +// poll until it appears, then PATCH /providers/ to enable + configure it. +// Idempotent (skips already-installed) and best-effort (a provider that fails +// warns and never aborts the install/update). + +export interface ProviderHubTarget { + id: string; + config?: Record; +} + +// The migrated default set. podnapisi is intentionally absent (dead, not in the +// catalog). opensubtitles carries the FlareSolverr fallback endpoint; the two +// credential providers (opensubtitlescom, addic7ed) are enabled but left +// unconfigured for the user to fill in via Settings -> Provider Hub. +export function providerHubTargets(flaresolverrUrl: string): ProviderHubTarget[] { + return [ + { id: "opensubtitles", config: { flaresolverr_url: flaresolverrUrl } }, + { id: "embeddedsubtitles" }, + { id: "yifysubtitles" }, + { id: "opensubtitlescom" }, + { id: "addic7ed" }, + ]; +} + +export interface ProviderHubOptions { + apiKey: string; + flaresolverrUrl: string; + base?: string; + pollTimeoutMs?: number; + pollIntervalMs?: number; + log?: (msg: string) => void; +} + +interface CatalogEntry { + provider_id?: string; + manifest?: unknown; +} + +export async function configureBazarrProviderHub(opts: ProviderHubOptions): Promise { + const base = opts.base ?? "http://localhost:6767"; + const log = opts.log ?? (() => {}); + const headers = { "X-API-KEY": opts.apiKey, "Content-Type": "application/json" }; + const timeoutMs = opts.pollTimeoutMs ?? 120_000; + const intervalMs = opts.pollIntervalMs ?? 3000; + const targets = providerHubTargets(opts.flaresolverrUrl); + + // withRetry doubles as the readiness gate: the first catalog GET retries while + // Bazarr+ finishes booting (connection refused / 500 during DB migrations). + const catRes = await withRetry(() => + fetch(`${base}/api/provider-hub/catalog`, { headers }), + ); + const catalog = (await catRes.json()) as { entries?: CatalogEntry[] }; + const manifestById = new Map(); + for (const e of catalog.entries ?? []) { + if (e.provider_id) manifestById.set(e.provider_id, e.manifest); + } + + for (const target of targets) { + try { + if (!(await installedIds(base, headers)).has(target.id)) { + const manifest = manifestById.get(target.id); + if (!manifest) { + log(`Provider Hub: ${target.id} is not in the catalog; skipping`); + continue; + } + const res = await fetch(`${base}/api/provider-hub/installations`, { + method: "POST", + headers, + body: JSON.stringify({ manifest }), + }); + if (!res.ok) throw new Error(`install returned ${res.status}`); + await waitForInstalled(base, headers, target.id, timeoutMs, intervalMs); + } + const patch = await fetch(`${base}/api/provider-hub/providers/${target.id}`, { + method: "PATCH", + headers, + body: JSON.stringify({ enabled: true, ...(target.config ? { config: target.config } : {}) }), + }); + if (!patch.ok) throw new Error(`enable returned ${patch.status}`); + log(`Provider Hub: ${target.id} installed + enabled`); + } catch (err) { + log( + `Provider Hub: could not set up ${target.id} (${(err as Error).message}); ` + + `install it later from Bazarr+ Settings, Provider Hub`, + ); + } + } +} + +async function installedIds( + base: string, + headers: Record, +): Promise> { + const r = await fetch(`${base}/api/provider-hub/providers`, { headers }); + if (!r.ok) throw new Error(`list providers returned ${r.status}`); + const j = (await r.json()) as { data?: Array<{ provider_id?: string; id?: string }> }; + return new Set( + (j.data ?? []).map((p) => p.provider_id ?? p.id).filter((x): x is string => !!x), + ); +} + +async function waitForInstalled( + base: string, + headers: Record, + id: string, + timeoutMs: number, + intervalMs: number, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if ((await installedIds(base, headers)).has(id)) return; + await new Promise((r) => setTimeout(r, intervalMs)); + } + throw new Error(`install did not finish within ${Math.round(timeoutMs / 1000)}s`); +} diff --git a/tests/wiring/bazarr-providerhub.test.ts b/tests/wiring/bazarr-providerhub.test.ts new file mode 100644 index 0000000..cf262c1 --- /dev/null +++ b/tests/wiring/bazarr-providerhub.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, test, afterEach } from "bun:test"; +import { + configureBazarrProviderHub, + providerHubTargets, +} from "../../src/wiring/bazarr-providerhub"; + +const FLARE = "http://flaresolverr:8191/v1"; +const IDS = ["opensubtitles", "embeddedsubtitles", "yifysubtitles", "opensubtitlescom", "addic7ed"]; + +function fakeCatalog() { + return { + sources: {}, + entries: IDS.map((id) => ({ provider_id: id, version: "1.0.0", manifest: { provider_id: id } })), + }; +} + +// A stateful fake of the Bazarr+ Provider Hub API: POST /installations adds the +// provider to the installed set, so the subsequent GET /providers (which +// waitForInstalled polls) sees it. Without this the install would "never finish" +// and every provider would time out. +function makeStatefulFetch(opts: { preinstalled?: string[]; installThrows?: boolean } = {}) { + const installed = new Set(opts.preinstalled ?? []); + const calls: Array<{ method: string; url: string; body?: any }> = []; + const fetchFn = async (url: any, init: any = {}) => { + const u = String(url); + const method = init.method ?? "GET"; + const body = init.body ? JSON.parse(init.body) : undefined; + calls.push({ method, url: u, body }); + if (u.endsWith("/provider-hub/catalog")) { + return new Response(JSON.stringify(fakeCatalog()), { status: 200 }); + } + if (u.endsWith("/provider-hub/providers")) { + return new Response( + JSON.stringify({ data: [...installed].map((id) => ({ provider_id: id })) }), + { status: 200 }, + ); + } + if (u.endsWith("/provider-hub/installations")) { + if (opts.installThrows) throw new Error("network boom"); + installed.add(body.manifest.provider_id); + return new Response("{}", { status: 200 }); + } + // PATCH /provider-hub/providers/ + return new Response("{}", { status: 200 }); + }; + return { fetchFn, calls }; +} + +describe("providerHubTargets", () => { + test("is the migrated set; podnapisi absent; opensubtitles carries flaresolverr_url", () => { + const t = providerHubTargets(FLARE); + expect(t.map((x) => x.id)).toEqual(IDS); + expect(t.map((x) => x.id)).not.toContain("podnapisi"); + expect(t.find((x) => x.id === "opensubtitles")!.config).toEqual({ flaresolverr_url: FLARE }); + }); +}); + +describe("configureBazarrProviderHub", () => { + const orig = globalThis.fetch; + afterEach(() => { + globalThis.fetch = orig; + }); + + test("installs each not-yet-installed provider then enables it", async () => { + const { fetchFn, calls } = makeStatefulFetch(); + globalThis.fetch = fetchFn as any; + + await configureBazarrProviderHub({ + apiKey: "K", + flaresolverrUrl: FLARE, + base: "http://bz.test", + pollTimeoutMs: 500, + pollIntervalMs: 5, + }); + + expect(calls.filter((c) => c.method === "POST" && c.url.endsWith("/installations"))).toHaveLength(5); + const osPatch = calls.find((c) => c.method === "PATCH" && c.url.endsWith("/providers/opensubtitles")); + expect(osPatch?.body).toEqual({ enabled: true, config: { flaresolverr_url: FLARE } }); + const emb = calls.find((c) => c.method === "PATCH" && c.url.endsWith("/providers/embeddedsubtitles")); + expect(emb?.body).toEqual({ enabled: true }); + expect(calls.filter((c) => c.method === "PATCH")).toHaveLength(5); + }); + + test("skips install for an already-installed provider (idempotent), still enables it", async () => { + const { fetchFn, calls } = makeStatefulFetch({ preinstalled: ["opensubtitles"] }); + globalThis.fetch = fetchFn as any; + + await configureBazarrProviderHub({ + apiKey: "K", + flaresolverrUrl: FLARE, + base: "http://bz.test", + pollTimeoutMs: 500, + pollIntervalMs: 5, + }); + + // Only the other 4 get an install POST. + expect(calls.filter((c) => c.method === "POST" && c.url.endsWith("/installations"))).toHaveLength(4); + expect(calls.some((c) => c.method === "PATCH" && c.url.endsWith("/providers/opensubtitles"))).toBe(true); + }); + + test("a per-provider failure warns and does not throw", async () => { + const warnings: string[] = []; + const { fetchFn } = makeStatefulFetch({ installThrows: true }); + globalThis.fetch = fetchFn as any; + + await expect( + configureBazarrProviderHub({ + apiKey: "K", + flaresolverrUrl: FLARE, + base: "http://bz.test", + pollTimeoutMs: 30, + pollIntervalMs: 5, + log: (m) => warnings.push(m), + }), + ).resolves.toBeUndefined(); + expect(warnings.some((w) => w.toLowerCase().includes("opensubtitles"))).toBe(true); + expect(warnings.length).toBeGreaterThanOrEqual(5); // one warning per provider + }); +}); From 60d916ca7735c9380737f4539b844748ce5035d7 Mon Sep 17 00:00:00 2001 From: Laszlo Toth Date: Mon, 6 Jul 2026 17:07:51 +0200 Subject: [PATCH 4/7] feat(bazarr): run Provider Hub wiring on install and update --- src/usecase/install.ts | 18 ++++++++++++++++++ src/usecase/update.ts | 17 +++++++++++++++++ tests/usecase/update.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 57 insertions(+) diff --git a/src/usecase/install.ts b/src/usecase/install.ts index 6945c46..87bff21 100644 --- a/src/usecase/install.ts +++ b/src/usecase/install.ts @@ -38,6 +38,7 @@ import { isNordVpnToken, resolveVpnWireguardKey } from "../wiring/nordvpn.js"; import { setupJellyfin } from "../wiring/jellyfin.js"; import { linkJellyseerr } from "../wiring/jellyseerr.js"; import { configureBazarrLanguages } from "../wiring/bazarr.js"; +import { configureBazarrProviderHub } from "../wiring/bazarr-providerhub.js"; import { runRecyclarrSync } from "../wiring/recyclarr.js"; export type StepStatus = "pending" | "running" | "done" | "failed"; @@ -539,6 +540,23 @@ export async function runInstall( }); } + // Step 12b2b: Install + enable the default subtitle providers via the Bazarr+ + // Provider Hub (the built-in providers are being retired). Best-effort: a + // provider that fails to install just logs a warning and never aborts install. + if (has("bazarr")) { + await runStep("Installing Bazarr subtitle providers (Provider Hub)", onStep, log, async () => { + try { + await configureBazarrProviderHub({ + apiKey: apiKeys["bazarr"] ?? "", + flaresolverrUrl: "http://flaresolverr:8191/v1", + log: (m) => log.info("provider-hub", m), + }); + } catch (err) { + log.warn("provider-hub", `skipped: ${(err as Error).message}`); + } + }); + } + // Step 12c: qBittorrent categories + settings if (has("qbittorrent")) { await runStep("Configuring qBittorrent categories and settings", onStep, log, async () => { diff --git a/src/usecase/update.ts b/src/usecase/update.ts index c388286..d395e5e 100644 --- a/src/usecase/update.ts +++ b/src/usecase/update.ts @@ -8,6 +8,7 @@ import type { State } from "../state/schema.js"; import { renderCaddyfile } from "../renderer/caddy.js"; import { renderCompose } from "../renderer/compose.js"; import { resolveVpnWireguardKey } from "../wiring/nordvpn.js"; +import { configureBazarrProviderHub } from "../wiring/bazarr-providerhub.js"; export interface UpdateDeps { runStreaming: (argv: string[], onLine: (line: string) => void) => Promise<{ ok: boolean; code: number | null }>; @@ -125,6 +126,22 @@ export async function runUpdate(installDir: string, deps?: Partial): await runHealthChecks(services, d.checkHealth, logAndEcho); phaseTimes["health"] = d.now() - healthStart; + // Provider Hub: keep the default subtitle providers installed + enabled on + // existing installs too (the built-in providers are being retired). + // Best-effort: a failure here logs a warning and never fails the update. + if (state.services_enabled.includes("bazarr")) { + logAndEcho("[update] Provider Hub: ensuring default subtitle providers"); + try { + await configureBazarrProviderHub({ + apiKey: state.api_keys["bazarr"] ?? "", + flaresolverrUrl: "http://flaresolverr:8191/v1", + log: (m) => logAndEcho(`[update] ${m}`), + }); + } catch (err) { + logAndEcho(`[update] Provider Hub wiring skipped: ${(err as Error).message}`); + } + } + const captureAfterStart = d.now(); const after = await d.captureImages(composeFile); phaseTimes["capture-after"] = d.now() - captureAfterStart; diff --git a/tests/usecase/update.test.ts b/tests/usecase/update.test.ts index ac3bcce..c9f1df9 100644 --- a/tests/usecase/update.test.ts +++ b/tests/usecase/update.test.ts @@ -190,6 +190,28 @@ test("runUpdate does not re-add deunhealth when it's already in services_enabled expect(logTxt).not.toContain("adding deunhealth sidecar"); }); +test("runUpdate attempts the Bazarr Provider Hub wiring when bazarr is enabled", async () => { + const dir = makeInstallDir(true, false); + writeState(dir, { ...VALID_STATE, install_dir: dir, services_enabled: ["sonarr", "bazarr", "flaresolverr"] }); + const origFetch = globalThis.fetch; + // Fake a reachable-but-empty Provider Hub so the wiring runs fast instead of + // backing off (withRetry) against an unreachable Bazarr+. + globalThis.fetch = (async (url: any) => { + const u = String(url); + if (u.endsWith("/provider-hub/catalog")) return new Response(JSON.stringify({ entries: [] }), { status: 200 }); + if (u.endsWith("/provider-hub/providers")) return new Response(JSON.stringify({ data: [] }), { status: 200 }); + return new Response("{}", { status: 200 }); + }) as any; + try { + const { deps } = makeDeps(); + await silently(() => runUpdate(dir, deps)); + } finally { + globalThis.fetch = origFetch; + } + const logTxt = readFileSync(join(dir, "update.log"), "utf-8"); + expect(logTxt.toLowerCase()).toContain("provider hub"); +}); + test("runUpdate summary reports changed service images", async () => { const dir = makeInstallDir(); let captureCount = 0; From 151614acd1a39738628319fc6ebef7930b9c0d52 Mon Sep 17 00:00:00 2001 From: Laszlo Toth Date: Mon, 6 Jul 2026 17:08:54 +0200 Subject: [PATCH 5/7] chore(release): v1.2.0 --- package.json | 2 +- src/version.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index a95c0b0..3748972 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "arrstack-installer", - "version": "1.1.1", + "version": "1.2.0", "type": "module", "bin": { "arrstack": "src/cli.ts" diff --git a/src/version.ts b/src/version.ts index 9db5e41..a3a25fb 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = "1.1.1"; +export const VERSION = "1.2.0"; From 678f197d0c4f368f132a39ade51dd109281cb31f Mon Sep 17 00:00:00 2001 From: Laszlo Toth Date: Mon, 6 Jul 2026 18:12:50 +0200 Subject: [PATCH 6/7] fix(bazarr): address code review on the Provider Hub wiring - Readiness gate: call waitForBazarrApiReady before the Provider Hub calls so the wiring waits out Bazarr+'s post-restart DB-migration 500s (the update path only gates on /api/system/ping) instead of silently no-opping. Switch the catalog GET to fetchWithRetry (forgives transient 500/401) and check res.ok. - Add AbortSignal.timeout to every Provider Hub fetch so a wedged Bazarr can't hang an unattended install/update. - waitForInstalled tolerates a transient GET /providers error mid-build instead of dropping the provider on a single blip. - Only pass flaresolverr_url when flaresolverr is actually enabled. - Export + parameterize waitForBazarrApiReady's poll interval; add a test for the 500-during-migration window. 319 tests pass, typecheck clean. --- src/usecase/install.ts | 4 ++- src/usecase/update.ts | 6 +++- src/wiring/bazarr-providerhub.ts | 42 ++++++++++++++++++++----- src/wiring/bazarr.ts | 5 +-- tests/wiring/bazarr-providerhub.test.ts | 37 +++++++++++++++++++++- 5 files changed, 81 insertions(+), 13 deletions(-) diff --git a/src/usecase/install.ts b/src/usecase/install.ts index 87bff21..12c4b9e 100644 --- a/src/usecase/install.ts +++ b/src/usecase/install.ts @@ -548,7 +548,9 @@ export async function runInstall( try { await configureBazarrProviderHub({ apiKey: apiKeys["bazarr"] ?? "", - flaresolverrUrl: "http://flaresolverr:8191/v1", + // Only point the opensubtitles provider at FlareSolverr when it's + // actually deployed; a legacy install can have bazarr without it. + flaresolverrUrl: has("flaresolverr") ? "http://flaresolverr:8191/v1" : "", log: (m) => log.info("provider-hub", m), }); } catch (err) { diff --git a/src/usecase/update.ts b/src/usecase/update.ts index d395e5e..053e418 100644 --- a/src/usecase/update.ts +++ b/src/usecase/update.ts @@ -134,7 +134,11 @@ export async function runUpdate(installDir: string, deps?: Partial): try { await configureBazarrProviderHub({ apiKey: state.api_keys["bazarr"] ?? "", - flaresolverrUrl: "http://flaresolverr:8191/v1", + // Only point the opensubtitles provider at FlareSolverr when it's + // actually deployed; a legacy install can have bazarr without it. + flaresolverrUrl: state.services_enabled.includes("flaresolverr") + ? "http://flaresolverr:8191/v1" + : "", log: (m) => logAndEcho(`[update] ${m}`), }); } catch (err) { diff --git a/src/wiring/bazarr-providerhub.ts b/src/wiring/bazarr-providerhub.ts index c74df0a..cd8cf08 100644 --- a/src/wiring/bazarr-providerhub.ts +++ b/src/wiring/bazarr-providerhub.ts @@ -1,4 +1,5 @@ -import { withRetry } from "../lib/retry.js"; +import { fetchWithRetry } from "../lib/retry.js"; +import { waitForBazarrApiReady } from "./bazarr.js"; // Bazarr+ 2.4.0 loads subtitle providers from the Provider Hub (the built-in // providers are being retired). This wires the installer's default set via the @@ -33,6 +34,8 @@ export interface ProviderHubOptions { base?: string; pollTimeoutMs?: number; pollIntervalMs?: number; + readyTimeoutMs?: number; + readyIntervalMs?: number; log?: (msg: string) => void; } @@ -41,6 +44,10 @@ interface CatalogEntry { manifest?: unknown; } +// Per-request timeout so a wedged Bazarr+ (accepts the socket but never answers) +// cannot hang an unattended install/update. Bun's fetch has no default timeout. +const REQ_TIMEOUT_MS = 15_000; + export async function configureBazarrProviderHub(opts: ProviderHubOptions): Promise { const base = opts.base ?? "http://localhost:6767"; const log = opts.log ?? (() => {}); @@ -49,11 +56,19 @@ export async function configureBazarrProviderHub(opts: ProviderHubOptions): Prom const intervalMs = opts.pollIntervalMs ?? 3000; const targets = providerHubTargets(opts.flaresolverrUrl); - // withRetry doubles as the readiness gate: the first catalog GET retries while - // Bazarr+ finishes booting (connection refused / 500 during DB migrations). - const catRes = await withRetry(() => - fetch(`${base}/api/provider-hub/catalog`, { headers }), - ); + // Readiness gate: after `up` (especially on update) Bazarr+ answers /ping and + // /health with 200 while its DB migrations still 500 the real API for 30-60s. + // Poll the authenticated /api/system/status until it is truly live before + // touching the Provider Hub, otherwise every install below silently no-ops. + await waitForBazarrApiReady(base, opts.apiKey, opts.readyTimeoutMs, opts.readyIntervalMs); + + // fetchWithRetry forgives a transient 500/401 (plain fetch resolves on 500 and + // would not be retried); still check .ok before parsing the body. + const catRes = await fetchWithRetry(`${base}/api/provider-hub/catalog`, { + headers, + signal: AbortSignal.timeout(REQ_TIMEOUT_MS), + }); + if (!catRes.ok) throw new Error(`catalog returned ${catRes.status}`); const catalog = (await catRes.json()) as { entries?: CatalogEntry[] }; const manifestById = new Map(); for (const e of catalog.entries ?? []) { @@ -72,6 +87,7 @@ export async function configureBazarrProviderHub(opts: ProviderHubOptions): Prom method: "POST", headers, body: JSON.stringify({ manifest }), + signal: AbortSignal.timeout(REQ_TIMEOUT_MS), }); if (!res.ok) throw new Error(`install returned ${res.status}`); await waitForInstalled(base, headers, target.id, timeoutMs, intervalMs); @@ -80,6 +96,7 @@ export async function configureBazarrProviderHub(opts: ProviderHubOptions): Prom method: "PATCH", headers, body: JSON.stringify({ enabled: true, ...(target.config ? { config: target.config } : {}) }), + signal: AbortSignal.timeout(REQ_TIMEOUT_MS), }); if (!patch.ok) throw new Error(`enable returned ${patch.status}`); log(`Provider Hub: ${target.id} installed + enabled`); @@ -96,7 +113,10 @@ async function installedIds( base: string, headers: Record, ): Promise> { - const r = await fetch(`${base}/api/provider-hub/providers`, { headers }); + const r = await fetch(`${base}/api/provider-hub/providers`, { + headers, + signal: AbortSignal.timeout(REQ_TIMEOUT_MS), + }); if (!r.ok) throw new Error(`list providers returned ${r.status}`); const j = (await r.json()) as { data?: Array<{ provider_id?: string; id?: string }> }; return new Set( @@ -113,7 +133,13 @@ async function waitForInstalled( ): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { - if ((await installedIds(base, headers)).has(id)) return; + try { + if ((await installedIds(base, headers)).has(id)) return; + } catch { + // Transient error while the hub downloads the bundle / builds the venv + // (it can 5xx mid-build). Keep polling rather than dropping this provider + // on a single blip; the outer timeout still bounds the wait. + } await new Promise((r) => setTimeout(r, intervalMs)); } throw new Error(`install did not finish within ${Math.round(timeoutMs / 1000)}s`); diff --git a/src/wiring/bazarr.ts b/src/wiring/bazarr.ts index 3730ead..4374a6b 100644 --- a/src/wiring/bazarr.ts +++ b/src/wiring/bazarr.ts @@ -19,10 +19,11 @@ async function readBody(res: Response): Promise { // during the first 30-60s often 500 while the DB migrations finish loading. // Poll an authenticated endpoint until it returns 200; that is the real signal // that the app + db + our config.yaml (with its apikey) are fully live. -async function waitForBazarrApiReady( +export async function waitForBazarrApiReady( base: string, apiKey: string, timeoutMs = 120_000, + intervalMs = 2000, ): Promise { const start = Date.now(); let lastStatus = 0; @@ -35,7 +36,7 @@ async function waitForBazarrApiReady( if (r.ok) return; lastStatus = r.status; } catch { /* retry */ } - await new Promise((r) => setTimeout(r, 2000)); + await new Promise((r) => setTimeout(r, intervalMs)); } throw new Error( `Bazarr API did not accept the configured key within ${timeoutMs / 1000}s ` + diff --git a/tests/wiring/bazarr-providerhub.test.ts b/tests/wiring/bazarr-providerhub.test.ts index cf262c1..f85042b 100644 --- a/tests/wiring/bazarr-providerhub.test.ts +++ b/tests/wiring/bazarr-providerhub.test.ts @@ -18,14 +18,23 @@ function fakeCatalog() { // provider to the installed set, so the subsequent GET /providers (which // waitForInstalled polls) sees it. Without this the install would "never finish" // and every provider would time out. -function makeStatefulFetch(opts: { preinstalled?: string[]; installThrows?: boolean } = {}) { +function makeStatefulFetch( + opts: { preinstalled?: string[]; installThrows?: boolean; statusFailsTimes?: number } = {}, +) { const installed = new Set(opts.preinstalled ?? []); const calls: Array<{ method: string; url: string; body?: any }> = []; + let statusCalls = 0; const fetchFn = async (url: any, init: any = {}) => { const u = String(url); const method = init.method ?? "GET"; const body = init.body ? JSON.parse(init.body) : undefined; calls.push({ method, url: u, body }); + if (u.endsWith("/api/system/status")) { + // Simulate Bazarr+'s DB-migration window: 500 a few times, then live. + statusCalls += 1; + const ready = statusCalls > (opts.statusFailsTimes ?? 0); + return new Response(ready ? "{}" : "migrating", { status: ready ? 200 : 500 }); + } if (u.endsWith("/provider-hub/catalog")) { return new Response(JSON.stringify(fakeCatalog()), { status: 200 }); } @@ -116,4 +125,30 @@ describe("configureBazarrProviderHub", () => { expect(warnings.some((w) => w.toLowerCase().includes("opensubtitles"))).toBe(true); expect(warnings.length).toBeGreaterThanOrEqual(5); // one warning per provider }); + + test("waits out Bazarr's post-restart HTTP 500 window instead of silently no-opping", async () => { + // The update path only gates on /api/system/ping (200 early), so the API can + // still 500 for 30-60s during DB migrations. /api/system/status 500s twice + // then goes live; the readiness gate must wait, then install all providers, + // NOT skip them as 'not in the catalog'. + const warnings: string[] = []; + const { fetchFn, calls } = makeStatefulFetch({ statusFailsTimes: 2 }); + globalThis.fetch = fetchFn as any; + + await configureBazarrProviderHub({ + apiKey: "K", + flaresolverrUrl: FLARE, + base: "http://bz.test", + pollTimeoutMs: 500, + pollIntervalMs: 5, + readyIntervalMs: 5, // don't wait the real 2s between readiness polls + log: (m) => warnings.push(m), + }); + + // Readiness gate polled status 3 times (500, 500, 200) before proceeding. + expect(calls.filter((c) => c.url.endsWith("/api/system/status"))).toHaveLength(3); + // All 5 providers installed (the feature did NOT no-op). + expect(calls.filter((c) => c.method === "POST" && c.url.endsWith("/installations"))).toHaveLength(5); + expect(warnings.some((w) => w.includes("not in the catalog"))).toBe(false); + }); }); From 00fecb271704474a0b0aaf3d46e4d973691cce3f Mon Sep 17 00:00:00 2001 From: Laszlo Toth Date: Mon, 6 Jul 2026 18:19:12 +0200 Subject: [PATCH 7/7] fix(bazarr): generous timeout for the Provider Hub install POST The POST /installations call blocks while the Hub downloads the provider bundle and builds its venv; heavy providers (opensubtitles) exceed the 15s per-request timeout, which falsely reported them as failed and skipped their enable+config PATCH. Give the install POST a 180s cap; keep 15s for the quick catalog/list/ patch calls. Verified end-to-end on a live Bazarr+ 2.4.0: all 5 providers install + enable and opensubtitles gets its flaresolverr_url. --- src/wiring/bazarr-providerhub.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/wiring/bazarr-providerhub.ts b/src/wiring/bazarr-providerhub.ts index cd8cf08..2b9a62e 100644 --- a/src/wiring/bazarr-providerhub.ts +++ b/src/wiring/bazarr-providerhub.ts @@ -47,6 +47,11 @@ interface CatalogEntry { // Per-request timeout so a wedged Bazarr+ (accepts the socket but never answers) // cannot hang an unattended install/update. Bun's fetch has no default timeout. const REQ_TIMEOUT_MS = 15_000; +// POST /installations blocks while the Hub downloads the provider bundle and +// builds its isolated venv, which for heavy providers (e.g. opensubtitles pulls +// ai-cloudscraper) takes well over REQ_TIMEOUT_MS. Give it a generous cap so it +// isn't falsely reported as failed (which would also skip its enable+config). +const INSTALL_TIMEOUT_MS = 180_000; export async function configureBazarrProviderHub(opts: ProviderHubOptions): Promise { const base = opts.base ?? "http://localhost:6767"; @@ -87,7 +92,7 @@ export async function configureBazarrProviderHub(opts: ProviderHubOptions): Prom method: "POST", headers, body: JSON.stringify({ manifest }), - signal: AbortSignal.timeout(REQ_TIMEOUT_MS), + signal: AbortSignal.timeout(INSTALL_TIMEOUT_MS), }); if (!res.ok) throw new Error(`install returned ${res.status}`); await waitForInstalled(base, headers, target.id, timeoutMs, intervalMs);