Repository navigation
feat: non-interactive recovery and current Jellyfin/Seerr support (v1.2.2) #8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1efe172
ff6593d
1c58481
9985b53
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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/<pid>/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 })); | ||
|
Comment on lines
+329
to
+331
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the independently toggleable Jellyseerr service remains selected but Jellyfin is deselected, this blanket filter removes Jellyseerr's declared Useful? React with 👍 / 👎. |
||
| if (vpnNetwork) { | ||
| dependsOn.push({ service: "gluetun", condition: "service_healthy" }); | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<InstallResult> { | ||
| 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, | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1 @@ | ||
| export const VERSION = "1.2.1"; | ||
| export const VERSION = "1.2.2"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,15 +12,24 @@ async function readBody(res: Response): Promise<string> { | |
| // 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<void> { | ||
| // 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<boolean> { | ||
|
|
@@ -46,13 +55,20 @@ async function authenticate( | |
| adminUser: string, | ||
| adminPass: string, | ||
| ): Promise<AuthResult> { | ||
| // 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(() => | ||
| fetch(`${base}/Users/AuthenticateByName`, { | ||
| 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) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
On every fresh Jellyfin installation, Useful? React with 👍 / 👎. |
||
| 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); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When an existing user runs
arrstack install --fresh --yeswithout--resume, the--freshblock has already stopped the stack and deletedstate.json,admin.txt,.env, and the entire serviceconfig/tree before this newly added validation exits with an error. Since--yesis rejected in this combination, validate it before performing any--freshside effects (and likewise reject--fresh --resume --yesbefore purging) to avoid destructive data loss from an invalid invocation.Useful? React with 👍 / 👎.