diff --git a/web/index.html b/web/index.html
index 6aab230b2..c8a9bb7c6 100644
--- a/web/index.html
+++ b/web/index.html
@@ -4,6 +4,21 @@
ChicoryTV
+
diff --git a/web/scripts/generate-openapi-types.mjs b/web/scripts/generate-openapi-types.mjs
index cb8d7478a..7bc7ad41c 100644
--- a/web/scripts/generate-openapi-types.mjs
+++ b/web/scripts/generate-openapi-types.mjs
@@ -1,6 +1,7 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
-import { fileURLToPath } from 'node:url';
+import process from 'node:process';
+import { fileURLToPath, pathToFileURL } from 'node:url';
const scriptDir = dirname(fileURLToPath(import.meta.url));
const webRoot = resolve(scriptDir, '..');
@@ -8,9 +9,6 @@ const repoRoot = resolve(webRoot, '..');
const inputPath = resolve(repoRoot, 'ErsatzTV/wwwroot/openapi/v1.json');
const outputPath = resolve(webRoot, 'src/api/generated/v1.d.ts');
-const document = JSON.parse(await readFile(inputPath, 'utf8'));
-const schemas = document.components?.schemas ?? {};
-
function schemaNameFromRef(ref) {
return ref.replace('#/components/schemas/', '');
}
@@ -92,52 +90,34 @@ function objectTypeFromSchema(schema) {
return lines.join('\n');
}
-function operationType(operation) {
- if (!operation) {
- return 'never';
+export function generateTypes(document) {
+ const schemas = document.components?.schemas ?? {};
+ const lines = [
+ '// This file is generated by web/scripts/generate-openapi-types.mjs.',
+ '// Do not edit by hand.',
+ '',
+ 'export interface components {',
+ ' schemas: {'
+ ];
+
+ for (const [name, schema] of Object.entries(schemas).sort(([a], [b]) => a.localeCompare(b))) {
+ lines.push(`${formatDescription(schema.description, ' ')} ${JSON.stringify(name)}: ${typeFromSchema(schema)};`);
}
- const responses = operation.responses ?? {};
- const successResponse = responses['200'] ?? responses['201'] ?? responses['204'];
- const jsonContent = successResponse?.content?.['application/json']
- ?? successResponse?.content?.['text/json']
- ?? successResponse?.content?.['text/plain'];
+ lines.push(' };');
+ lines.push('}');
+ lines.push('');
- if (!jsonContent?.schema) {
- return 'void';
- }
-
- return typeFromSchema(jsonContent.schema);
+ return `${lines.join('\n')}\n`;
}
-const lines = [
- '// This file is generated by web/scripts/generate-openapi-types.mjs.',
- '// Do not edit by hand.',
- '',
- 'export interface components {',
- ' schemas: {'
-];
+async function main() {
+ const document = JSON.parse(await readFile(inputPath, 'utf8'));
-for (const [name, schema] of Object.entries(schemas).sort(([a], [b]) => a.localeCompare(b))) {
- lines.push(`${formatDescription(schema.description, ' ')} ${JSON.stringify(name)}: ${typeFromSchema(schema)};`);
+ await mkdir(dirname(outputPath), { recursive: true });
+ await writeFile(outputPath, generateTypes(document));
}
-lines.push(' };');
-lines.push('}');
-lines.push('');
-lines.push('export interface operations {');
-
-for (const [path, methods] of Object.entries(document.paths ?? {}).sort(([a], [b]) => a.localeCompare(b))) {
- for (const [method, operation] of Object.entries(methods).sort(([a], [b]) => a.localeCompare(b))) {
- const name = operation.operationId ?? `${method.toUpperCase()} ${path}`;
- lines.push(` ${JSON.stringify(name)}: {`);
- lines.push(` response: ${operationType(operation)};`);
- lines.push(' };');
- }
+if (import.meta.url === pathToFileURL(process.argv[1]).href) {
+ await main();
}
-
-lines.push('}');
-lines.push('');
-
-await mkdir(dirname(outputPath), { recursive: true });
-await writeFile(outputPath, `${lines.join('\n')}\n`);
diff --git a/web/scripts/generate-openapi-types.test.mjs b/web/scripts/generate-openapi-types.test.mjs
new file mode 100644
index 000000000..2f9fd25e8
--- /dev/null
+++ b/web/scripts/generate-openapi-types.test.mjs
@@ -0,0 +1,47 @@
+import { describe, expect, it } from 'vitest';
+import { generateTypes } from './generate-openapi-types.mjs';
+
+describe('generate-openapi-types', () => {
+ it('emits only schemas and ignores non-operation path item keys', () => {
+ const output = generateTypes({
+ components: {
+ schemas: {
+ ProblemDetails: {
+ properties: {
+ detail: { type: 'string' }
+ },
+ type: 'object'
+ }
+ }
+ },
+ paths: {
+ '/api/items/{id}': {
+ parameters: [
+ {
+ in: 'path',
+ name: 'id',
+ schema: { type: 'integer' }
+ }
+ ],
+ get: {
+ operationId: 'GetItem',
+ responses: {
+ 200: {
+ content: {
+ 'application/json': {
+ schema: { $ref: '#/components/schemas/ProblemDetails' }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ });
+
+ expect(output).toContain('export interface components');
+ expect(output).toContain('"ProblemDetails"');
+ expect(output).not.toContain('export interface operations');
+ expect(output).not.toContain('"parameters"');
+ });
+});
diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx
index 76541c121..d284d47ad 100644
--- a/web/src/App.test.tsx
+++ b/web/src/App.test.tsx
@@ -1,7 +1,7 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { App } from './App';
-import { Button, Checkbox, Input, ProgressBar, Switch, Tabs, Tooltip } from './components';
+import { Button, Checkbox, Input, ProgressBar, Switch, Tabs, Toast, Tooltip } from './components';
import {
applyDesignSystemTheme,
designSystemStylesheet,
@@ -282,6 +282,18 @@ describe('ChicoryTV SPA scaffold', () => {
fireEvent.mouseEnter(screen.getByText('Reset'));
expect(screen.getByRole('tooltip')).toHaveTextContent('Reset playout');
});
+
+ it('uses distinct semantic icons for warning and error toasts', () => {
+ const { container } = render(
+ <>
+
+
+ >
+ );
+
+ expect(container.querySelector('.lucide-triangle-alert')).toBeInTheDocument();
+ expect(container.querySelector('.lucide-circle-x')).toBeInTheDocument();
+ });
});
function jsonResponse(body: unknown, status = 200): Response {
diff --git a/web/src/api/auth.test.ts b/web/src/api/auth.test.ts
index b3532bb28..df1b23d91 100644
--- a/web/src/api/auth.test.ts
+++ b/web/src/api/auth.test.ts
@@ -1,4 +1,4 @@
-import { beforeEach, describe, expect, it } from 'vitest';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
import { clearStoredApiKey, getStoredApiKey, setStoredApiKey } from './auth';
describe('API key storage', () => {
@@ -27,4 +27,16 @@ describe('API key storage', () => {
expect(getStoredApiKey()).toBeNull();
});
+
+ it('ignores unavailable local storage when reading the API key', () => {
+ const localStorageGetter = vi
+ .spyOn(window, 'localStorage', 'get')
+ .mockImplementation(() => {
+ throw new Error('localStorage unavailable');
+ });
+
+ expect(getStoredApiKey()).toBeNull();
+
+ localStorageGetter.mockRestore();
+ });
});
diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts
index 95f1fed7b..9cb28502e 100644
--- a/web/src/api/auth.ts
+++ b/web/src/api/auth.ts
@@ -1,7 +1,19 @@
const apiKeyStorageKey = 'ctv-api-key';
+function getStorage(): Storage | undefined {
+ if (typeof window === 'undefined') {
+ return undefined;
+ }
+
+ try {
+ return window.localStorage;
+ } catch {
+ return undefined;
+ }
+}
+
export function getStoredApiKey(): string | null {
- const value = window.localStorage.getItem(apiKeyStorageKey);
+ const value = getStorage()?.getItem(apiKeyStorageKey);
return value && value.trim().length > 0 ? value : null;
}
@@ -13,9 +25,9 @@ export function setStoredApiKey(apiKey: string): void {
return;
}
- window.localStorage.setItem(apiKeyStorageKey, trimmed);
+ getStorage()?.setItem(apiKeyStorageKey, trimmed);
}
export function clearStoredApiKey(): void {
- window.localStorage.removeItem(apiKeyStorageKey);
+ getStorage()?.removeItem(apiKeyStorageKey);
}
diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts
index 39bc31a74..f0c6d1dd9 100644
--- a/web/src/api/client.test.ts
+++ b/web/src/api/client.test.ts
@@ -56,4 +56,42 @@ describe('API request client', () => {
status: 422
} satisfies Partial);
});
+
+ it('returns undefined for successful responses without a JSON body', async () => {
+ vi.spyOn(window, 'fetch').mockResolvedValue(new Response(null, { status: 200 }));
+
+ await expect(request('/api/channels', { method: 'POST' })).resolves.toBeUndefined();
+ });
+
+ it('reads problem details from application/problem+json responses', async () => {
+ vi.spyOn(window, 'fetch').mockResolvedValue(
+ new Response(JSON.stringify({ status: 401, title: 'Unauthorized', detail: 'A valid API key is required for write requests.' }), {
+ headers: { 'Content-Type': 'application/problem+json' },
+ status: 401
+ })
+ );
+
+ await expect(request('/api/channels', { method: 'POST' })).rejects.toMatchObject({
+ detail: 'A valid API key is required for write requests.',
+ message: 'Unauthorized',
+ status: 401
+ } satisfies Partial);
+ });
+
+ it('does not add a duplicate JSON content type when callers provide lowercase content-type', async () => {
+ const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(new Response(null, { status: 204 }));
+
+ await request('/api/channels', {
+ body: { name: 'News' },
+ headers: { 'content-type': 'application/merge-patch+json' },
+ method: 'PATCH'
+ });
+
+ expect(fetchMock).toHaveBeenCalledWith(
+ '/api/channels',
+ expect.objectContaining({
+ headers: expect.not.objectContaining({ 'Content-Type': 'application/json' })
+ })
+ );
+ });
});
diff --git a/web/src/api/client.ts b/web/src/api/client.ts
index 1a0069fbd..b9e3e94b8 100644
--- a/web/src/api/client.ts
+++ b/web/src/api/client.ts
@@ -29,10 +29,10 @@ export async function request(
options: ApiRequestOptions = {}
): Promise {
const method = (options.method ?? 'GET').toUpperCase();
- const headers: Record = {
+ const headers = normalizeHeaders({
Accept: 'application/json',
...headersToRecord(options.headers)
- };
+ });
const body = serializeBody(options.body, headers);
const apiKey = getStoredApiKey();
@@ -56,7 +56,21 @@ export async function request(
return undefined as TResponse;
}
- return await response.json() as TResponse;
+ return await readJsonResponse(response) as TResponse;
+}
+
+function normalizeHeaders(headers: Record): Record {
+ return Object.fromEntries(
+ Object.entries(headers).map(([key, value]) => [canonicalHeaderName(key), value])
+ );
+}
+
+function canonicalHeaderName(header: string): string {
+ return header
+ .toLowerCase()
+ .split('-')
+ .map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`)
+ .join('-');
}
function headersToRecord(headers?: HeadersInit): Record {
@@ -95,9 +109,32 @@ function serializeBody(body: RequestBody | undefined, headers: Record {
const contentType = response.headers.get('Content-Type') ?? '';
- if (!contentType.includes('application/json')) {
+ if (!isJsonContentType(contentType)) {
return undefined;
}
return await response.json() as ProblemDetails;
}
+
+async function readJsonResponse(response: Response): Promise {
+ const contentLength = response.headers.get('Content-Length');
+ const contentType = response.headers.get('Content-Type') ?? '';
+
+ if (contentLength === '0' || !isJsonContentType(contentType)) {
+ return undefined;
+ }
+
+ try {
+ return await response.json();
+ } catch (error) {
+ if (error instanceof SyntaxError) {
+ return undefined;
+ }
+
+ throw error;
+ }
+}
+
+function isJsonContentType(contentType: string): boolean {
+ return contentType.includes('application/json') || contentType.includes('problem+json');
+}
diff --git a/web/src/api/generated/v1.d.ts b/web/src/api/generated/v1.d.ts
index b9dab2ac7..135ff5c75 100644
--- a/web/src/api/generated/v1.d.ts
+++ b/web/src/api/generated/v1.d.ts
@@ -515,138 +515,3 @@ export interface components {
};
}
-export interface operations {
- "GET /api/channels": {
- response: Array;
- };
- "POST /api/channels": {
- response: components["schemas"]["ChannelViewModel"];
- };
- "POST /api/channels/{channelNumber}/playout/reset": {
- response: void;
- };
- "DELETE /api/channels/{id}": {
- response: void;
- };
- "GetChannelById": {
- response: components["schemas"]["ChannelViewModel"];
- };
- "PUT /api/channels/{id}": {
- response: components["schemas"]["ChannelViewModel"];
- };
- "GET /api/collections": {
- response: Array;
- };
- "POST /api/collections": {
- response: components["schemas"]["MediaCollectionViewModel"];
- };
- "DELETE /api/collections/{id}": {
- response: void;
- };
- "GetCollectionById": {
- response: components["schemas"]["MediaCollectionViewModel"];
- };
- "PUT /api/collections/{id}": {
- response: components["schemas"]["MediaCollectionViewModel"];
- };
- "POST /api/collections/{id}/items": {
- response: void;
- };
- "DELETE /api/collections/{id}/items/{mediaItemId}": {
- response: void;
- };
- "GetFFmpegProfiles": {
- response: Array;
- };
- "CreateFFmpegProfile": {
- response: components["schemas"]["FFmpegFullProfileResponseModel"];
- };
- "DeleteFFmpegProfile": {
- response: void;
- };
- "GetFFmpegProfileById": {
- response: components["schemas"]["FFmpegFullProfileResponseModel"];
- };
- "UpdateFFmpegProfile": {
- response: components["schemas"]["FFmpegFullProfileResponseModel"];
- };
- "GetResolutionByName": {
- response: components["schemas"]["ResolutionViewModel"];
- };
- "POST /api/libraries/{id}/scan": {
- response: void;
- };
- "POST /api/libraries/{id}/scan-show": {
- response: void;
- };
- "POST /api/maintenance/clean_artwork": {
- response: void;
- };
- "POST /api/maintenance/empty_trash": {
- response: void;
- };
- "GET /api/maintenance/gc": {
- response: void;
- };
- "POST /api/playouts": {
- response: components["schemas"]["PlayoutResponseModel"];
- };
- "DELETE /api/playouts/{id}": {
- response: void;
- };
- "GetPlayoutById": {
- response: components["schemas"]["PlayoutResponseModel"];
- };
- "GET /api/schedules": {
- response: Array;
- };
- "POST /api/schedules": {
- response: components["schemas"]["ProgramScheduleViewModel"];
- };
- "DELETE /api/schedules/{id}": {
- response: void;
- };
- "GetScheduleById": {
- response: components["schemas"]["ProgramScheduleViewModel"];
- };
- "PUT /api/schedules/{id}": {
- response: components["schemas"]["ProgramScheduleViewModel"];
- };
- "GET /api/schedules/{id}/items": {
- response: Array;
- };
- "POST /api/schedules/{id}/items": {
- response: components["schemas"]["ProgramScheduleItemViewModel"];
- };
- "PUT /api/schedules/{id}/items": {
- response: Array;
- };
- "DELETE /api/schedules/{id}/items/{itemId}": {
- response: void;
- };
- "DELETE /api/session/{channelNumber}": {
- response: void;
- };
- "GET /api/sessions": {
- response: Array;
- };
- "GET /api/smart-collections": {
- response: Array;
- };
- "POST /api/smart-collections": {
- response: components["schemas"]["SmartCollectionViewModel"];
- };
- "DELETE /api/smart-collections/{id}": {
- response: void;
- };
- "GetSmartCollectionById": {
- response: components["schemas"]["SmartCollectionViewModel"];
- };
- "PUT /api/smart-collections/{id}": {
- response: components["schemas"]["SmartCollectionViewModel"];
- };
- "GetVersion": {
- response: components["schemas"]["CombinedVersion"];
- };
-}
-
diff --git a/web/src/components/feedback.tsx b/web/src/components/feedback.tsx
index 7fcf98015..cf67ca7bb 100644
--- a/web/src/components/feedback.tsx
+++ b/web/src/components/feedback.tsx
@@ -1,5 +1,5 @@
import { useState, type CSSProperties, type ReactNode } from 'react';
-import { Check, Info, X } from 'lucide-react';
+import { Check, CircleX, Info, TriangleAlert, X } from 'lucide-react';
function classNames(...values: Array): string {
return values.filter(Boolean).join(' ');
@@ -47,7 +47,12 @@ const toastStyle: Record, { color: string; bg: s
export function Toast({ tone = 'info', title, message, onClose, style }: ToastProps) {
const toneStyle = toastStyle[tone];
- const Icon = tone === 'ok' ? Check : Info;
+ const Icon = {
+ error: CircleX,
+ info: Info,
+ ok: Check,
+ warn: TriangleAlert
+ }[tone];
return (