@@ -61,6 +61,10 @@ jobs:
|
||||
working-directory: web
|
||||
run: npm ci
|
||||
|
||||
- name: Check generated SPA API client
|
||||
working-directory: web
|
||||
run: npm run check:api
|
||||
|
||||
- name: Lint SPA
|
||||
working-directory: web
|
||||
run: npm run lint
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# ChicoryTV SPA Typed API Client Auth Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build issue #81: generate typed API types from the ErsatzTV OpenAPI document, add API-key aware fetch/data helpers, and render a live channel-list smoke screen.
|
||||
|
||||
**Architecture:** Generate TypeScript schema types from `ErsatzTV/wwwroot/openapi/v1.json` into `web/src/api/generated/`. Keep hand-written runtime behavior in small files under `web/src/api/`: API-key storage, typed fetch/error handling, and a channel query hook. `App.tsx` consumes the hook and renders loading, error, empty, and data states without changing the ASP.NET API surface.
|
||||
|
||||
**Tech Stack:** Vite, React 19, TypeScript, Vitest, Testing Library, local OpenAPI JSON generator script, browser `fetch`, `localStorage`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: API Generation Wiring
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/package.json`
|
||||
- Modify: `.gitea/workflows/docker-build.yml`
|
||||
- Create: `web/scripts/generate-openapi-types.mjs`
|
||||
- Create: `web/src/api/generated/v1.d.ts`
|
||||
|
||||
- [x] Add a local OpenAPI generator script and package scripts:
|
||||
- `generate:api`: `node scripts/generate-openapi-types.mjs`
|
||||
- `check:api`: `npm run generate:api && git diff --exit-code -- src/api/generated/v1.d.ts`
|
||||
- [x] Add `npm run check:api` to the existing SPA CI workflow.
|
||||
- [x] Run `cd web && npm run generate:api`.
|
||||
- [x] Run `cd web && npm run check:api`.
|
||||
|
||||
### Task 2: API Runtime and Auth Tests
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/api/client.test.ts`
|
||||
- Create: `web/src/api/auth.test.ts`
|
||||
|
||||
- [x] Write failing tests proving:
|
||||
- API key storage reads/writes/clears `ctv-api-key`.
|
||||
- Mutating requests include `X-Api-Key`.
|
||||
- Read requests do not include `X-Api-Key`.
|
||||
- Non-OK API responses throw an `ApiError` with status and problem details.
|
||||
- [x] Run `cd web && npm test -- --run src/api/client.test.ts src/api/auth.test.ts` and confirm the tests fail because the modules do not exist.
|
||||
|
||||
### Task 3: API Runtime and Auth Implementation
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/api/auth.ts`
|
||||
- Create: `web/src/api/client.ts`
|
||||
- Create: `web/src/api/channels.ts`
|
||||
- Create: `web/src/api/index.ts`
|
||||
|
||||
- [x] Implement `getStoredApiKey`, `setStoredApiKey`, and `clearStoredApiKey`.
|
||||
- [x] Implement `ApiError`, typed `request`, and `getChannels`.
|
||||
- [x] Run `cd web && npm test -- --run src/api/client.test.ts src/api/auth.test.ts` and confirm the new tests pass.
|
||||
|
||||
### Task 4: Data-Fetching Hook and Smoke Screen
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/api/useChannelsQuery.ts`
|
||||
- Modify: `web/src/App.test.tsx`
|
||||
- Modify: `web/src/App.tsx`
|
||||
- Modify: `web/src/shell.css`
|
||||
|
||||
- [x] Write failing tests for loading, error, empty, and live channel-list states.
|
||||
- [x] Run `cd web && npm test -- --run src/App.test.tsx` and confirm the smoke-screen tests fail before implementation.
|
||||
- [x] Implement `useChannelsQuery` and render the channel smoke panel in `App.tsx`.
|
||||
- [x] Run `cd web && npm test -- --run src/App.test.tsx` and confirm the tests pass.
|
||||
|
||||
### Task 5: Verification and Completion
|
||||
|
||||
**Files:**
|
||||
- Modify as needed from previous tasks.
|
||||
|
||||
- [x] Run `cd web && npm test -- --run`.
|
||||
- [x] Run `cd web && npm run lint`.
|
||||
- [x] Run `cd web && npm run typecheck`.
|
||||
- [x] Run `cd web && npm run build`.
|
||||
- [x] Run `git status --short`.
|
||||
- [ ] Commit and push with a message referencing `#81`.
|
||||
- [ ] Use the `done` skill for issue `#81`.
|
||||
@@ -6,6 +6,8 @@
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "tsc -b && vite build",
|
||||
"generate:api": "node scripts/generate-openapi-types.mjs",
|
||||
"check:api": "npm run generate:api && git diff --exit-code -- src/api/generated/v1.d.ts",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest",
|
||||
"typecheck": "tsc -b --pretty false"
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const webRoot = resolve(scriptDir, '..');
|
||||
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/', '');
|
||||
}
|
||||
|
||||
function formatDescription(description, indent = '') {
|
||||
if (!description) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `${indent}/** ${String(description).replaceAll('*/', '* /')} */\n`;
|
||||
}
|
||||
|
||||
function typeFromSchema(schema) {
|
||||
if (!schema) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
if (schema.$ref) {
|
||||
return `components["schemas"]["${schemaNameFromRef(schema.$ref)}"]`;
|
||||
}
|
||||
|
||||
if (schema.oneOf || schema.anyOf) {
|
||||
return (schema.oneOf ?? schema.anyOf).map(typeFromSchema).join(' | ');
|
||||
}
|
||||
|
||||
if (schema.allOf) {
|
||||
return schema.allOf.map(typeFromSchema).join(' & ');
|
||||
}
|
||||
|
||||
if (Array.isArray(schema.enum)) {
|
||||
return schema.enum.map((value) => JSON.stringify(value)).join(' | ') || 'never';
|
||||
}
|
||||
|
||||
if (Array.isArray(schema.type)) {
|
||||
return schema.type.map((type) => typeFromSchema({ ...schema, type })).join(' | ');
|
||||
}
|
||||
|
||||
switch (schema.type) {
|
||||
case 'array':
|
||||
return `Array<${typeFromSchema(schema.items)}>`;
|
||||
case 'boolean':
|
||||
return 'boolean';
|
||||
case 'integer':
|
||||
case 'number':
|
||||
return 'number';
|
||||
case 'null':
|
||||
return 'null';
|
||||
case 'object':
|
||||
return objectTypeFromSchema(schema);
|
||||
case 'string':
|
||||
return 'string';
|
||||
default:
|
||||
if (schema.properties || schema.additionalProperties) {
|
||||
return objectTypeFromSchema(schema);
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
function objectTypeFromSchema(schema) {
|
||||
const properties = schema.properties ?? {};
|
||||
const required = new Set(schema.required ?? []);
|
||||
const lines = ['{'];
|
||||
|
||||
for (const [name, propertySchema] of Object.entries(properties)) {
|
||||
const optional = required.has(name) ? '' : '?';
|
||||
lines.push(`${formatDescription(propertySchema.description, ' ')} ${JSON.stringify(name)}${optional}: ${typeFromSchema(propertySchema)};`);
|
||||
}
|
||||
|
||||
if (schema.additionalProperties) {
|
||||
const valueType = schema.additionalProperties === true
|
||||
? 'unknown'
|
||||
: typeFromSchema(schema.additionalProperties);
|
||||
lines.push(` [key: string]: ${valueType};`);
|
||||
}
|
||||
|
||||
lines.push(' }');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function operationType(operation) {
|
||||
if (!operation) {
|
||||
return 'never';
|
||||
}
|
||||
|
||||
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'];
|
||||
|
||||
if (!jsonContent?.schema) {
|
||||
return 'void';
|
||||
}
|
||||
|
||||
return typeFromSchema(jsonContent.schema);
|
||||
}
|
||||
|
||||
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)};`);
|
||||
}
|
||||
|
||||
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(' };');
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('}');
|
||||
lines.push('');
|
||||
|
||||
await mkdir(dirname(outputPath), { recursive: true });
|
||||
await writeFile(outputPath, `${lines.join('\n')}\n`);
|
||||
+86
-13
@@ -14,21 +14,15 @@ describe('ChicoryTV SPA scaffold', () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
if (!window.localStorage) {
|
||||
const storage = new Map<string, string>();
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
clear: () => storage.clear(),
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
removeItem: (key: string) => storage.delete(key),
|
||||
setItem: (key: string, value: string) => storage.set(key, value)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.localStorage.clear();
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
vi.restoreAllMocks();
|
||||
vi.spyOn(window, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify([]), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status: 200
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the placeholder shell and loads the design system stylesheet', () => {
|
||||
@@ -92,6 +86,85 @@ describe('ChicoryTV SPA scaffold', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('renders live channel data from the typed API client', async () => {
|
||||
vi.spyOn(window, 'fetch').mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify([
|
||||
{
|
||||
fFmpegProfile: 'HLS Direct',
|
||||
id: 1,
|
||||
language: 'en',
|
||||
name: 'Retro Cartoons',
|
||||
number: '5.1',
|
||||
streamingMode: 'HLS Direct'
|
||||
},
|
||||
{
|
||||
fFmpegProfile: 'MPEG-TS',
|
||||
id: 2,
|
||||
language: 'fr',
|
||||
name: 'News 24',
|
||||
number: '24',
|
||||
streamingMode: 'MPEG-TS'
|
||||
}
|
||||
]),
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status: 200
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Live channels' })).toBeInTheDocument();
|
||||
expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument();
|
||||
expect(screen.getByText('5.1')).toBeInTheDocument();
|
||||
expect(screen.getByText('News 24')).toBeInTheDocument();
|
||||
expect(screen.getByText('24')).toBeInTheDocument();
|
||||
expect(screen.getByText('2 live')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the channel loading state while the request is pending', async () => {
|
||||
vi.spyOn(window, 'fetch').mockImplementation(() => new Promise<Response>(() => {}));
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByText('Loading channels')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an empty channel state when the API returns no channels', async () => {
|
||||
vi.spyOn(window, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify([]), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status: 200
|
||||
})
|
||||
);
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByText('No channels found')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the API error detail when channel loading fails', async () => {
|
||||
vi.spyOn(window, 'fetch').mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
detail: 'API write key is invalid',
|
||||
status: 401,
|
||||
title: 'Unauthorized'
|
||||
}),
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status: 401
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByText('API write key is invalid')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exports typed primitives with expected interactions', () => {
|
||||
const onSwitch = vi.fn();
|
||||
const onCheckbox = vi.fn();
|
||||
|
||||
+54
-1
@@ -39,6 +39,7 @@ import {
|
||||
Toast,
|
||||
Tooltip
|
||||
} from './components';
|
||||
import { useChannelsQuery } from './api';
|
||||
import {
|
||||
applyDesignSystemTheme,
|
||||
designSystemThemes,
|
||||
@@ -66,6 +67,8 @@ export function App() {
|
||||
const [showDisabled, setShowDisabled] = useState(true);
|
||||
const [inEpg, setInEpg] = useState(true);
|
||||
const [galleryTab, setGalleryTab] = useState('streaming');
|
||||
const channelsQuery = useChannelsQuery();
|
||||
const liveChannelCount = channelsQuery.channels?.length ?? 0;
|
||||
|
||||
useEffect(() => {
|
||||
applyDesignSystemTheme(theme);
|
||||
@@ -131,12 +134,62 @@ export function App() {
|
||||
{stats.map(({ label, value }) => (
|
||||
<div key={label}>
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
<strong>{label === 'Channels' ? liveChannelCount : value}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="ctv-panel ctv-live-panel" aria-labelledby="live-channels-title">
|
||||
<div className="ctv-panel-header">
|
||||
<div>
|
||||
<p>Issue #81</p>
|
||||
<h3 id="live-channels-title">Live channels</h3>
|
||||
</div>
|
||||
<span className="ctv-status">
|
||||
<Tv aria-hidden="true" size={14} />
|
||||
{channelsQuery.status === 'success' ? `${liveChannelCount} live` : 'Connecting'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="ctv-live-channel-body">
|
||||
{channelsQuery.status === 'loading' && (
|
||||
<div className="ctv-live-channel-state">
|
||||
<Spinner size={18} tone="accent" />
|
||||
<span>Loading channels</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{channelsQuery.status === 'error' && (
|
||||
<div className="ctv-live-channel-state ctv-live-channel-state-error">
|
||||
<span>Channel API unavailable</span>
|
||||
<strong>{channelsQuery.error}</strong>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{channelsQuery.status === 'success' && channelsQuery.channels.length === 0 && (
|
||||
<div className="ctv-live-channel-state">
|
||||
<span>No channels found</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{channelsQuery.status === 'success' && channelsQuery.channels.length > 0 && (
|
||||
<div className="ctv-live-channel-list">
|
||||
{channelsQuery.channels.map((channel) => (
|
||||
<div className="ctv-live-channel-row" key={channel.id}>
|
||||
<ChannelLogo name={channel.name ?? channel.number ?? 'Channel'} size={34} />
|
||||
<div>
|
||||
<strong>{channel.name ?? 'Unnamed channel'}</strong>
|
||||
<span>{channel.streamingMode ?? 'Default streaming'}</span>
|
||||
</div>
|
||||
<code>{channel.number ?? channel.id}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="ctv-gallery" aria-labelledby="gallery-title">
|
||||
<div className="ctv-gallery-heading">
|
||||
<p>Issue #80</p>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { clearStoredApiKey, getStoredApiKey, setStoredApiKey } from './auth';
|
||||
|
||||
describe('API key storage', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it('stores and reads the API key from local storage', () => {
|
||||
setStoredApiKey('write-secret');
|
||||
|
||||
expect(getStoredApiKey()).toBe('write-secret');
|
||||
expect(window.localStorage.getItem('ctv-api-key')).toBe('write-secret');
|
||||
});
|
||||
|
||||
it('trims blank API keys and clears storage', () => {
|
||||
setStoredApiKey(' ');
|
||||
|
||||
expect(getStoredApiKey()).toBeNull();
|
||||
expect(window.localStorage.getItem('ctv-api-key')).toBeNull();
|
||||
});
|
||||
|
||||
it('clears a stored API key', () => {
|
||||
setStoredApiKey('write-secret');
|
||||
|
||||
clearStoredApiKey();
|
||||
|
||||
expect(getStoredApiKey()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
const apiKeyStorageKey = 'ctv-api-key';
|
||||
|
||||
export function getStoredApiKey(): string | null {
|
||||
const value = window.localStorage.getItem(apiKeyStorageKey);
|
||||
return value && value.trim().length > 0 ? value : null;
|
||||
}
|
||||
|
||||
export function setStoredApiKey(apiKey: string): void {
|
||||
const trimmed = apiKey.trim();
|
||||
|
||||
if (trimmed.length === 0) {
|
||||
clearStoredApiKey();
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(apiKeyStorageKey, trimmed);
|
||||
}
|
||||
|
||||
export function clearStoredApiKey(): void {
|
||||
window.localStorage.removeItem(apiKeyStorageKey);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { request } from './client';
|
||||
import type { components } from './generated/v1';
|
||||
|
||||
export type ChannelSummary = components['schemas']['ChannelResponseModel'];
|
||||
|
||||
export function getChannels(): Promise<ChannelSummary[]> {
|
||||
return request<ChannelSummary[]>('/api/channels');
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { setStoredApiKey } from './auth';
|
||||
import { ApiError, request } from './client';
|
||||
|
||||
describe('API request client', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('omits the API key header for read requests', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify([{ id: 1, number: '1', name: 'News' }]), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status: 200
|
||||
})
|
||||
);
|
||||
setStoredApiKey('write-secret');
|
||||
|
||||
await request('/api/channels');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/channels',
|
||||
expect.objectContaining({
|
||||
headers: expect.not.objectContaining({ 'X-Api-Key': 'write-secret' })
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('adds the API key header for mutating requests', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(new Response(null, { status: 204 }));
|
||||
setStoredApiKey('write-secret');
|
||||
|
||||
await request('/api/channels/1', { method: 'DELETE' });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/channels/1',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({ 'X-Api-Key': 'write-secret' }),
|
||||
method: 'DELETE'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('throws an ApiError with problem details for non-OK responses', async () => {
|
||||
vi.spyOn(window, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({ status: 422, title: 'Validation failed', detail: 'Number exists' }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status: 422
|
||||
})
|
||||
);
|
||||
|
||||
await expect(request('/api/channels', { method: 'POST', body: { name: 'News' } })).rejects.toMatchObject({
|
||||
detail: 'Number exists',
|
||||
message: 'Validation failed',
|
||||
status: 422
|
||||
} satisfies Partial<ApiError>);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { getStoredApiKey } from './auth';
|
||||
import type { components } from './generated/v1';
|
||||
|
||||
type ProblemDetails = components['schemas']['ProblemDetails'];
|
||||
type RequestBody = BodyInit | Record<string, unknown> | null;
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly detail?: string | null;
|
||||
readonly problem?: ProblemDetails;
|
||||
readonly status: number;
|
||||
|
||||
constructor(status: number, problem?: ProblemDetails) {
|
||||
super(problem?.title ?? `Request failed with status ${status}`);
|
||||
this.name = 'ApiError';
|
||||
this.detail = problem?.detail;
|
||||
this.problem = problem;
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApiRequestOptions extends Omit<RequestInit, 'body'> {
|
||||
body?: RequestBody;
|
||||
}
|
||||
|
||||
const mutatingMethods = new Set(['DELETE', 'PATCH', 'POST', 'PUT']);
|
||||
|
||||
export async function request<TResponse = unknown>(
|
||||
path: string,
|
||||
options: ApiRequestOptions = {}
|
||||
): Promise<TResponse> {
|
||||
const method = (options.method ?? 'GET').toUpperCase();
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
...headersToRecord(options.headers)
|
||||
};
|
||||
|
||||
const body = serializeBody(options.body, headers);
|
||||
const apiKey = getStoredApiKey();
|
||||
|
||||
if (apiKey && mutatingMethods.has(method)) {
|
||||
headers['X-Api-Key'] = apiKey;
|
||||
}
|
||||
|
||||
const response = await fetch(path, {
|
||||
...options,
|
||||
body,
|
||||
headers,
|
||||
method
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response.status, await readProblemDetails(response));
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as TResponse;
|
||||
}
|
||||
|
||||
return await response.json() as TResponse;
|
||||
}
|
||||
|
||||
function headersToRecord(headers?: HeadersInit): Record<string, string> {
|
||||
if (!headers) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (headers instanceof Headers) {
|
||||
return Object.fromEntries(headers.entries());
|
||||
}
|
||||
|
||||
if (Array.isArray(headers)) {
|
||||
return Object.fromEntries(headers);
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
function serializeBody(body: RequestBody | undefined, headers: Record<string, string>): BodyInit | null | undefined {
|
||||
if (body === undefined || body === null) {
|
||||
return body;
|
||||
}
|
||||
|
||||
if (body instanceof FormData || body instanceof URLSearchParams || body instanceof Blob) {
|
||||
return body;
|
||||
}
|
||||
|
||||
if (typeof body === 'string') {
|
||||
return body;
|
||||
}
|
||||
|
||||
headers['Content-Type'] ??= 'application/json';
|
||||
return JSON.stringify(body);
|
||||
}
|
||||
|
||||
async function readProblemDetails(response: Response): Promise<ProblemDetails | undefined> {
|
||||
const contentType = response.headers.get('Content-Type') ?? '';
|
||||
|
||||
if (!contentType.includes('application/json')) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return await response.json() as ProblemDetails;
|
||||
}
|
||||
Vendored
+652
@@ -0,0 +1,652 @@
|
||||
// This file is generated by web/scripts/generate-openapi-types.mjs.
|
||||
// Do not edit by hand.
|
||||
|
||||
export interface components {
|
||||
schemas: {
|
||||
"AddItemsToCollectionRequest": {
|
||||
"movieIds": null | Array<number>;
|
||||
"showIds": null | Array<number>;
|
||||
"seasonIds": null | Array<number>;
|
||||
"episodeIds": null | Array<number>;
|
||||
"artistIds": null | Array<number>;
|
||||
"musicVideoIds": null | Array<number>;
|
||||
"otherVideoIds": null | Array<number>;
|
||||
"songIds": null | Array<number>;
|
||||
"imageIds": null | Array<number>;
|
||||
"remoteStreamIds": null | Array<number>;
|
||||
};
|
||||
"ArtworkContentTypeModel": {
|
||||
"path": null | string;
|
||||
"contentType": null | string;
|
||||
"isExternalUrl"?: boolean;
|
||||
"hasContentType"?: boolean;
|
||||
"urlWithContentType"?: null | string;
|
||||
};
|
||||
"ChannelIdleBehavior": "StopOnDisconnect" | "KeepRunning";
|
||||
"ChannelMusicVideoCreditsMode": "None" | "GenerateSubtitles";
|
||||
"ChannelPlayoutMode": "Continuous" | "OnDemand";
|
||||
"ChannelPlayoutSource": "Generated" | "Mirror";
|
||||
"ChannelResponseModel": {
|
||||
"id": number;
|
||||
"number": null | string;
|
||||
"name": null | string;
|
||||
"fFmpegProfile": null | string;
|
||||
"language": null | string;
|
||||
"streamingMode": null | string;
|
||||
};
|
||||
"ChannelSongVideoMode": "Default" | "WithProgress";
|
||||
"ChannelStreamSelectorMode": "Default" | "Custom" | "Troubleshooting";
|
||||
"ChannelSubtitleMode": "None" | "Forced" | "Default" | "Any";
|
||||
"ChannelTranscodeMode": "OnDemand";
|
||||
"ChannelViewModel": {
|
||||
"id": number;
|
||||
"number": null | string;
|
||||
"name": null | string;
|
||||
"group": null | string;
|
||||
"categories": null | string;
|
||||
"fFmpegProfileId": number;
|
||||
"slugSeconds": null | number;
|
||||
"logo": components["schemas"]["ArtworkContentTypeModel"];
|
||||
"streamSelectorMode": components["schemas"]["ChannelStreamSelectorMode"];
|
||||
"streamSelector": null | string;
|
||||
"preferredAudioLanguageCode": null | string;
|
||||
"preferredAudioTitle": null | string;
|
||||
"playoutSource": components["schemas"]["ChannelPlayoutSource"];
|
||||
"playoutMode": components["schemas"]["ChannelPlayoutMode"];
|
||||
"mirrorSourceChannelId": null | number;
|
||||
"playoutOffset": null | string;
|
||||
"streamingMode": components["schemas"]["StreamingMode"];
|
||||
"watermarkId": null | number;
|
||||
"fallbackFillerId": null | number;
|
||||
"playoutCount": number;
|
||||
"preferredSubtitleLanguageCode": null | string;
|
||||
"subtitleMode": components["schemas"]["ChannelSubtitleMode"];
|
||||
"musicVideoCreditsMode": components["schemas"]["ChannelMusicVideoCreditsMode"];
|
||||
"musicVideoCreditsTemplate": null | string;
|
||||
"songVideoMode": components["schemas"]["ChannelSongVideoMode"];
|
||||
"transcodeMode": components["schemas"]["ChannelTranscodeMode"];
|
||||
"idleBehavior": components["schemas"]["ChannelIdleBehavior"];
|
||||
"isEnabled": boolean;
|
||||
"showInEpg": boolean;
|
||||
"webEncodedName"?: null | string;
|
||||
};
|
||||
"ChannelWatermarkImageSource": "Custom" | "ChannelLogo" | "Resource";
|
||||
"ChannelWatermarkMode": "None" | "Permanent" | "Intermittent" | "OpacityExpression";
|
||||
"CollectionType": "Collection" | "TelevisionShow" | "TelevisionSeason" | "Artist" | "MultiCollection" | "SmartCollection" | "Playlist" | "RerunFirstRun" | "RerunRerun" | "SearchQuery" | "Movie" | "Episode" | "MusicVideo" | "OtherVideo" | "Song" | "Image" | "RemoteStream" | "FakeCollection" | "FakePlaylistItem";
|
||||
"CombinedVersion": {
|
||||
"apiVersion": number;
|
||||
"appVersion": null | string;
|
||||
};
|
||||
"CreateChannelRequest": {
|
||||
"name": null | string;
|
||||
"number": null | string;
|
||||
"group": null | string;
|
||||
"categories": null | string;
|
||||
"fFmpegProfileId": number;
|
||||
"slugSeconds": null | number;
|
||||
"logo": components["schemas"]["ArtworkContentTypeModel"];
|
||||
"streamSelectorMode": components["schemas"]["ChannelStreamSelectorMode"];
|
||||
"streamSelector": null | string;
|
||||
"preferredAudioLanguageCode": null | string;
|
||||
"preferredAudioTitle": null | string;
|
||||
"playoutSource": components["schemas"]["ChannelPlayoutSource"];
|
||||
"playoutMode": components["schemas"]["ChannelPlayoutMode"];
|
||||
"mirrorSourceChannelId": null | number;
|
||||
"playoutOffset": null | string;
|
||||
"streamingMode": components["schemas"]["StreamingMode"];
|
||||
"watermarkId": null | number;
|
||||
"fallbackFillerId": null | number;
|
||||
"preferredSubtitleLanguageCode": null | string;
|
||||
"subtitleMode": components["schemas"]["ChannelSubtitleMode"];
|
||||
"musicVideoCreditsMode": components["schemas"]["ChannelMusicVideoCreditsMode"];
|
||||
"musicVideoCreditsTemplate": null | string;
|
||||
"songVideoMode": components["schemas"]["ChannelSongVideoMode"];
|
||||
"transcodeMode": components["schemas"]["ChannelTranscodeMode"];
|
||||
"idleBehavior": components["schemas"]["ChannelIdleBehavior"];
|
||||
"isEnabled": boolean;
|
||||
"showInEpg": boolean;
|
||||
};
|
||||
"CreateCollectionRequest": {
|
||||
"name": null | string;
|
||||
};
|
||||
"CreateFFmpegProfileRequest": {
|
||||
"name": null | string;
|
||||
"threadCount": number;
|
||||
"normalizeAudio": boolean;
|
||||
"normalizeVideo": boolean;
|
||||
"hardwareAcceleration": components["schemas"]["HardwareAccelerationKind"];
|
||||
"vaapiDisplay": null | string;
|
||||
"vaapiDriver": components["schemas"]["VaapiDriver"];
|
||||
"vaapiDevice": null | string;
|
||||
"qsvExtraHardwareFrames": null | number;
|
||||
"resolutionId": number;
|
||||
"scalingBehavior": components["schemas"]["ScalingBehavior"];
|
||||
"padMode": components["schemas"]["FilterMode"];
|
||||
"videoFormat": components["schemas"]["FFmpegProfileVideoFormat"];
|
||||
"videoProfile": null | string;
|
||||
"videoPreset": null | string;
|
||||
"allowBFrames": boolean;
|
||||
"bitDepth": components["schemas"]["FFmpegProfileBitDepth"];
|
||||
"videoBitrate": number;
|
||||
"videoBufferSize": number;
|
||||
"tonemapAlgorithm": components["schemas"]["FFmpegProfileTonemapAlgorithm"];
|
||||
"audioFormat": components["schemas"]["FFmpegProfileAudioFormat"];
|
||||
"audioBitrate": number;
|
||||
"audioBufferSize": number;
|
||||
"normalizeLoudnessMode": components["schemas"]["NormalizeLoudnessMode"];
|
||||
"targetLoudness": null | number;
|
||||
"audioChannels": number;
|
||||
"audioSampleRate": number;
|
||||
"normalizeFramerate": boolean;
|
||||
"normalizeColors": boolean;
|
||||
"deinterlaceVideo": boolean;
|
||||
};
|
||||
"CreatePlayoutRequest": {
|
||||
"channelId": number;
|
||||
"programScheduleId": number;
|
||||
};
|
||||
"CreateScheduleRequest": {
|
||||
"name": null | string;
|
||||
"keepMultiPartEpisodesTogether": boolean;
|
||||
"treatCollectionsAsShows": boolean;
|
||||
"shuffleScheduleItems": boolean;
|
||||
"randomStartPoint": boolean;
|
||||
"fixedStartTimeBehavior": components["schemas"]["FixedStartTimeBehavior"];
|
||||
};
|
||||
"CreateSmartCollectionRequest": {
|
||||
"name": null | string;
|
||||
"query": null | string;
|
||||
};
|
||||
"FFmpegFullProfileResponseModel": {
|
||||
"id": number;
|
||||
"name": null | string;
|
||||
"threadCount": number;
|
||||
"hardwareAcceleration": components["schemas"]["HardwareAccelerationKind"];
|
||||
"vaapiDisplay": null | string;
|
||||
"vaapiDriver": components["schemas"]["VaapiDriver"];
|
||||
"vaapiDevice": null | string;
|
||||
"qsvExtraHardwareFrames": null | number;
|
||||
"resolution": null | string;
|
||||
"scalingBehavior": components["schemas"]["ScalingBehavior"];
|
||||
"videoFormat": components["schemas"]["FFmpegProfileVideoFormat"];
|
||||
"videoProfile": null | string;
|
||||
"videoPreset": null | string;
|
||||
"allowBFrames": boolean;
|
||||
"bitDepth": components["schemas"]["FFmpegProfileBitDepth"];
|
||||
"videoBitrate": number;
|
||||
"videoBufferSize": number;
|
||||
"tonemapAlgorithm": components["schemas"]["FFmpegProfileTonemapAlgorithm"];
|
||||
"audioFormat": components["schemas"]["FFmpegProfileAudioFormat"];
|
||||
"audioBitrate": number;
|
||||
"audioBufferSize": number;
|
||||
"normalizeLoudnessMode": components["schemas"]["NormalizeLoudnessMode"];
|
||||
"audioChannels": number;
|
||||
"audioSampleRate": number;
|
||||
"normalizeFramerate": boolean;
|
||||
"deinterlaceVideo": null | boolean;
|
||||
};
|
||||
"FFmpegProfileAudioFormat": "None" | "Aac" | "Ac3" | "AacLatm" | "Copy";
|
||||
"FFmpegProfileBitDepth": "EightBit" | "TenBit";
|
||||
"FFmpegProfileTonemapAlgorithm": "Linear" | "Clip" | "Gamma" | "Reinhard" | "Mobius" | "Hable";
|
||||
"FFmpegProfileVideoFormat": "None" | "H264" | "Hevc" | "Mpeg2Video" | "Av1" | "Copy";
|
||||
"FillerKind": "None" | "PreRoll" | "MidRoll" | "PostRoll" | "Tail" | "Fallback" | "GuideMode" | "DecoDefault";
|
||||
"FillerMode": "None" | "Duration" | "Count" | "Pad" | "RandomCount";
|
||||
"FillerPresetViewModel": {
|
||||
"id": number;
|
||||
"name": null | string;
|
||||
"fillerKind": components["schemas"]["FillerKind"];
|
||||
"fillerMode": components["schemas"]["FillerMode"];
|
||||
"duration": null | string;
|
||||
"count": null | number;
|
||||
"padToNearestMinute": null | number;
|
||||
"allowWatermarks": boolean;
|
||||
"collectionType": components["schemas"]["CollectionType"];
|
||||
"collectionId": null | number;
|
||||
"mediaItemId": null | number;
|
||||
"multiCollectionId": null | number;
|
||||
"smartCollectionId": null | number;
|
||||
"playlist": components["schemas"]["PlaylistViewModel"];
|
||||
"expression": null | string;
|
||||
"useChaptersAsMediaItems": boolean;
|
||||
};
|
||||
"FillWithGroupMode": "None" | "FillWithOrderedGroups" | "FillWithShuffledGroups";
|
||||
"FilterMode": "HardwareIfPossible" | "Software";
|
||||
"FixedStartTimeBehavior": "Strict" | "Flexible";
|
||||
"GraphicsElementViewModel": {
|
||||
"id": number;
|
||||
"name": null | string;
|
||||
"fileName": null | string;
|
||||
};
|
||||
"GuideMode": "Normal" | "Filler";
|
||||
"HardwareAccelerationKind": "None" | "Qsv" | "Nvenc" | "Vaapi" | "VideoToolbox" | "Amf" | "V4l2m2m" | "Rkmpp";
|
||||
"HlsSessionModel": {
|
||||
"channelNumber": null | string;
|
||||
"state": null | string;
|
||||
"transcodedUntil": string;
|
||||
"lastAccess": string;
|
||||
};
|
||||
"MarathonGroupBy": "None" | "Show" | "Season" | "Artist" | "Album" | "Director";
|
||||
"MediaCollectionViewModel": {
|
||||
"collectionType": components["schemas"]["CollectionType"];
|
||||
"id": number;
|
||||
"name": null | string;
|
||||
"useCustomPlaybackOrder": boolean;
|
||||
"mediaItemId"?: number;
|
||||
"title"?: null | string;
|
||||
"subtitle"?: null | string;
|
||||
"sortTitle"?: null | string;
|
||||
"poster"?: null | string;
|
||||
"state": components["schemas"]["MediaItemState"];
|
||||
"hasMediaInfo"?: boolean;
|
||||
};
|
||||
"MediaItemState": "Normal" | "FileNotFound" | "Unavailable" | "RemoteOnly";
|
||||
"MultiCollectionItemViewModel": {
|
||||
"multiCollectionId": number;
|
||||
"collection": components["schemas"]["MediaCollectionViewModel"];
|
||||
"scheduleAsGroup": boolean;
|
||||
"playbackOrder": components["schemas"]["PlaybackOrder"];
|
||||
};
|
||||
"MultiCollectionSmartItemViewModel": {
|
||||
"multiCollectionId": number;
|
||||
"smartCollection": components["schemas"]["SmartCollectionViewModel"];
|
||||
"scheduleAsGroup": boolean;
|
||||
"playbackOrder": components["schemas"]["PlaybackOrder"];
|
||||
};
|
||||
"MultiCollectionViewModel": {
|
||||
"id": number;
|
||||
"name": null | string;
|
||||
"items": null | Array<components["schemas"]["MultiCollectionItemViewModel"]>;
|
||||
"smartItems": null | Array<components["schemas"]["MultiCollectionSmartItemViewModel"]>;
|
||||
};
|
||||
"MultipleMode": "Count" | "CollectionSize" | "PlaylistItemSize" | "MultiEpisodeGroupSize";
|
||||
"NamedMediaItemViewModel": {
|
||||
"mediaItemId": number;
|
||||
"name": null | string;
|
||||
};
|
||||
"NormalizeLoudnessMode": "Off" | "LoudNorm";
|
||||
"PlaybackOrder": "None" | "Chronological" | "Random" | "Shuffle" | "ShuffleInOrder" | "MultiEpisodeShuffle" | "SeasonEpisode" | "RandomRotation" | "Marathon";
|
||||
"PlaylistViewModel": {
|
||||
"id": number;
|
||||
"playlistGroupId": number;
|
||||
"name": null | string;
|
||||
"isSystem": boolean;
|
||||
};
|
||||
"PlayoutMode": "Flood" | "One" | "Multiple" | "Duration";
|
||||
"PlayoutResponseModel": {
|
||||
"id": number;
|
||||
"scheduleKind": components["schemas"]["PlayoutScheduleKind"];
|
||||
"channelName": null | string;
|
||||
"channelNumber": null | string;
|
||||
"playoutMode": components["schemas"]["ChannelPlayoutMode"];
|
||||
"scheduleName": null | string;
|
||||
"scheduleFile": null | string;
|
||||
"dailyRebuildTime": null | string;
|
||||
};
|
||||
"PlayoutScheduleKind": "None" | "Classic" | "Block" | "Sequential" | "Scripted" | "ExternalJson";
|
||||
"ProblemDetails": {
|
||||
"type"?: null | string;
|
||||
"title"?: null | string;
|
||||
"status"?: null | number;
|
||||
"detail"?: null | string;
|
||||
"instance"?: null | string;
|
||||
};
|
||||
"ProgramScheduleItemViewModel": {
|
||||
"id"?: number;
|
||||
"index"?: number;
|
||||
"startType"?: components["schemas"]["StartType"];
|
||||
"startTime"?: null | string;
|
||||
"fixedStartTimeBehavior"?: null | components["schemas"]["FixedStartTimeBehavior"];
|
||||
"playoutMode"?: components["schemas"]["PlayoutMode"];
|
||||
"collectionType"?: components["schemas"]["CollectionType"];
|
||||
"collection"?: components["schemas"]["MediaCollectionViewModel"];
|
||||
"multiCollection"?: components["schemas"]["MultiCollectionViewModel"];
|
||||
"smartCollection"?: components["schemas"]["SmartCollectionViewModel"];
|
||||
"rerunCollection"?: components["schemas"]["RerunCollectionViewModel"];
|
||||
"playlist"?: components["schemas"]["PlaylistViewModel"];
|
||||
"mediaItem"?: components["schemas"]["NamedMediaItemViewModel"];
|
||||
"searchTitle"?: null | string;
|
||||
"searchQuery"?: null | string;
|
||||
"playbackOrder"?: components["schemas"]["PlaybackOrder"];
|
||||
"marathonGroupBy"?: components["schemas"]["MarathonGroupBy"];
|
||||
"marathonShuffleGroups"?: boolean;
|
||||
"marathonShuffleItems"?: boolean;
|
||||
"marathonBatchSize"?: null | number;
|
||||
"fillWithGroupMode"?: components["schemas"]["FillWithGroupMode"];
|
||||
"customTitle"?: null | string;
|
||||
"guideMode"?: components["schemas"]["GuideMode"];
|
||||
"preRollFiller"?: components["schemas"]["FillerPresetViewModel"];
|
||||
"midRollFiller"?: components["schemas"]["FillerPresetViewModel"];
|
||||
"postRollFiller"?: components["schemas"]["FillerPresetViewModel"];
|
||||
"tailFiller"?: components["schemas"]["FillerPresetViewModel"];
|
||||
"fallbackFiller"?: components["schemas"]["FillerPresetViewModel"];
|
||||
"watermarks"?: null | Array<components["schemas"]["WatermarkViewModel"]>;
|
||||
"graphicsElements"?: null | Array<components["schemas"]["GraphicsElementViewModel"]>;
|
||||
"preferredAudioLanguageCode"?: null | string;
|
||||
"preferredAudioTitle"?: null | string;
|
||||
"preferredSubtitleLanguageCode"?: null | string;
|
||||
"subtitleMode"?: null | components["schemas"]["ChannelSubtitleMode"];
|
||||
"name"?: null | string;
|
||||
};
|
||||
"ProgramScheduleViewModel": {
|
||||
"id": number;
|
||||
"name": null | string;
|
||||
"keepMultiPartEpisodesTogether": boolean;
|
||||
"treatCollectionsAsShows": boolean;
|
||||
"shuffleScheduleItems": boolean;
|
||||
"randomStartPoint": boolean;
|
||||
"fixedStartTimeBehavior": components["schemas"]["FixedStartTimeBehavior"];
|
||||
};
|
||||
"ReplaceScheduleItemsRequest": {
|
||||
"items": null | Array<components["schemas"]["ScheduleItemRequest"]>;
|
||||
};
|
||||
"RerunCollectionViewModel": {
|
||||
"id": number;
|
||||
"name": null | string;
|
||||
"collectionType": components["schemas"]["CollectionType"];
|
||||
"collection": components["schemas"]["MediaCollectionViewModel"];
|
||||
"multiCollection": components["schemas"]["MultiCollectionViewModel"];
|
||||
"smartCollection": components["schemas"]["SmartCollectionViewModel"];
|
||||
"mediaItem": components["schemas"]["NamedMediaItemViewModel"];
|
||||
"firstRunPlaybackOrder": components["schemas"]["PlaybackOrder"];
|
||||
"rerunPlaybackOrder": components["schemas"]["PlaybackOrder"];
|
||||
};
|
||||
"ResolutionViewModel": {
|
||||
"id": number;
|
||||
"name": null | string;
|
||||
"width": number;
|
||||
"height": number;
|
||||
"isCustom": boolean;
|
||||
};
|
||||
"ScalingBehavior": "ScaleAndPad" | "Stretch" | "Crop";
|
||||
"ScanShowRequest": {
|
||||
"showTitle": null | string;
|
||||
"deepScan"?: boolean;
|
||||
};
|
||||
"ScheduleItemRequest": {
|
||||
"startType": components["schemas"]["StartType"];
|
||||
"startTime": null | string;
|
||||
"fixedStartTimeBehavior": null | components["schemas"]["FixedStartTimeBehavior"];
|
||||
"playoutMode": components["schemas"]["PlayoutMode"];
|
||||
"collectionType": components["schemas"]["CollectionType"];
|
||||
"collectionId": null | number;
|
||||
"multiCollectionId": null | number;
|
||||
"smartCollectionId": null | number;
|
||||
"rerunCollectionId": null | number;
|
||||
"mediaItemId": null | number;
|
||||
"playlistId": null | number;
|
||||
"searchTitle": null | string;
|
||||
"searchQuery": null | string;
|
||||
"playbackOrder": components["schemas"]["PlaybackOrder"];
|
||||
"marathonGroupBy": components["schemas"]["MarathonGroupBy"];
|
||||
"marathonShuffleGroups": boolean;
|
||||
"marathonShuffleItems": boolean;
|
||||
"marathonBatchSize": null | number;
|
||||
"fillWithGroupMode": components["schemas"]["FillWithGroupMode"];
|
||||
"multipleMode": components["schemas"]["MultipleMode"];
|
||||
"multipleCount": null | string;
|
||||
"playoutDuration": null | string;
|
||||
"tailMode": components["schemas"]["TailMode"];
|
||||
"discardToFillAttempts": null | number;
|
||||
"customTitle": null | string;
|
||||
"guideMode": components["schemas"]["GuideMode"];
|
||||
"preRollFillerId": null | number;
|
||||
"midRollFillerId": null | number;
|
||||
"postRollFillerId": null | number;
|
||||
"tailFillerId": null | number;
|
||||
"fallbackFillerId": null | number;
|
||||
"watermarkIds": null | Array<number>;
|
||||
"graphicsElementIds": null | Array<number>;
|
||||
"preferredAudioLanguageCode": null | string;
|
||||
"preferredAudioTitle": null | string;
|
||||
"preferredSubtitleLanguageCode": null | string;
|
||||
"subtitleMode": null | components["schemas"]["ChannelSubtitleMode"];
|
||||
};
|
||||
"SmartCollectionResponseModel": {
|
||||
"id": number;
|
||||
"name": null | string;
|
||||
"query": null | string;
|
||||
};
|
||||
"SmartCollectionViewModel": {
|
||||
"id": number;
|
||||
"name": null | string;
|
||||
"query": null | string;
|
||||
};
|
||||
"StartType": "Dynamic" | "Fixed";
|
||||
"StreamingMode": "TransportStream" | "HttpLiveStreamingDirect" | "HttpLiveStreamingSegmenter" | "TransportStreamHybrid";
|
||||
"TailMode": "None" | "Offline" | "Slate" | "Filler";
|
||||
"UpdateChannelRequest": {
|
||||
"name": null | string;
|
||||
"number": null | string;
|
||||
"group": null | string;
|
||||
"categories": null | string;
|
||||
"fFmpegProfileId": number;
|
||||
"slugSeconds": null | number;
|
||||
"logo": components["schemas"]["ArtworkContentTypeModel"];
|
||||
"streamSelectorMode": components["schemas"]["ChannelStreamSelectorMode"];
|
||||
"streamSelector": null | string;
|
||||
"preferredAudioLanguageCode": null | string;
|
||||
"preferredAudioTitle": null | string;
|
||||
"playoutSource": components["schemas"]["ChannelPlayoutSource"];
|
||||
"playoutMode": components["schemas"]["ChannelPlayoutMode"];
|
||||
"mirrorSourceChannelId": null | number;
|
||||
"playoutOffset": null | string;
|
||||
"streamingMode": components["schemas"]["StreamingMode"];
|
||||
"watermarkId": null | number;
|
||||
"fallbackFillerId": null | number;
|
||||
"preferredSubtitleLanguageCode": null | string;
|
||||
"subtitleMode": components["schemas"]["ChannelSubtitleMode"];
|
||||
"musicVideoCreditsMode": components["schemas"]["ChannelMusicVideoCreditsMode"];
|
||||
"musicVideoCreditsTemplate": null | string;
|
||||
"songVideoMode": components["schemas"]["ChannelSongVideoMode"];
|
||||
"transcodeMode": components["schemas"]["ChannelTranscodeMode"];
|
||||
"idleBehavior": components["schemas"]["ChannelIdleBehavior"];
|
||||
"isEnabled": boolean;
|
||||
"showInEpg": boolean;
|
||||
};
|
||||
"UpdateCollectionRequest": {
|
||||
"name": null | string;
|
||||
"useCustomPlaybackOrder": null | boolean;
|
||||
};
|
||||
"UpdateFFmpegProfileRequest": {
|
||||
"name": null | string;
|
||||
"threadCount": number;
|
||||
"normalizeAudio": boolean;
|
||||
"normalizeVideo": boolean;
|
||||
"hardwareAcceleration": components["schemas"]["HardwareAccelerationKind"];
|
||||
"vaapiDisplay": null | string;
|
||||
"vaapiDriver": components["schemas"]["VaapiDriver"];
|
||||
"vaapiDevice": null | string;
|
||||
"qsvExtraHardwareFrames": null | number;
|
||||
"resolutionId": number;
|
||||
"scalingBehavior": components["schemas"]["ScalingBehavior"];
|
||||
"padMode": components["schemas"]["FilterMode"];
|
||||
"videoFormat": components["schemas"]["FFmpegProfileVideoFormat"];
|
||||
"videoProfile": null | string;
|
||||
"videoPreset": null | string;
|
||||
"allowBFrames": boolean;
|
||||
"bitDepth": components["schemas"]["FFmpegProfileBitDepth"];
|
||||
"videoBitrate": number;
|
||||
"videoBufferSize": number;
|
||||
"tonemapAlgorithm": components["schemas"]["FFmpegProfileTonemapAlgorithm"];
|
||||
"audioFormat": components["schemas"]["FFmpegProfileAudioFormat"];
|
||||
"audioBitrate": number;
|
||||
"audioBufferSize": number;
|
||||
"normalizeLoudnessMode": components["schemas"]["NormalizeLoudnessMode"];
|
||||
"targetLoudness": null | number;
|
||||
"audioChannels": number;
|
||||
"audioSampleRate": number;
|
||||
"normalizeFramerate": boolean;
|
||||
"normalizeColors": boolean;
|
||||
"deinterlaceVideo": boolean;
|
||||
};
|
||||
"UpdateScheduleRequest": {
|
||||
"name": null | string;
|
||||
"keepMultiPartEpisodesTogether": boolean;
|
||||
"treatCollectionsAsShows": boolean;
|
||||
"shuffleScheduleItems": boolean;
|
||||
"randomStartPoint": boolean;
|
||||
"fixedStartTimeBehavior": components["schemas"]["FixedStartTimeBehavior"];
|
||||
};
|
||||
"UpdateSmartCollectionRequest": {
|
||||
"name": null | string;
|
||||
"query": null | string;
|
||||
};
|
||||
"VaapiDriver": "Default" | "iHD" | "i965" | "RadeonSI" | "Nouveau";
|
||||
"WatermarkLocation": number;
|
||||
"WatermarkSize": number;
|
||||
"WatermarkViewModel": {
|
||||
"id": number;
|
||||
"image": components["schemas"]["ArtworkContentTypeModel"];
|
||||
"name": null | string;
|
||||
"mode": components["schemas"]["ChannelWatermarkMode"];
|
||||
"imageSource": components["schemas"]["ChannelWatermarkImageSource"];
|
||||
"location": components["schemas"]["WatermarkLocation"];
|
||||
"size": components["schemas"]["WatermarkSize"];
|
||||
"width": number;
|
||||
"horizontalMargin": number;
|
||||
"verticalMargin": number;
|
||||
"frequencyMinutes": number;
|
||||
"durationSeconds": number;
|
||||
"opacity": number;
|
||||
"placeWithinSourceContent": boolean;
|
||||
"opacityExpression": null | string;
|
||||
"zIndex": number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface operations {
|
||||
"GET /api/channels": {
|
||||
response: Array<components["schemas"]["ChannelResponseModel"]>;
|
||||
};
|
||||
"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<components["schemas"]["MediaCollectionViewModel"]>;
|
||||
};
|
||||
"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<components["schemas"]["FFmpegFullProfileResponseModel"]>;
|
||||
};
|
||||
"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<components["schemas"]["ProgramScheduleViewModel"]>;
|
||||
};
|
||||
"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<components["schemas"]["ProgramScheduleItemViewModel"]>;
|
||||
};
|
||||
"POST /api/schedules/{id}/items": {
|
||||
response: components["schemas"]["ProgramScheduleItemViewModel"];
|
||||
};
|
||||
"PUT /api/schedules/{id}/items": {
|
||||
response: Array<components["schemas"]["ProgramScheduleItemViewModel"]>;
|
||||
};
|
||||
"DELETE /api/schedules/{id}/items/{itemId}": {
|
||||
response: void;
|
||||
};
|
||||
"DELETE /api/session/{channelNumber}": {
|
||||
response: void;
|
||||
};
|
||||
"GET /api/sessions": {
|
||||
response: Array<components["schemas"]["HlsSessionModel"]>;
|
||||
};
|
||||
"GET /api/smart-collections": {
|
||||
response: Array<components["schemas"]["SmartCollectionResponseModel"]>;
|
||||
};
|
||||
"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"];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './auth';
|
||||
export * from './channels';
|
||||
export * from './client';
|
||||
export * from './useChannelsQuery';
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ApiError } from './client';
|
||||
import { getChannels, type ChannelSummary } from './channels';
|
||||
|
||||
type ChannelsQueryState =
|
||||
| { channels: ChannelSummary[]; error: null; status: 'success' }
|
||||
| { channels: null; error: string; status: 'error' }
|
||||
| { channels: null; error: null; status: 'loading' };
|
||||
|
||||
export function useChannelsQuery(): ChannelsQueryState {
|
||||
const [state, setState] = useState<ChannelsQueryState>({
|
||||
channels: null,
|
||||
error: null,
|
||||
status: 'loading'
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
getChannels()
|
||||
.then((channels) => {
|
||||
if (active) {
|
||||
setState({ channels, error: null, status: 'success' });
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (active) {
|
||||
setState({ channels: null, error: messageFromError(error), status: 'error' });
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function messageFromError(error: unknown): string {
|
||||
if (error instanceof ApiError) {
|
||||
return error.detail ?? error.message;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return 'Unable to load channels';
|
||||
}
|
||||
@@ -1 +1,15 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
|
||||
if (!window.localStorage) {
|
||||
const storage = new Map<string, string>();
|
||||
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
clear: () => storage.clear(),
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
removeItem: (key: string) => storage.delete(key),
|
||||
setItem: (key: string, value: string) => storage.set(key, value)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -254,6 +254,80 @@
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.ctv-live-channel-body {
|
||||
padding: var(--space-7, 16px);
|
||||
}
|
||||
|
||||
.ctv-live-channel-state {
|
||||
min-height: 72px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-4, 8px);
|
||||
color: var(--text-secondary, #a89e92);
|
||||
}
|
||||
|
||||
.ctv-live-channel-state-error {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--status-error, #f0663f);
|
||||
border-radius: var(--radius-md, 7px);
|
||||
background: var(--ctv-error-soft, rgba(240, 102, 63, 0.14));
|
||||
color: var(--text-primary, #f0ebe4);
|
||||
padding: var(--space-6, 12px);
|
||||
}
|
||||
|
||||
.ctv-live-channel-state-error span {
|
||||
color: var(--status-error, #f0663f);
|
||||
font-size: var(--text-xs, 12px);
|
||||
font-weight: var(--weight-semibold, 600);
|
||||
letter-spacing: var(--tracking-caps, 0.06em);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ctv-live-channel-list {
|
||||
display: grid;
|
||||
gap: var(--space-3, 6px);
|
||||
}
|
||||
|
||||
.ctv-live-channel-row {
|
||||
min-height: 48px;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--space-5, 10px);
|
||||
border: 1px solid var(--border-hairline, #2e2823);
|
||||
border-radius: var(--radius-sm, 5px);
|
||||
background: var(--surface-raised, #211d19);
|
||||
padding: var(--space-4, 8px);
|
||||
}
|
||||
|
||||
.ctv-live-channel-row div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.ctv-live-channel-row strong,
|
||||
.ctv-live-channel-row span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ctv-live-channel-row span {
|
||||
color: var(--text-secondary, #a89e92);
|
||||
font-size: var(--text-xs, 12px);
|
||||
}
|
||||
|
||||
.ctv-live-channel-row code {
|
||||
border-radius: var(--radius-sm, 5px);
|
||||
background: var(--surface-selected, #2c2620);
|
||||
color: var(--text-primary, #f0ebe4);
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: var(--text-xs, 12px);
|
||||
padding: var(--space-2, 4px) var(--space-3, 6px);
|
||||
}
|
||||
|
||||
.ctv-gallery {
|
||||
margin-top: var(--space-9, 24px);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user