feat(web): add typed API client auth

refs #81
This commit is contained in:
2026-07-02 08:23:19 +02:00
parent e6df6ac10a
commit d1cc12e065
16 changed files with 1381 additions and 14 deletions
+4
View File
@@ -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`.
+2
View File
@@ -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"
+143
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+30
View File
@@ -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();
});
});
+21
View File
@@ -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);
}
+8
View File
@@ -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');
}
+59
View File
@@ -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>);
});
});
+103
View File
@@ -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;
}
+652
View File
@@ -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"];
};
}
+4
View File
@@ -0,0 +1,4 @@
export * from './auth';
export * from './channels';
export * from './client';
export * from './useChannelsQuery';
+50
View File
@@ -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';
}
+14
View File
@@ -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)
}
});
}
+74
View File
@@ -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);
}