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>
285 lines
10 KiB
TypeScript
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;
|
|
}
|