From 1efe172d7614fa290e55ecf68f287faf9fea36e0 Mon Sep 17 00:00:00 2001 From: Laszlo Toth Date: Mon, 19 Oct 2026 11:06:35 +0200 Subject: [PATCH 1/4] feat(install): add --yes for non-interactive resume arrstack install --resume --yes reads state.json and admin.txt and reruns the full install pipeline without the wizard. This makes recovery from an aborted install scriptable and covers every wiring step, including Jellyfin and Jellyseerr, not just the arr logins that update repairs. --- README.md | 3 +- docs/guide/01-installation.md | 4 + docs/guide/09-updating.md | 2 +- docs/troubleshooting/common-errors.md | 2 + docs/troubleshooting/reinstall-recovery.md | 8 ++ src/cli.ts | 23 ++++++ src/usecase/resume.ts | 52 +++++++++++++ tests/cli.test.ts | 13 ++++ tests/usecase/resume.test.ts | 86 ++++++++++++++++++++++ 9 files changed, 191 insertions(+), 2 deletions(-) create mode 100644 src/usecase/resume.ts create mode 100644 tests/usecase/resume.test.ts diff --git a/README.md b/README.md index a46c19a..b69e047 100644 --- a/README.md +++ b/README.md @@ -206,7 +206,8 @@ Add a drive later by mounting it on the host, re-running `arrstack`, and enterin | `arrstack uninstall` | `docker compose down` only. Preserves `config/`, `state.json`, `admin.txt`, and media. Add `--purge` to also delete `/config/`; `state.json`, `admin.txt`, and media still survive | Global flags: `--verbose`, `--non-interactive`. -`install` flags: `--fresh`, `--resume`, `--install-dir `. +`install` flags: `--fresh`, `--resume`, `--yes`, `--install-dir `. +`--yes` finishes an existing install without the wizard and requires `--resume`. ## Configuration reference diff --git a/docs/guide/01-installation.md b/docs/guide/01-installation.md index 0f0f103..e6850fe 100644 --- a/docs/guide/01-installation.md +++ b/docs/guide/01-installation.md @@ -13,6 +13,9 @@ arrstack install # Resume a partially finished run arrstack install --resume + +# Finish it without the wizard (CI, remote shell, scripted recovery) +arrstack install --resume --yes ``` ## Supported systems @@ -70,6 +73,7 @@ If you prefer not to pipe curl to bash, the script URL is plain text. Read it, t arrstack install Run the TUI wizard arrstack install --fresh Wipe state and start over arrstack install --resume Continue from last saved step +arrstack install --resume --yes Finish an existing install without the wizard arrstack doctor Health check existing install arrstack update Pull latest images and restart arrstack show-password Print admin credentials diff --git a/docs/guide/09-updating.md b/docs/guide/09-updating.md index 117939e..6aed509 100644 --- a/docs/guide/09-updating.md +++ b/docs/guide/09-updating.md @@ -26,7 +26,7 @@ arrstack doctor Duration: 30 seconds to 5 minutes depending on image sizes and download speed. To update a single service, use `docker compose` directly. -`arrstack update` does not re-run the Jellyfin or Jellyseerr bootstrap. If those were never configured because an install failed early, use `arrstack install --resume`. +`arrstack update` does not re-run the Jellyfin or Jellyseerr bootstrap. If those were never configured because an install failed early, use `arrstack install --resume`, or `arrstack install --resume --yes` to finish without the wizard. ## Image tag policy diff --git a/docs/troubleshooting/common-errors.md b/docs/troubleshooting/common-errors.md index 1a7b4c4..6925833 100644 --- a/docs/troubleshooting/common-errors.md +++ b/docs/troubleshooting/common-errors.md @@ -40,6 +40,8 @@ sudo -E arrstack install --install-dir /opt/arrstack # or run elevated **Fix.** Finish the install so the wiring steps run: ```bash arrstack install --resume +# or, without the wizard: +arrstack install --resume --yes ``` On arrstack 1.2.1 and later, `arrstack update` also seeds a missing Sonarr/Radarr/Prowlarr admin user, but it does not run the Jellyfin or Jellyseerr setup. Use `--resume` when Jellyfin login is also broken. diff --git a/docs/troubleshooting/reinstall-recovery.md b/docs/troubleshooting/reinstall-recovery.md index 20bd1e1..2da091c 100644 --- a/docs/troubleshooting/reinstall-recovery.md +++ b/docs/troubleshooting/reinstall-recovery.md @@ -37,6 +37,14 @@ arrstack install --resume arrstack install --resume --install-dir /opt/arrstack ``` +For a script, remote shell, or any non-interactive run, add `--yes`. It skips +the wizard, reads `state.json` and `admin.txt`, and reruns the full pipeline +without rotating the password: + +```bash +arrstack install --resume --yes +``` + ### arrstack install --fresh Ignores any existing `state.json` and walks the wizard from scratch. Your previous config directory and media are still on disk, but the new install will generate a new `state.json` and may pick different API keys and ports. diff --git a/src/cli.ts b/src/cli.ts index e7f1ac2..02e9127 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,6 +7,7 @@ import { App } from "./ui/App.js"; import { readState, adminTxtGuardMessage } from "./state/store.js"; import { runDoctor } from "./usecase/doctor.js"; import { runUpdate } from "./usecase/update.js"; +import { runResume } from "./usecase/resume.js"; import { showPassword } from "./usecase/show-password.js"; import { runUninstall } from "./usecase/uninstall.js"; import { tailLogs } from "./usecase/logs.js"; @@ -44,6 +45,7 @@ program .description("Install the arr media stack") .option("--fresh", "perform a fresh installation") .option("--resume", "resume a previously interrupted installation") + .option("--yes", "skip the wizard and finish an existing install non-interactively (requires --resume)") .option("--install-dir ", "installation directory", `${process.env.HOME}/arrstack`) .action(async (opts) => { const installDir = opts.installDir ?? `${process.env.HOME}/arrstack`; @@ -67,6 +69,27 @@ program // --fresh purges the dir (removing admin.txt) just above, so only guard a // resume/reconfigure over an existing install. if (!opts.fresh) guardAdminTxtReadable(installDir); + + if (opts.yes) { + if (!opts.resume) { + console.error("\n--yes requires --resume. Use 'arrstack install --resume --yes' to finish an existing install.\n"); + process.exit(1); + } + try { + const result = await runResume(installDir); + console.log("\nInstall complete."); + for (const url of result.urls) { + console.log(` ${url.name}: ${url.url}`); + } + console.log(` Admin user: ${result.adminUser} (password in ${join(installDir, "admin.txt")})`); + console.log(` Verify: arrstack doctor --install-dir ${installDir}`); + } catch (err: any) { + console.error(`\nInstall failed: ${err.message ?? String(err)}`); + process.exit(1); + } + return; + } + const existing = opts.fresh ? null : readState(installDir); const { waitUntilExit } = render(React.createElement(App, { existingState: existing })); await waitUntilExit(); diff --git a/src/usecase/resume.ts b/src/usecase/resume.ts new file mode 100644 index 0000000..48fb230 --- /dev/null +++ b/src/usecase/resume.ts @@ -0,0 +1,52 @@ +import { readState, readAdminCredentials } from "../state/store.js"; +import { runInstall, type InstallResult, type StepUpdate } from "./install.js"; +import { VERSION } from "../version.js"; + +export type RunInstallFn = typeof runInstall; + +export interface RunResumeOptions { + onStep?: (update: StepUpdate) => void; + // Injectable for tests. Production uses runInstall. + runInstallFn?: RunInstallFn; +} + +function printStep(update: StepUpdate): void { + if (update.status === "running") { + process.stdout.write(` ${update.step}... `); + } else if (update.status === "done") { + const seconds = ((update.durationMs ?? 0) / 1000).toFixed(1); + console.log(`ok (${seconds}s)`); + } else if (update.status === "failed") { + console.log(`FAILED\n ${update.message ?? "unknown error"}`); + } +} + +// Non-interactive `arrstack install --resume --yes`. Reads the saved state and +// the existing admin password, then reruns the full install pipeline: render, +// pull, up, health gate, and every wiring step (arr logins, Jellyfin, +// Jellyseerr, qBittorrent, etc). This is the scriptable recovery path for an +// install that aborted before wiring, and it never rotates the admin password. +export async function runResume( + installDir: string, + opts: RunResumeOptions = {}, +): Promise { + const state = readState(installDir); + if (!state) { + throw new Error(`No state.json found in ${installDir}. Run 'arrstack install' first.`); + } + + const creds = readAdminCredentials(installDir); + if (!creds) { + throw new Error( + `No readable admin.txt found in ${installDir}. ` + + `A previous install may have failed before writing it. Run 'arrstack install --fresh'.`, + ); + } + + const install = opts.runInstallFn ?? runInstall; + return install( + { ...state, install_dir: installDir, installer_version: VERSION }, + creds.password, + opts.onStep ?? printStep, + ); +} diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 4d9d51d..d69745b 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -2,6 +2,8 @@ import { test, expect } from "bun:test"; import { spawnSync } from "bun"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; const repoRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); const cliPath = join(repoRoot, "src", "cli.ts"); @@ -27,6 +29,17 @@ test("--help includes arrstack", () => { expect(stdout).toContain("arrstack"); }); +test("install --yes without --resume fails with a clear error", () => { + const dir = mkdtempSync(join(tmpdir(), "arrstack-cli-test-")); + try { + const { stderr, exitCode } = runCli("install", "--yes", "--install-dir", dir); + expect(exitCode).toBe(1); + expect(stderr).toContain("--yes requires --resume"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("--help lists all subcommands", () => { const { stdout } = runCli("--help"); expect(stdout).toContain("install"); diff --git a/tests/usecase/resume.test.ts b/tests/usecase/resume.test.ts new file mode 100644 index 0000000..7047479 --- /dev/null +++ b/tests/usecase/resume.test.ts @@ -0,0 +1,86 @@ +import { test, expect, afterEach } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { runResume } from "../../src/usecase/resume.js"; +import { writeState } from "../../src/state/store.js"; +import { VERSION } from "../../src/version.js"; +import type { State } from "../../src/state/schema.js"; +import type { InstallResult } from "../../src/usecase/install.js"; + +const STATE: State = { + schema_version: 1, + installer_version: "1.0.0", + install_dir: "/old/path", + storage_root: "/data", + extra_paths: [], + admin: { username: "admin" }, + services_enabled: ["sonarr"], + gpu: { vendor: "none" }, + remote_access: { mode: "none" }, + local_dns: { enabled: false, tld: "local", install_dnsmasq: false }, + vpn: { enabled: false }, + timezone: "Europe/London", + puid: 1000, + pgid: 1000, + subtitle_languages: ["en"], + api_keys: { sonarr: "key" }, + secrets: {}, +}; + +const RESULT: InstallResult = { + urls: [{ name: "Sonarr", url: "http://localhost:8989", description: "Sonarr" }], + password: "from-state", + adminUser: "admin", +}; + +let created: string[] = []; + +function makeDir(): string { + const dir = mkdtempSync(join(tmpdir(), "arrstack-resume-test-")); + created.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of created) rmSync(dir, { recursive: true, force: true }); + created = []; +}); + +test("runResume passes existing state and admin.txt password to runInstall", async () => { + const dir = makeDir(); + writeState(dir, { ...STATE, install_dir: dir }); + writeFileSync(join(dir, "admin.txt"), "username: admin\npassword: s3cret-pw\ngenerated: now\n"); + + let receivedState: State | null = null; + let receivedPassword = ""; + const result = await runResume(dir, { + onStep: () => {}, + runInstallFn: async (state, password) => { + receivedState = state; + receivedPassword = password; + return RESULT; + }, + }); + + expect(result).toEqual(RESULT); + expect(receivedPassword).toBe("s3cret-pw"); + expect(receivedState?.install_dir).toBe(dir); + expect(receivedState?.installer_version).toBe(VERSION); + expect(receivedState?.services_enabled).toEqual(["sonarr"]); +}); + +test("runResume rejects when state.json is missing", async () => { + const dir = makeDir(); + await expect(runResume(dir, { runInstallFn: async () => RESULT })).rejects.toThrow( + /No state\.json found/, + ); +}); + +test("runResume rejects when admin.txt is missing", async () => { + const dir = makeDir(); + writeState(dir, { ...STATE, install_dir: dir }); + await expect(runResume(dir, { runInstallFn: async () => RESULT })).rejects.toThrow( + /No readable admin\.txt found/, + ); +}); From ff6593d713c28761717fefac748d9ae1f8684a08 Mon Sep 17 00:00:00 2001 From: Laszlo Toth Date: Mon, 19 Oct 2026 11:06:35 +0200 Subject: [PATCH 2/4] fix(jellyfin,jellyseerr): support current upstream APIs on resume Jellyfin 12.x disables legacy X-Emby-Authorization by default and rejects AuthenticateByName without the standard Authorization header (400, request.App null). Send both headers, and skip Startup/User when an earlier attempt already created the first user (Jellyfin answers 403). Seerr 3.x replaced the library sync and bulk-enable query parameters with POST /library/sync and PUT /library/:id. Use the new endpoints with a legacy fallback, and log in without hostname fields when the server is already configured (500 "hostname already configured"). --- src/wiring/jellyfin.ts | 47 +++++++--- src/wiring/jellyseerr.ts | 156 ++++++++++++++++++++------------ tests/wiring/jellyfin.test.ts | 92 +++++++++++++++++++ tests/wiring/jellyseerr.test.ts | 114 +++++++++++++++++++++++ 4 files changed, 340 insertions(+), 69 deletions(-) diff --git a/src/wiring/jellyfin.ts b/src/wiring/jellyfin.ts index 88b0de6..574dd06 100644 --- a/src/wiring/jellyfin.ts +++ b/src/wiring/jellyfin.ts @@ -12,15 +12,24 @@ async function readBody(res: Response): Promise { // Jellyfin's /health returns 200 before the default user record is created // internally, so the first POST /Startup/User can 500. Poll /Startup/User (GET) // until it returns 200, which is Jellyfin's signal that the startup API is ready. -async function waitForJellyfinStartupReady(base: string, timeoutMs = 60_000): Promise { +// Returns the current user record so a resumed install can detect a user that +// was created by an earlier attempt and skip the POST (Jellyfin answers 403 +// once the first user exists). +async function waitForJellyfinStartupReady( + base: string, + timeoutMs = 60_000, +): Promise<{ Name?: string } | null> { const start = Date.now(); while (Date.now() - start < timeoutMs) { try { const r = await fetch(`${base}/Startup/User`); - if (r.ok) return; + if (r.ok) { + return (await r.json().catch(() => null)) as { Name?: string } | null; + } } catch { /* retry */ } await new Promise((r) => setTimeout(r, 1500)); } + return null; } async function isStartupComplete(base: string): Promise { @@ -46,6 +55,10 @@ async function authenticate( adminUser: string, adminPass: string, ): Promise { + // Jellyfin 10.11+ parses the standard Authorization header. 12.x disables + // the legacy X-Emby-Authorization fallback by default, and without Client / + // Device / DeviceId / Version in the header AuthenticateByName returns 400 + // ("Value cannot be null. Parameter 'request.App'"). const authHeader = 'MediaBrowser Client="arrstack", Device="installer", DeviceId="arrstack", Version="1.0.0"'; const authRes = await withRetry(() => @@ -53,6 +66,9 @@ async function authenticate( method: "POST", headers: { "Content-Type": "application/json", + Authorization: authHeader, + // Older Jellyfin builds only read the legacy header. 10.11+ prefer + // Authorization, and 12.x ignores this when legacy auth is disabled. "X-Emby-Authorization": authHeader, }, body: JSON.stringify({ Username: adminUser, Pw: adminPass }), @@ -120,23 +136,30 @@ export async function setupJellyfin( throw new Error(`Startup/Configuration failed: HTTP ${configRes.status}\n${await readBody(configRes)}`); } - await waitForJellyfinStartupReady(base); - - const userRes = await fetchWithRetry(`${base}/Startup/User`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ Name: adminUser, Password: adminPass }), - }); - if (!userRes.ok) { - throw new Error(`Startup/User failed: HTTP ${userRes.status}\n${await readBody(userRes)}`); + const startupUser = await waitForJellyfinStartupReady(base); + + // A previous attempt may have created the first user before failing later + // (or the user answered part of the startup wizard). POSTing /Startup/User + // again returns 403, so only create the user when none exists. + if (!startupUser?.Name) { + const userRes = await fetchWithRetry(`${base}/Startup/User`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ Name: adminUser, Password: adminPass }), + }); + if (!userRes.ok) { + throw new Error(`Startup/User failed: HTTP ${userRes.status}\n${await readBody(userRes)}`); + } } } // Authenticate with the (now-existing) admin user const auth = await authenticate(base, adminUser, adminPass); + const tokenHeader = `${auth.authHeader}, Token="${auth.token}"`; const authedHeaders = { "Content-Type": "application/json", - "X-Emby-Authorization": `${auth.authHeader}, Token="${auth.token}"`, + Authorization: tokenHeader, + "X-Emby-Authorization": tokenHeader, }; await ensureAdmin(base, auth, authedHeaders); diff --git a/src/wiring/jellyseerr.ts b/src/wiring/jellyseerr.ts index abf35be..aed27f8 100644 --- a/src/wiring/jellyseerr.ts +++ b/src/wiring/jellyseerr.ts @@ -29,55 +29,59 @@ export async function linkJellyseerr( ): Promise { await waitForJellyseerrReady(base); - // 1. Bootstrap: create the admin + store the Jellyfin connection. - const bootstrap = await withRetry(() => - fetch(`${base}/api/v1/auth/jellyfin`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - username: jellyfinUser, - password: jellyfinPass, - hostname: "jellyfin", - port: 8096, - useSsl: false, - urlBase: "", - // MediaServerType enum (PLEX=1, JELLYFIN=2, EMBY=3). Without this the - // endpoint returns HTTP 500 {"message":"NO_ADMIN_USER"}. - serverType: 2, - email: jellyfinUser, + // 1. Bootstrap: create the admin + store the Jellyfin connection. On a + // resumed install the hostname is already configured and Seerr answers 500 + // "Jellyfin hostname already configured", so retry without the hostname + // fields to log in to the existing admin instead. + const login = (withHostname: boolean) => + withRetry(() => + fetch(`${base}/api/v1/auth/jellyfin`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: jellyfinUser, + password: jellyfinPass, + email: jellyfinUser, + // MediaServerType enum (PLEX=1, JELLYFIN=2, EMBY=3). Without this the + // endpoint returns HTTP 500 {"message":"NO_ADMIN_USER"}. + serverType: 2, + ...(withHostname + ? { hostname: "jellyfin", port: 8096, useSsl: false, urlBase: "" } + : {}), + }), }), - }) - ); + ); + + const bootstrap = await login(true); + let sessionRes = bootstrap; if (!bootstrap.ok) { - throw new Error(`Jellyseerr auth failed: HTTP ${bootstrap.status}\n${await readBody(bootstrap)}`); + const existing = await login(false); + if (!existing.ok) { + throw new Error( + `Jellyseerr auth failed: HTTP ${bootstrap.status} (bootstrap), ` + + `${existing.status} (existing login)\n${await readBody(existing)}`, + ); + } + sessionRes = existing; + } else { + // The session cookie that bootstrap returns is racy: Jellyseerr creates + // the admin user, saves settings, and fires startJobs() before the + // response flushes, but subsequent GETs with that cookie intermittently + // return 403 ("You do not have permission to access this endpoint") + // because the session/user join hasn't materialised yet. Re-logging in + // with the same credentials (no hostname — Jellyfin is configured now) + // returns a cookie that's reliably bound to the admin user. + const relogin = await login(false); + if (!relogin.ok) { + throw new Error(`Jellyseerr re-login failed: HTTP ${relogin.status}\n${await readBody(relogin)}`); + } + sessionRes = relogin; } - // The session cookie that bootstrap returns is racy: Jellyseerr creates the - // admin user, saves settings, and fires startJobs() before the response - // flushes, but subsequent GETs with that cookie intermittently return 403 - // ("You do not have permission to access this endpoint") because the - // session/user join hasn't materialised yet. Re-logging in with the same - // credentials (no hostname — Jellyfin is already configured now) returns a - // cookie that's reliably bound to the admin user. - const relogin = await withRetry(() => - fetch(`${base}/api/v1/auth/jellyfin`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - username: jellyfinUser, - password: jellyfinPass, - email: jellyfinUser, - serverType: 2, - }), - }) - ); - if (!relogin.ok) { - throw new Error(`Jellyseerr re-login failed: HTTP ${relogin.status}\n${await readBody(relogin)}`); - } const cookie = - (relogin.headers.get("set-cookie") ?? "").match(/connect\.sid=[^;]+/)?.[0] ?? ""; + (sessionRes.headers.get("set-cookie") ?? "").match(/connect\.sid=[^;]+/)?.[0] ?? ""; if (!cookie) { - throw new Error("Jellyseerr re-login did not return a session cookie"); + throw new Error("Jellyseerr login did not return a session cookie"); } const authedHeaders = { "Content-Type": "application/json", Cookie: cookie }; @@ -94,34 +98,72 @@ export async function linkJellyseerr( await new Promise((r) => setTimeout(r, 500)); } - // 2. Sync the Jellyfin library list so Jellyseerr knows what exists. + // 2. Sync the Jellyfin library list so Jellyseerr knows what exists. Seerr + // 3.x replaced the old `?sync=true` shortcut with POST /library/sync; keep + // the legacy call as a fallback for older Jellyseerr builds. + let libs: Library[] = []; + let seerrLibraryApi = false; const syncRes = await withRetry(() => - fetch(`${base}/api/v1/settings/jellyfin/library?sync=true`, { headers: authedHeaders }), + fetch(`${base}/api/v1/settings/jellyfin/library/sync`, { + method: "POST", + headers: authedHeaders, + }), ); - if (!syncRes.ok) { + if (syncRes.ok) { + libs = (await syncRes.json()) as Library[]; + seerrLibraryApi = true; + } else if (syncRes.status === 404 || syncRes.status === 400) { + const legacyRes = await withRetry(() => + fetch(`${base}/api/v1/settings/jellyfin/library?sync=true`, { headers: authedHeaders }), + ); + if (!legacyRes.ok) { + throw new Error( + `Jellyseerr library sync failed: HTTP ${legacyRes.status}\n${await readBody(legacyRes)}`, + ); + } + libs = (await legacyRes.json()) as Library[]; + } else { throw new Error(`Jellyseerr library sync failed: HTTP ${syncRes.status}\n${await readBody(syncRes)}`); } - const libs = (await syncRes.json()) as Library[]; // 3. Enable every library (Movies, TV Shows, Music). Jellyseerr's library // flag is what the /setup wizard toggles at step 3 — without it, requests // can't be matched against media. if (libs.length > 0) { - const enableIds = libs.map((l) => l.id).join(","); - const enableRes = await withRetry(() => - fetch(`${base}/api/v1/settings/jellyfin/library?enable=${enableIds}`, { - headers: authedHeaders, - }), - ); - if (!enableRes.ok) { - throw new Error(`Jellyseerr enable libraries failed: HTTP ${enableRes.status}\n${await readBody(enableRes)}`); + if (seerrLibraryApi) { + // Seerr 3.x: PUT /library/:id { enabled: true } + for (const lib of libs) { + const enableRes = await withRetry(() => + fetch(`${base}/api/v1/settings/jellyfin/library/${encodeURIComponent(lib.id)}`, { + method: "PUT", + headers: authedHeaders, + body: JSON.stringify({ enabled: true }), + }), + ); + if (!enableRes.ok) { + throw new Error( + `Jellyseerr enable library "${lib.name}" failed: HTTP ${enableRes.status}\n${await readBody(enableRes)}`, + ); + } + } + } else { + const enableIds = libs.map((l) => l.id).join(","); + const enableRes = await withRetry(() => + fetch(`${base}/api/v1/settings/jellyfin/library?enable=${enableIds}`, { + headers: authedHeaders, + }), + ); + if (!enableRes.ok) { + throw new Error(`Jellyseerr enable libraries failed: HTTP ${enableRes.status}\n${await readBody(enableRes)}`); + } } - // Kick off the one-time full media scan in the background. + // Kick off the one-time full media scan in the background. Seerr expects + // { start: true }; older Overseerr-derived builds also accept it. await fetch(`${base}/api/v1/settings/jellyfin/sync`, { method: "POST", headers: authedHeaders, - body: "{}", + body: JSON.stringify({ start: true }), }).catch(() => { /* scan is best-effort */ }); } diff --git a/tests/wiring/jellyfin.test.ts b/tests/wiring/jellyfin.test.ts index 1d47ad8..da47bc8 100644 --- a/tests/wiring/jellyfin.test.ts +++ b/tests/wiring/jellyfin.test.ts @@ -9,3 +9,95 @@ test("setupJellyfin is exported and is async", () => { // Suppress unhandled rejection result.catch(() => {}); }); + +test("setupJellyfin skips Startup/User when a previous attempt created the user", async () => { + const origFetch = globalThis.fetch; + const calls: string[] = []; + globalThis.fetch = (async (url: any, init: any) => { + const u = String(url); + const method = init?.method ?? "GET"; + calls.push(`${method} ${u}`); + if (u.endsWith("/System/Info/Public")) { + return new Response(JSON.stringify({ StartupWizardCompleted: false }), { status: 200 }); + } + if (u.endsWith("/Startup/User") && method === "GET") { + return new Response(JSON.stringify({ Name: "admin" }), { status: 200 }); + } + if (u.endsWith("/Startup/User") && method === "POST") { + return new Response("first user exists", { status: 403 }); + } + if (u.endsWith("/Users/AuthenticateByName")) { + return new Response( + JSON.stringify({ + AccessToken: "tok-123", + User: { Id: "user-1", Policy: { IsAdministrator: true } }, + }), + { status: 200 }, + ); + } + if (u.endsWith("/Library/VirtualFolders") && method === "GET") { + return new Response("[]", { status: 200 }); + } + return new Response("{}", { status: 200 }); + }) as any; + + try { + await setupJellyfin("admin", "pw", [], "http://jellyfin.test"); + } finally { + globalThis.fetch = origFetch; + } + + expect(calls).not.toContain("POST http://jellyfin.test/Startup/User"); + expect(calls).toContain("POST http://jellyfin.test/Startup/Complete"); +}); + +// Jellyfin 12.x disables legacy X-Emby-Authorization by default. The standard +// Authorization header carries Client/Device/DeviceId/Version, which +// AuthenticateByName requires for request.App. +test("setupJellyfin uses the standard Authorization header", async () => { + const origFetch = globalThis.fetch; + const authHeaders: Record[] = []; + const libraryHeaders: Record[] = []; + globalThis.fetch = (async (url: any, init: any) => { + const u = String(url); + const headers = (init?.headers ?? {}) as Record; + if (u.endsWith("/System/Info/Public")) { + return new Response(JSON.stringify({ StartupWizardCompleted: true }), { status: 200 }); + } + if (u.endsWith("/Users/AuthenticateByName")) { + authHeaders.push(headers); + return new Response( + JSON.stringify({ + AccessToken: "tok-123", + User: { Id: "user-1", Policy: { IsAdministrator: true } }, + }), + { status: 200 }, + ); + } + if (u.includes("/Library/VirtualFolders")) { + libraryHeaders.push(headers); + return new Response("[]", { status: 200 }); + } + return new Response("{}", { status: 200 }); + }) as any; + + try { + await setupJellyfin( + "admin", + "pw", + [{ name: "Movies", type: "movies", paths: ["/data/media/movies"] }], + "http://jellyfin.test", + ); + } finally { + globalThis.fetch = origFetch; + } + + expect(authHeaders).toHaveLength(1); + expect(authHeaders[0].Authorization).toStartWith("MediaBrowser "); + expect(authHeaders[0].Authorization).toContain('Client="arrstack"'); + expect(authHeaders[0]["X-Emby-Authorization"]).toBe(authHeaders[0].Authorization); + + expect(libraryHeaders.length).toBeGreaterThan(0); + expect(libraryHeaders[0].Authorization).toContain('Token="tok-123"'); + expect(libraryHeaders[0]["X-Emby-Authorization"]).toBe(libraryHeaders[0].Authorization); +}); diff --git a/tests/wiring/jellyseerr.test.ts b/tests/wiring/jellyseerr.test.ts index c1a7a86..d7dab4c 100644 --- a/tests/wiring/jellyseerr.test.ts +++ b/tests/wiring/jellyseerr.test.ts @@ -7,3 +7,117 @@ test("linkJellyseerr is exported and is async", () => { expect(result instanceof Promise).toBe(true); result.catch(() => {}); }); + +interface Call { + method: string; + url: string; +} + +function mockSeerr( + librarySyncStatus: number, + hostnameAlreadyConfigured = false, +): { calls: Call[]; authBodies: any[]; restore: () => void } { + const origFetch = globalThis.fetch; + const calls: Call[] = []; + const authBodies: any[] = []; + globalThis.fetch = (async (url: any, init: any) => { + const u = String(url); + const method = init?.method ?? "GET"; + calls.push({ method, url: u }); + + if (u.endsWith("/api/v1/settings/public")) { + return new Response("{}", { status: 200 }); + } + if (u.endsWith("/api/v1/auth/jellyfin")) { + const body = init?.body ? JSON.parse(init.body) : {}; + authBodies.push(body); + if (hostnameAlreadyConfigured && body.hostname) { + return new Response(JSON.stringify({ error: "Jellyfin hostname already configured" }), { + status: 500, + }); + } + return new Response("{}", { + status: 200, + headers: { "set-cookie": "connect.sid=test-session; Path=/; HttpOnly" }, + }); + } + if (u.endsWith("/api/v1/settings/main")) { + return new Response("{}", { status: 200 }); + } + if (u.endsWith("/api/v1/settings/jellyfin/library/sync")) { + if (librarySyncStatus !== 200) { + return new Response("not found", { status: librarySyncStatus }); + } + return new Response( + JSON.stringify([ + { id: "movies", name: "Movies", enabled: false, type: "movies" }, + { id: "tv", name: "TV Shows", enabled: false, type: "tvshows" }, + ]), + { status: 200 }, + ); + } + if (u.includes("/api/v1/settings/jellyfin/library?sync=true")) { + return new Response( + JSON.stringify([{ id: "movies", name: "Movies", enabled: false, type: "movies" }]), + { status: 200 }, + ); + } + if (u.includes("/api/v1/settings/jellyfin/library?enable=")) { + return new Response("{}", { status: 200 }); + } + if (u.includes("/api/v1/settings/jellyfin/library/")) { + return new Response("{}", { status: 200 }); + } + if (u.endsWith("/api/v1/settings/jellyfin/sync")) { + return new Response("{}", { status: 200 }); + } + if (u.endsWith("/api/v1/settings/initialize")) { + return new Response("{}", { status: 200 }); + } + return new Response("{}", { status: 200 }); + }) as any; + return { calls, authBodies, restore: () => { globalThis.fetch = origFetch; } }; +} + +test("linkJellyseerr uses the Seerr 3.x library sync and per-library enable API", async () => { + const mock = mockSeerr(200); + try { + await linkJellyseerr("admin", "pw", "http://seerr.test"); + } finally { + mock.restore(); + } + + const urls = mock.calls.map((c) => `${c.method} ${c.url}`); + expect(urls).toContain("POST http://seerr.test/api/v1/settings/jellyfin/library/sync"); + expect(urls).toContain("PUT http://seerr.test/api/v1/settings/jellyfin/library/movies"); + expect(urls).toContain("PUT http://seerr.test/api/v1/settings/jellyfin/library/tv"); + expect(urls.some((u) => u.includes("?sync=true"))).toBe(false); + expect(urls.some((u) => u.includes("?enable="))).toBe(false); + expect(urls.some((u) => u.startsWith("POST ") && u.endsWith("/settings/initialize"))).toBe(true); +}); + +test("linkJellyseerr logs in to an existing install when the hostname is already configured", async () => { + const mock = mockSeerr(200, true); + try { + await linkJellyseerr("admin", "pw", "http://seerr.test"); + } finally { + mock.restore(); + } + + expect(mock.authBodies.length).toBeGreaterThanOrEqual(2); + expect(mock.authBodies[0].hostname).toBe("jellyfin"); + expect(mock.authBodies[1].hostname).toBeUndefined(); +}); + +test("linkJellyseerr falls back to the legacy library API on 404", async () => { + const mock = mockSeerr(404); + try { + await linkJellyseerr("admin", "pw", "http://seerr.test"); + } finally { + mock.restore(); + } + + const urls = mock.calls.map((c) => `${c.method} ${c.url}`); + expect(urls).toContain("GET http://seerr.test/api/v1/settings/jellyfin/library?sync=true"); + expect(urls.some((u) => u.includes("library?enable="))).toBe(true); +}); From 1c58481313e35a5f69be97dd58f2159a69e4a169 Mon Sep 17 00:00:00 2001 From: Laszlo Toth Date: Mon, 19 Oct 2026 11:06:35 +0200 Subject: [PATCH 3/4] fix(compose,retry): survive socket closes and partial service sets Retry requests whose socket closes mid-flight (Jellyfin startup does this during configuration). Drop depends_on edges to services the user deselected instead of rendering a compose project Docker rejects with "depends on undefined service". --- src/lib/retry.ts | 7 +++++-- src/renderer/compose.ts | 10 +++++++++- tests/lib/retry.test.ts | 16 ++++++++++++++++ tests/renderer/compose.test.ts | 20 ++++++++++++++++++++ 4 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/lib/retry.ts b/src/lib/retry.ts index 03fbae5..5811a48 100644 --- a/src/lib/retry.ts +++ b/src/lib/retry.ts @@ -10,9 +10,12 @@ export interface RetryOpts { } function defaultShouldRetry(err: unknown): boolean { - // Undici throws TypeError/AggregateError on connection refused / socket hang up. + // Undici/Bun throw TypeError/AggregateError on connection refused, socket + // hang up, and sockets closed while a request is in flight. Jellyfin's + // Startup/Configuration restarts internal services and can close the socket + // mid-request, which is transient even though it is not ECONNREFUSED. const msg = err instanceof Error ? err.message : String(err); - return /ECONN|socket hang up|fetch failed|network|UND_ERR/i.test(msg); + return /ECONN|socket hang up|socket connection was closed|connection closed unexpectedly|fetch failed|network|UND_ERR/i.test(msg); } export async function withRetry( diff --git a/src/renderer/compose.ts b/src/renderer/compose.ts index 18979b7..09b187e 100644 --- a/src/renderer/compose.ts +++ b/src/renderer/compose.ts @@ -296,6 +296,12 @@ export function buildComposeContext(services: Service[], opts: ComposeOptions): ? services.filter((svc) => isVpnRouted(svc, opts.vpn)).flatMap((svc) => svc.ports) : []; + // Catalog dependsOn edges describe the full stack, but the wizard lets users + // deselect services. A dangling edge (Sonarr -> Prowlarr when Prowlarr is + // unchecked) makes `docker compose` reject the whole project with + // "depends on undefined service". Drop edges to services not being rendered. + const enabledIds = new Set(services.map((svc) => svc.id)); + const serviceContexts: ServiceContext[] = services.map((svc) => { const vpnNetwork = isVpnRouted(svc, opts.vpn); const apiKeyEnv = svc.apiKeyEnv; @@ -320,7 +326,9 @@ export function buildComposeContext(services: Service[], opts: ComposeOptions): // before they attach to its netns, otherwise a gluetun restart mid-boot // leaves a stale namespace path and `docker compose up` aborts with the // cryptic `lstat /proc//ns/net: no such file or directory`. - const dependsOn: DependsOnEntry[] = svc.dependsOn.map((service) => ({ service })); + const dependsOn: DependsOnEntry[] = svc.dependsOn + .filter((service) => enabledIds.has(service)) + .map((service) => ({ service })); if (vpnNetwork) { dependsOn.push({ service: "gluetun", condition: "service_healthy" }); } diff --git a/tests/lib/retry.test.ts b/tests/lib/retry.test.ts index a1d9a8c..539e365 100644 --- a/tests/lib/retry.test.ts +++ b/tests/lib/retry.test.ts @@ -20,6 +20,22 @@ test("withRetry retries on ECONNREFUSED-style errors", async () => { expect(calls).toBe(3); }); +test("withRetry retries when the socket closes mid-request (Jellyfin startup)", async () => { + let calls = 0; + const result = await withRetry( + async () => { + calls++; + if (calls < 3) { + throw new Error("The socket connection was closed unexpectedly. For more information, pass `verbose: true`"); + } + return "ok"; + }, + { baseMs: 1, attempts: 5 }, + ); + expect(result).toBe("ok"); + expect(calls).toBe(3); +}); + test("withRetry does not retry on non-transient errors", async () => { let calls = 0; await expect( diff --git a/tests/renderer/compose.test.ts b/tests/renderer/compose.test.ts index 885db14..707eda7 100644 --- a/tests/renderer/compose.test.ts +++ b/tests/renderer/compose.test.ts @@ -31,6 +31,26 @@ describe("renderCompose", () => { expect(output).toContain("8989:8989"); }); + test("drops depends_on edges to services that are not rendered", () => { + // Catalog says Sonarr dependsOn Prowlarr, but the wizard lets users deselect + // Prowlarr. A dangling edge makes `docker compose` reject the project with + // "depends on undefined service", so it must not be emitted. + const services = getServices(["sonarr"]); + const ctx = buildComposeContext(services, baseOpts); + const sonarr = ctx.services.find((s) => s.id === "sonarr")!; + expect(sonarr.dependsOn.map((d) => d.service)).not.toContain("prowlarr"); + + const output = renderCompose(services, baseOpts); + expect(output).not.toContain("- prowlarr"); + }); + + test("keeps depends_on edges when both services are rendered", () => { + const services = getServices(["sonarr", "prowlarr"]); + const ctx = buildComposeContext(services, baseOpts); + const sonarr = ctx.services.find((s) => s.id === "sonarr")!; + expect(sonarr.dependsOn.map((d) => d.service)).toContain("prowlarr"); + }); + test("logging block is present", () => { const services = getServices(["sonarr"]); const output = renderCompose(services, baseOpts); From 9985b539fd549fe6b4111d64316cc700ff636ae4 Mon Sep 17 00:00:00 2001 From: Laszlo Toth Date: Mon, 19 Oct 2026 11:06:50 +0200 Subject: [PATCH 4/4] chore(release): v1.2.2 --- package.json | 2 +- src/version.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 96a88c7..91bde06 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "arrstack-installer", - "version": "1.2.1", + "version": "1.2.2", "type": "module", "bin": { "arrstack": "src/cli.ts" diff --git a/src/version.ts b/src/version.ts index f92f80d..498d5d1 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = "1.2.1"; +export const VERSION = "1.2.2";