Files
ersatztv/web/src/api/settings.ts
T
timothyandClaude Fable 5 ab9d21326b fix(web): settings screen degrades gracefully on partial API failure
Reference data (profiles, watermarks, fillers, media sources, version,
health) now loads via allSettled with per-resource failure notes; only
the settings groups themselves gate the screen. Error state renders
before the loading guard (was unreachable). +2 tests (136 total).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 09:22:51 +02:00

285 lines
10 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { ApiError, request } from './client';
import { getDashboardHealth, getDashboardVersion, type DashboardVersion } from './dashboard';
import { getMediaSources, type MediaSource } from './libraries';
import { getFFmpegProfiles, getFillerPresets, getWatermarks, type FFmpegProfile, type FillerPreset, type Watermark } from './pickers';
import type { components } from './generated/v1';
export type FfmpegSettings = components['schemas']['FFmpegSettingsResponseModel'];
export type UpdateFfmpegSettingsRequest = components['schemas']['UpdateFFmpegSettingsRequest'];
export type PlayoutSettings = components['schemas']['PlayoutSettingsResponseModel'];
export type UpdatePlayoutSettingsRequest = components['schemas']['UpdatePlayoutSettingsRequest'];
export type XmltvSettings = components['schemas']['XmltvSettingsResponseModel'];
export type UpdateXmltvSettingsRequest = components['schemas']['UpdateXmltvSettingsRequest'];
export type ScannerSettings = components['schemas']['ScannerSettingsResponseModel'];
export type UpdateScannerSettingsRequest = components['schemas']['UpdateScannerSettingsRequest'];
export type LoggingSettings = components['schemas']['LoggingSettingsResponseModel'];
export type UpdateLoggingSettingsRequest = components['schemas']['UpdateLoggingSettingsRequest'];
export type UiSettings = components['schemas']['UiSettingsResponseModel'];
export type UpdateUiSettingsRequest = components['schemas']['UpdateUiSettingsRequest'];
export type HdhrSettings = components['schemas']['HdhrSettingsResponseModel'];
export type UpdateHdhrSettingsRequest = components['schemas']['UpdateHdhrSettingsRequest'];
export type Resolution = components['schemas']['ResolutionResponseModel'];
export type CreateResolutionRequest = components['schemas']['CreateResolutionRequest'];
export type LogEventLevel = components['schemas']['LogEventLevel'];
export type XmltvTimeZone = components['schemas']['XmltvTimeZone'];
export type XmltvBlockBehavior = components['schemas']['XmltvBlockBehavior'];
export type OutputFormatKind = components['schemas']['OutputFormatKind'];
export type HealthCheck = components['schemas']['HealthCheckResponseModel'];
export function getFfmpegSettings(): Promise<FfmpegSettings> {
return request<FfmpegSettings>('/api/settings/ffmpeg');
}
export function updateFfmpegSettings(body: UpdateFfmpegSettingsRequest): Promise<FfmpegSettings> {
return request<FfmpegSettings>('/api/settings/ffmpeg', { body, method: 'PUT' });
}
export function getPlayoutSettings(): Promise<PlayoutSettings> {
return request<PlayoutSettings>('/api/settings/playout');
}
export function updatePlayoutSettings(body: UpdatePlayoutSettingsRequest): Promise<PlayoutSettings> {
return request<PlayoutSettings>('/api/settings/playout', { body, method: 'PUT' });
}
export function getXmltvSettings(): Promise<XmltvSettings> {
return request<XmltvSettings>('/api/settings/xmltv');
}
export function updateXmltvSettings(body: UpdateXmltvSettingsRequest): Promise<XmltvSettings> {
return request<XmltvSettings>('/api/settings/xmltv', { body, method: 'PUT' });
}
export function getScannerSettings(): Promise<ScannerSettings> {
return request<ScannerSettings>('/api/settings/scanner');
}
export function updateScannerSettings(body: UpdateScannerSettingsRequest): Promise<ScannerSettings> {
return request<ScannerSettings>('/api/settings/scanner', { body, method: 'PUT' });
}
export function getLoggingSettings(): Promise<LoggingSettings> {
return request<LoggingSettings>('/api/settings/logging');
}
export function updateLoggingSettings(body: UpdateLoggingSettingsRequest): Promise<LoggingSettings> {
return request<LoggingSettings>('/api/settings/logging', { body, method: 'PUT' });
}
export function getUiSettings(): Promise<UiSettings> {
return request<UiSettings>('/api/settings/ui');
}
export function updateUiSettings(body: UpdateUiSettingsRequest): Promise<UiSettings> {
return request<UiSettings>('/api/settings/ui', { body, method: 'PUT' });
}
export function getHdhrSettings(): Promise<HdhrSettings> {
return request<HdhrSettings>('/api/settings/hdhr');
}
export function updateHdhrSettings(body: UpdateHdhrSettingsRequest): Promise<HdhrSettings> {
return request<HdhrSettings>('/api/settings/hdhr', { body, method: 'PUT' });
}
export function getResolutions(): Promise<Resolution[]> {
return request<Resolution[]>('/api/settings/resolutions');
}
export function createResolution(body: CreateResolutionRequest): Promise<Resolution> {
return request<Resolution>('/api/settings/resolutions', { body, method: 'POST' });
}
export function deleteResolution(id: number): Promise<void> {
return request<void>(`/api/settings/resolutions/${id}`, { method: 'DELETE' });
}
export interface SettingsScreenData {
ffmpeg: FfmpegSettings;
playout: PlayoutSettings;
xmltv: XmltvSettings;
scanner: ScannerSettings;
logging: LoggingSettings;
ui: UiSettings;
hdhr: HdhrSettings;
resolutions: Resolution[];
ffmpegProfiles: FFmpegProfile[];
watermarks: Watermark[];
fillerPresets: FillerPreset[];
mediaSources: MediaSource[];
version: DashboardVersion | null;
health: HealthCheck[];
// Per-resource tier-2 failure flags: reference data that failed to load. The screen
// still renders (with empty/null fallbacks above); the affected card shows an inline
// "Couldn't load X" note instead of data.
referenceFailures: ReferenceFailures;
}
export interface ReferenceFailures {
ffmpegProfiles: boolean;
fillerPresets: boolean;
health: boolean;
mediaSources: boolean;
version: boolean;
watermarks: boolean;
}
export type SettingsScreenQueryState =
| {
data: SettingsScreenData;
error: string | null;
refresh: () => void;
refreshResolutions: () => Promise<void>;
status: 'success';
}
| { data: null; error: string; refresh: () => void; refreshResolutions: () => Promise<void>; status: 'error' }
| { data: null; error: null; refresh: () => void; refreshResolutions: () => Promise<void>; status: 'loading' };
type SettingsScreenState =
| { data: SettingsScreenData; error: string | null; status: 'success' }
| { data: null; error: string; status: 'error' }
| { data: null; error: null; status: 'loading' };
// A settled tier-2 (reference-data) result: falls back to `fallback` on failure and
// records the failure so the UI can show an inline note instead of data.
function settled<T>(result: PromiseSettledResult<T>, fallback: T): { failed: boolean; value: T } {
return result.status === 'fulfilled' ? { failed: false, value: result.value } : { failed: true, value: fallback };
}
async function loadSettingsScreenData(): Promise<SettingsScreenData> {
// Tier 1 (critical): the settings groups themselves + resolutions. A failure here
// rejects and produces the screen-level error state (with retry).
const tier1 = Promise.all([
getFfmpegSettings(),
getPlayoutSettings(),
getXmltvSettings(),
getScannerSettings(),
getLoggingSettings(),
getUiSettings(),
getHdhrSettings(),
getResolutions()
]);
// Tier 2 (reference data): each settles independently. A failure never blocks the
// screen - the affected card degrades to an inline "Couldn't load X" note.
const tier2 = Promise.allSettled([
getFFmpegProfiles(),
getWatermarks(),
getFillerPresets(),
getMediaSources(),
getDashboardVersion(),
getDashboardHealth()
] as const);
const [ffmpeg, playout, xmltv, scanner, logging, ui, hdhr, resolutions] = await tier1;
const [profilesResult, watermarksResult, fillerPresetsResult, mediaSourcesResult, versionResult, healthResult] =
await tier2;
const ffmpegProfiles = settled<FFmpegProfile[]>(profilesResult, []);
const watermarks = settled<Watermark[]>(watermarksResult, []);
const fillerPresets = settled<FillerPreset[]>(fillerPresetsResult, []);
const mediaSources = settled<MediaSource[]>(mediaSourcesResult, []);
const version = settled<DashboardVersion | null>(versionResult, null);
const health = settled<HealthCheck[]>(healthResult, []);
return {
ffmpeg,
ffmpegProfiles: ffmpegProfiles.value,
fillerPresets: fillerPresets.value,
health: health.value,
hdhr,
logging,
mediaSources: mediaSources.value,
playout,
referenceFailures: {
ffmpegProfiles: ffmpegProfiles.failed,
fillerPresets: fillerPresets.failed,
health: health.failed,
mediaSources: mediaSources.failed,
version: version.failed,
watermarks: watermarks.failed
},
resolutions,
scanner,
ui,
version: version.value,
watermarks: watermarks.value,
xmltv
};
}
export function useSettingsScreenQuery(): SettingsScreenQueryState {
const [state, setState] = useState<SettingsScreenState>({ data: null, error: null, status: 'loading' });
const activeRef = useRef(true);
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
const load = useCallback(() => {
loadSettingsScreenData()
.then((data) => {
if (activeRef.current) {
setState({ data, error: null, status: 'success' });
}
})
.catch((error: unknown) => {
if (activeRef.current) {
setState({ data: null, error: messageFromSettingsError(error, 'Unable to load settings'), status: 'error' });
}
});
}, []);
useEffect(() => {
load();
}, [load]);
const refresh = useCallback(() => {
setState({ data: null, error: null, status: 'loading' });
load();
}, [load]);
const refreshResolutions = useCallback(async () => {
const resolutions = await getResolutions();
if (!activeRef.current) {
return;
}
setState((current) => {
if (current.status !== 'success') {
return current;
}
return { data: { ...current.data, resolutions }, error: current.error, status: 'success' };
});
}, []);
if (state.status === 'success') {
return { data: state.data, error: state.error, refresh, refreshResolutions, status: 'success' };
}
if (state.status === 'error') {
return { data: null, error: state.error, refresh, refreshResolutions, status: 'error' };
}
return { data: null, error: null, refresh, refreshResolutions, status: 'loading' };
}
export function messageFromSettingsError(error: unknown, fallback = 'Unable to load settings'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}