Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "arrstack-installer",
"version": "1.1.1",
"version": "1.2.0",
"type": "module",
"bin": {
"arrstack": "src/cli.ts"
Expand Down
22 changes: 1 addition & 21 deletions src/catalog/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions src/ui/wizard/useWizardState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -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[] {
Expand Down
20 changes: 20 additions & 0 deletions src/usecase/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -539,6 +540,25 @@ 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"] ?? "",
// 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) {
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 () => {
Expand Down
21 changes: 21 additions & 0 deletions src/usecase/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>;
Expand Down Expand Up @@ -125,6 +126,26 @@ export async function runUpdate(installDir: string, deps?: Partial<UpdateDeps>):
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"] ?? "",
// 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) {
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;
Expand Down
2 changes: 1 addition & 1 deletion src/version.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export const VERSION = "1.1.1";
export const VERSION = "1.2.0";
151 changes: 151 additions & 0 deletions src/wiring/bazarr-providerhub.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
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
// 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/<id> 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<string, unknown>;
}

// 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;
readyTimeoutMs?: number;
readyIntervalMs?: number;
log?: (msg: string) => void;
}

interface CatalogEntry {
provider_id?: string;
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;
// 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<void> {
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);

// 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<string, unknown>();
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 }),
signal: AbortSignal.timeout(INSTALL_TIMEOUT_MS),
});
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 } : {}) }),
signal: AbortSignal.timeout(REQ_TIMEOUT_MS),
});
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<string, string>,
): Promise<Set<string>> {
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(
(j.data ?? []).map((p) => p.provider_id ?? p.id).filter((x): x is string => !!x),
);
}

async function waitForInstalled(
base: string,
headers: Record<string, string>,
id: string,
timeoutMs: number,
intervalMs: number,
): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
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`);
}
5 changes: 3 additions & 2 deletions src/wiring/bazarr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,11 @@ async function readBody(res: Response): Promise<string> {
// 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<void> {
const start = Date.now();
let lastStatus = 0;
Expand All @@ -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 ` +
Expand Down
6 changes: 0 additions & 6 deletions templates/bazarr-config.yaml.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ general:
- opensubtitlescom
- opensubtitles
- embeddedsubtitles
- podnapisi
- addic7ed
- yifysubtitles
flask_secret_key: {{flaskSecretKey}}
Expand Down Expand Up @@ -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: '/'
Expand Down
9 changes: 9 additions & 0 deletions tests/renderer/compose.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
12 changes: 10 additions & 2 deletions tests/renderer/configs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
8 changes: 8 additions & 0 deletions tests/ui/wizard/useWizardState.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading
Loading