From a5f4bfb5293430e34ba11bbf157b85ca403781c6 Mon Sep 17 00:00:00 2001 From: Laszlo Toth Date: Mon, 19 Oct 2026 11:20:37 +0200 Subject: [PATCH] fix(jellyfin): set admin credentials on a fresh Jellyfin 12 install Jellyfin 12 creates the default first user (root) on GET /Startup/User instead of POST. The previous skip-if-a-user-exists check therefore left root without a password and AuthenticateByName returned 401. Always POST Startup/User to rename the first user and set the password; tolerate 403 when a previous attempt already set one. v1.2.3. --- package.json | 2 +- src/version.ts | 2 +- src/wiring/jellyfin.ts | 42 +++++++++++++------------------- tests/wiring/jellyfin.test.ts | 46 ++++++++++++++++++++++++++++++++--- 4 files changed, 62 insertions(+), 30 deletions(-) diff --git a/package.json b/package.json index 91bde06..b39b0a5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "arrstack-installer", - "version": "1.2.2", + "version": "1.2.3", "type": "module", "bin": { "arrstack": "src/cli.ts" diff --git a/src/version.ts b/src/version.ts index 498d5d1..e28b13d 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = "1.2.2"; +export const VERSION = "1.2.3"; diff --git a/src/wiring/jellyfin.ts b/src/wiring/jellyfin.ts index 574dd06..66a2abd 100644 --- a/src/wiring/jellyfin.ts +++ b/src/wiring/jellyfin.ts @@ -12,24 +12,16 @@ 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. -// 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> { +// On Jellyfin 12 this GET also creates the default first user ("root"). +async function waitForJellyfinStartupReady(base: string, timeoutMs = 60_000): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { try { const r = await fetch(`${base}/Startup/User`); - if (r.ok) { - return (await r.json().catch(() => null)) as { Name?: string } | null; - } + if (r.ok) return; } catch { /* retry */ } await new Promise((r) => setTimeout(r, 1500)); } - return null; } async function isStartupComplete(base: string): Promise { @@ -136,20 +128,20 @@ export async function setupJellyfin( throw new Error(`Startup/Configuration failed: HTTP ${configRes.status}\n${await readBody(configRes)}`); } - 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)}`); - } + await waitForJellyfinStartupReady(base); + + // Jellyfin 12 creates a default first user ("root") on the GET above, so + // this POST is what renames it and sets the admin password. It returns 403 + // when the first user already has a password (a previous attempt or a + // partly completed wizard), which is fine: authenticate below will use the + // stored credentials. + const userRes = await fetchWithRetry(`${base}/Startup/User`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ Name: adminUser, Password: adminPass }), + }); + if (!userRes.ok && userRes.status !== 403) { + throw new Error(`Startup/User failed: HTTP ${userRes.status}\n${await readBody(userRes)}`); } } diff --git a/tests/wiring/jellyfin.test.ts b/tests/wiring/jellyfin.test.ts index da47bc8..3ba55ec 100644 --- a/tests/wiring/jellyfin.test.ts +++ b/tests/wiring/jellyfin.test.ts @@ -10,7 +10,7 @@ test("setupJellyfin is exported and is async", () => { result.catch(() => {}); }); -test("setupJellyfin skips Startup/User when a previous attempt created the user", async () => { +test("setupJellyfin tolerates 403 from Startup/User when the first user already has a password", async () => { const origFetch = globalThis.fetch; const calls: string[] = []; globalThis.fetch = (async (url: any, init: any) => { @@ -21,7 +21,7 @@ test("setupJellyfin skips Startup/User when a previous attempt created the user" 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 }); + return new Response(JSON.stringify({ Name: "root" }), { status: 200 }); } if (u.endsWith("/Startup/User") && method === "POST") { return new Response("first user exists", { status: 403 }); @@ -47,10 +47,50 @@ test("setupJellyfin skips Startup/User when a previous attempt created the user" globalThis.fetch = origFetch; } - expect(calls).not.toContain("POST http://jellyfin.test/Startup/User"); + expect(calls).toContain("POST http://jellyfin.test/Startup/User"); expect(calls).toContain("POST http://jellyfin.test/Startup/Complete"); }); +test("setupJellyfin renames the default first user on a fresh install", async () => { + const origFetch = globalThis.fetch; + let startupUserBody: any = null; + globalThis.fetch = (async (url: any, init: any) => { + const u = String(url); + const method = init?.method ?? "GET"; + 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: "root" }), { status: 200 }); + } + if (u.endsWith("/Startup/User") && method === "POST") { + startupUserBody = JSON.parse(init.body); + return new Response("", { status: 204 }); + } + 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(startupUserBody).toEqual({ Name: "admin", Password: "pw" }); +}); + // 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.