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.2.2",
"version": "1.2.3",
"type": "module",
"bin": {
"arrstack": "src/cli.ts"
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.2.2";
export const VERSION = "1.2.3";
42 changes: 17 additions & 25 deletions src/wiring/jellyfin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,16 @@ 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.
// 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<void> {
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<boolean> {
Expand Down Expand Up @@ -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)}`);
}
}

Expand Down
46 changes: 43 additions & 3 deletions tests/wiring/jellyfin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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 });
Expand All @@ -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.
Expand Down
Loading