feat: add startup.completeStartupWizard config option

Adds support for completing the Jellyfin startup wizard via config.
When startup.completeStartupWizard is true, calls POST /Startup/Complete.

Closes #23
This commit is contained in:
Venkatesan Ravi
2025-11-30 07:16:15 -08:00
parent a94933460b
commit 8f8df1d092
10 changed files with 285 additions and 0 deletions

View File

@@ -290,6 +290,19 @@ users:
}
```
### Startup Configuration
```yaml
version: 1
base_url: "http://localhost:8096"
system: {}
startup:
completeStartupWizard: true # Mark startup wizard as complete
```
Useful for automated deployments where you want to skip the interactive startup
wizard.
---
## Secret Management
@@ -385,6 +398,8 @@ users:
policy:
isAdministrator: true
loginAttemptsBeforeLockout: 3
startup:
completeStartupWizard: true
```
---

View File

@@ -47,3 +47,5 @@ users:
loginAttemptsBeforeLockout: 3
- name: "test-jellarr-3"
password: "plainpass"
startup:
completeStartupWizard: true

View File

@@ -30,6 +30,7 @@ export type PostBrandingConfigurationResponse = ApiResponse<void>;
export type GetUsersResponse = ApiResponse<UserDtoSchema[]>;
export type PostNewUserResponse = ApiResponse<UserDtoSchema>;
export type PostUserPolicyResponse = ApiResponse<void>;
export type PostStartupCompleteResponse = ApiResponse<void>;
export interface JellyfinClient {
getSystemConfiguration(): Promise<ServerConfigurationSchema>;
@@ -53,4 +54,5 @@ export interface JellyfinClient {
getUsers(): Promise<UserDtoSchema[]>;
createUser(body: CreateUserByNameSchema): Promise<void>;
updateUserPolicy(userId: string, body: UserPolicySchema): Promise<void>;
completeStartupWizard(): Promise<void>;
}

View File

@@ -24,6 +24,7 @@ import type {
GetUsersResponse,
PostNewUserResponse,
PostUserPolicyResponse,
PostStartupCompleteResponse,
} from "./jellyfin.types";
import { makeClient } from "./client";
import type { paths } from "../../generated/schema";
@@ -224,5 +225,16 @@ export function createJellyfinClient(
);
}
},
async completeStartupWizard(): Promise<void> {
const res: PostStartupCompleteResponse =
await client.POST("/Startup/Complete");
if (res.error) {
throw new Error(
`POST /Startup/Complete failed: ${res.response.status.toString()}`,
);
}
},
};
}

View File

@@ -141,4 +141,10 @@ export async function runPipeline(path: string): Promise<void> {
console.log("✓ user policies already up to date");
}
}
if (cfg.startup?.completeStartupWizard) {
console.log("→ marking startup wizard as complete");
await jellyfinClient.completeStartupWizard();
console.log("✓ marked startup wizard as complete");
}
}

View File

@@ -4,6 +4,7 @@ import { EncodingOptionsConfigType } from "./encoding-options";
import { LibraryConfigType } from "./library";
import { BrandingOptionsConfigType } from "./branding-options";
import { UserConfigListType } from "./users";
import { StartupConfigType } from "./startup";
export const RootConfigType: z.ZodObject<{
version: z.ZodNumber;
@@ -13,6 +14,7 @@ export const RootConfigType: z.ZodObject<{
library: z.ZodOptional<typeof LibraryConfigType>;
branding: z.ZodOptional<typeof BrandingOptionsConfigType>;
users: z.ZodOptional<typeof UserConfigListType>;
startup: z.ZodOptional<typeof StartupConfigType>;
}> = z
.object({
version: z.number().int().positive("Version must be a positive integer"),
@@ -22,6 +24,7 @@ export const RootConfigType: z.ZodObject<{
library: LibraryConfigType.optional(),
branding: BrandingOptionsConfigType.optional(),
users: UserConfigListType.optional(),
startup: StartupConfigType.optional(),
})
.strict();

View File

@@ -0,0 +1,11 @@
import { z } from "zod";
export const StartupConfigType: z.ZodObject<{
completeStartupWizard: z.ZodOptional<z.ZodBoolean>;
}> = z
.object({
completeStartupWizard: z.boolean().optional(),
})
.strict();
export type StartupConfig = z.infer<typeof StartupConfigType>;

View File

@@ -682,3 +682,50 @@ describe("api/jf Users façade", () => {
).rejects.toThrow(/POST \/Users\/\{userId\}\/Policy failed/i);
});
});
describe("api/jf Startup façade", () => {
beforeEach((): void => {
vi.restoreAllMocks();
vi.clearAllMocks();
});
afterEach((): void => {
vi.restoreAllMocks();
});
it("when POST /Startup/Complete succeeds then it resolves", async (): Promise<void> => {
// Arrange
mockFetchNoContent(204);
// Act
const jellyfinClient: JellyfinClient = createJellyfinClient(
baseUrl,
apiKey,
);
await jellyfinClient.completeStartupWizard();
// Assert
const req: Request = getLastRequest();
expect(req.method).toBe("POST");
expect(req.url).toMatch(/\/Startup\/Complete$/);
expect(req.headers.get("X-Emby-Token")).toBe(apiKey);
});
it("when POST /Startup/Complete fails then it throws an error with status", async (): Promise<void> => {
// Arrange
fetchMock = vi
.spyOn(global, "fetch")
.mockResolvedValue(new Response("boom", { status: 500 }));
// Act
const jellyfinClient: JellyfinClient = createJellyfinClient(
baseUrl,
apiKey,
);
// Assert
await expect(jellyfinClient.completeStartupWizard()).rejects.toThrow(
/POST \/Startup\/Complete failed/i,
);
});
});

View File

@@ -567,4 +567,90 @@ describe("RootConfig", () => {
expect(strictError?.code).toBe("unrecognized_keys");
}
});
it("should validate root config with startup section", () => {
// Arrange
const validConfig: z.input<typeof RootConfigType> = {
version: 1,
base_url: "http://10.0.0.76:8096",
system: {},
startup: {
completeStartupWizard: true,
},
};
// Act
const result: ZodSafeParseResult<RootConfig> =
RootConfigType.safeParse(validConfig);
// Assert
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.startup).toBeDefined();
expect(result.data.startup?.completeStartupWizard).toBe(true);
}
});
it("should validate root config with empty startup section", () => {
// Arrange
const validConfig: z.input<typeof RootConfigType> = {
version: 1,
base_url: "http://10.0.0.76:8096",
system: {},
startup: {},
};
// Act
const result: ZodSafeParseResult<RootConfig> =
RootConfigType.safeParse(validConfig);
// Assert
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.startup).toBeDefined();
}
});
it("should validate root config without startup section", () => {
// Arrange
const validConfig: z.input<typeof RootConfigType> = {
version: 1,
base_url: "http://10.0.0.76:8096",
system: {},
};
// Act
const result: ZodSafeParseResult<RootConfig> =
RootConfigType.safeParse(validConfig);
// Assert
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.startup).toBeUndefined();
}
});
it("should reject invalid startup configuration", () => {
// Arrange
const invalidConfig: z.input<typeof RootConfigType> = {
version: 1,
base_url: "http://10.0.0.76:8096",
system: {},
startup: {
// @ts-expect-error intentional invalid field for test
invalidField: "not allowed",
},
};
// Act
const result: ZodSafeParseResult<RootConfig> =
RootConfigType.safeParse(invalidConfig);
// Assert
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues).toBeDefined();
expect(result.error.issues.length).toBeGreaterThan(0);
}
});
});

View File

@@ -0,0 +1,101 @@
import { describe, it, expect } from "vitest";
import type { ZodSafeParseResult } from "zod";
import { type z } from "zod";
import {
StartupConfigType,
type StartupConfig,
} from "../../../src/types/config/startup";
describe("StartupConfig", () => {
it("should validate startup config with completeStartupWizard true", () => {
// Arrange
const validConfig: z.input<typeof StartupConfigType> = {
completeStartupWizard: true,
};
// Act
const result: ZodSafeParseResult<StartupConfig> =
StartupConfigType.safeParse(validConfig);
// Assert
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.completeStartupWizard).toBe(true);
}
});
it("should validate startup config with completeStartupWizard false", () => {
// Arrange
const validConfig: z.input<typeof StartupConfigType> = {
completeStartupWizard: false,
};
// Act
const result: ZodSafeParseResult<StartupConfig> =
StartupConfigType.safeParse(validConfig);
// Assert
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.completeStartupWizard).toBe(false);
}
});
it("should reject invalid completeStartupWizard value", () => {
// Arrange
const invalidConfig: z.input<typeof StartupConfigType> = {
// @ts-expect-error intentional invalid value for test
completeStartupWizard: "invalid",
};
// Act
const result: ZodSafeParseResult<StartupConfig> =
StartupConfigType.safeParse(invalidConfig);
// Assert
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues).toBeDefined();
expect(result.error.issues.length).toBeGreaterThan(0);
}
});
it("should validate empty startup config", () => {
// Arrange
const validConfig: z.input<typeof StartupConfigType> = {};
// Act
const result: ZodSafeParseResult<StartupConfig> =
StartupConfigType.safeParse(validConfig);
// Assert
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.completeStartupWizard).toBeUndefined();
}
});
it("should reject extra fields due to strict mode", () => {
// Arrange
const invalidConfig: z.input<typeof StartupConfigType> = {
completeStartupWizard: true,
// @ts-expect-error intentional extra field for test
extraField: "not allowed",
};
// Act
const result: ZodSafeParseResult<StartupConfig> =
StartupConfigType.safeParse(invalidConfig);
// Assert
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues).toBeDefined();
const strictError: z.core.$ZodIssue | undefined =
result.error.issues.find(
(err: z.core.$ZodIssue) => err.code === "unrecognized_keys",
);
expect(strictError?.code).toBe("unrecognized_keys");
}
});
});