Merge pull request 'feat(web): Libraries screen (#88)' (#128) from feat/88-libraries into main
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled

This commit was merged in pull request #128.
This commit is contained in:
2026-07-05 17:06:50 +00:00
5 changed files with 1397 additions and 1 deletions
+526
View File
@@ -1011,6 +1011,450 @@ describe('ChicoryTV SPA scaffold', () => {
expect(window.fetch).toHaveBeenCalledWith('/api/channels/state', expect.any(Object));
});
it('renders the Libraries screen from live media source and scan status APIs', async () => {
mockDashboardApi({
libraryScanStatuses: [{ libraryId: 31, percent: 0.625 }],
mediaSources: [
mediaSource({
connectionAddress: null,
id: 30,
kind: 'Local',
libraries: [
library({ id: 31, itemCount: 1250, lastScan: '2026-07-05T14:30:00Z', mediaKind: 'Movies', name: 'Movies' }),
library({ id: 32, itemCount: 14, lastScan: null, mediaKind: 'OtherVideos', name: 'Station IDs' })
],
name: 'Local'
}),
mediaSource({
connectionAddress: 'https://plex.example.test',
id: 40,
kind: 'Plex',
libraries: [
library({ id: 41, itemCount: 80, lastScan: '2026-07-04T08:00:00Z', mediaKind: 'Shows', name: 'TV Shows' })
],
name: 'Plex Server'
}),
mediaSource({
connectionAddress: 'https://jellyfin.example.test',
id: 50,
kind: 'Jellyfin',
libraries: [],
name: 'Jellyfin Home'
}),
mediaSource({
connectionAddress: 'https://emby.example.test',
id: 60,
kind: 'Emby',
libraries: [
library({ id: 61, itemCount: 22, lastScan: '2026-07-01T00:00:00Z', mediaKind: 'MusicVideos', name: 'Music Videos' })
],
name: 'Emby Archive'
})
]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
expect(await screen.findByRole('heading', { name: 'Media Libraries' })).toBeInTheDocument();
expect(screen.getByText('4 sources')).toBeInTheDocument();
expect(screen.getByText('1,366 items')).toBeInTheDocument();
expect(screen.getByRole('region', { name: 'Local media source' })).toBeInTheDocument();
expect(screen.getByText('Local connection')).toBeInTheDocument();
expect(screen.getByRole('region', { name: 'Plex Server media source' })).toBeInTheDocument();
expect(screen.getByText('https://plex.example.test')).toBeInTheDocument();
expect(screen.getAllByText('Movies').length).toBeGreaterThan(0);
expect(screen.getByText('Station IDs')).toBeInTheDocument();
expect(screen.getByText('TV Shows')).toBeInTheDocument();
expect(screen.getAllByText('Music Videos').length).toBeGreaterThan(0);
expect(screen.getByText('Other Videos')).toBeInTheDocument();
expect(screen.getAllByText('Music Videos').length).toBeGreaterThan(0);
// "Scanning" now appears twice: once as the source-header status label (paired with
// the StatusDot, not color alone) and once as the per-library scanning Badge.
expect(screen.getAllByText('Scanning').length).toBeGreaterThan(0);
// Wire contract: percent is a 0-1 fraction (0.625) - the UI must render it as 63%, not 0.625%.
expect(screen.getByText('63%')).toBeInTheDocument();
expect(screen.getAllByText('Synced').length).toBeGreaterThan(0);
expect(screen.getAllByText('Never scanned').length).toBeGreaterThan(0);
expect(screen.getByRole('button', { name: 'Add Source' })).toBeDisabled();
expect(screen.getByText('Adding sources is deferred to the existing server setup screens.')).toBeInTheDocument();
expect(window.fetch).toHaveBeenCalledWith('/api/media-sources', expect.any(Object));
expect(window.fetch).toHaveBeenCalledWith('/api/libraries/scan-status', expect.any(Object));
});
it('converts 0 and 1 fractional scan-status percents to 0% and 100%', async () => {
mockDashboardApi({
libraryScanStatuses: [
{ libraryId: 31, percent: 0 },
{ libraryId: 32, percent: 1 }
],
mediaSources: [
mediaSource({
libraries: [
library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' }),
library({ id: 32, itemCount: 5, mediaKind: 'Shows', name: 'TV Shows' })
]
})
]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
expect(await screen.findByText('0%')).toBeInTheDocument();
expect(screen.getByText('100%')).toBeInTheDocument();
});
it('fetches Libraries data as a route delta and does not poll sources', async () => {
const intervalHandlers: Array<() => void> = [];
vi.spyOn(window, 'setInterval').mockImplementation((handler: TimerHandler, timeout?: number) => {
if (typeof handler === 'function' && (timeout ?? 0) >= 10000) {
intervalHandlers.push(handler as () => void);
}
return intervalHandlers.length;
});
vi.spyOn(window, 'clearInterval').mockImplementation(() => undefined);
mockDashboardApi({
libraryScanStatuses: [{ libraryId: 31, percent: 0.1 }],
mediaSources: [
mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] })
]
});
render(<App />);
await waitFor(() => {
expect(fetchCount('/api/media-sources')).toBeGreaterThan(0);
});
const sourceFetchesBeforeNavigation = fetchCount('/api/media-sources');
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
expect(await screen.findByRole('heading', { name: 'Media Libraries' })).toBeInTheDocument();
expect(fetchCount('/api/media-sources')).toBe(sourceFetchesBeforeNavigation + 1);
expect(fetchCount('/api/libraries/scan-status')).toBe(1);
const sourceFetchesBeforePoll = fetchCount('/api/media-sources');
intervalHandlers.forEach((handler) => handler());
await waitFor(() => {
expect(fetchCount('/api/libraries/scan-status')).toBeGreaterThan(1);
});
expect(fetchCount('/api/media-sources')).toBe(sourceFetchesBeforePoll);
});
it('stops Libraries scan polling and refreshes sources once when scans complete', async () => {
const intervalHandlers: Array<() => void> = [];
let clearCount = 0;
vi.spyOn(window, 'setInterval').mockImplementation((handler: TimerHandler) => {
if (typeof handler === 'function') {
intervalHandlers.push(handler as () => void);
}
return intervalHandlers.length;
});
vi.spyOn(window, 'clearInterval').mockImplementation(() => {
clearCount += 1;
});
mockDashboardApi({
libraryScanStatusSequence: [
[{ libraryId: 31, percent: 0.75 }],
[]
],
mediaSources: [
mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] })
]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
expect(await screen.findByText('75%')).toBeInTheDocument();
const sourceFetchesBeforeCompletion = fetchCount('/api/media-sources');
intervalHandlers.forEach((handler) => handler());
await waitFor(() => {
expect(screen.queryByText('75%')).not.toBeInTheDocument();
});
expect(fetchCount('/api/media-sources')).toBe(sourceFetchesBeforeCompletion + 1);
expect(clearCount).toBeGreaterThan(0);
});
it('triggers a library scan, disables that library while in flight, and starts polling scan status', async () => {
const scanStatusesAfterMutation = [{ libraryId: 31, percent: 0.05 }];
mockDashboardApi({
libraryScanStatuses: [],
libraryScanStatusesAfterMutation: scanStatusesAfterMutation,
mediaSources: [
mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] })
]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
expect((await screen.findAllByText('Movies')).length).toBeGreaterThan(0);
fireEvent.click(screen.getByRole('button', { name: 'Scan Movies' }));
await waitFor(() => {
expect(window.fetch).toHaveBeenCalledWith('/api/libraries/31/scan', expect.objectContaining({ method: 'POST' }));
});
expect(await screen.findByText('5%')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled();
});
it('keeps the scan button disabled and polling armed through the queue-to-start race, then clears once the scan finishes', async () => {
const intervalHandlers: Array<() => void> = [];
let clearCount = 0;
vi.spyOn(window, 'setInterval').mockImplementation((handler: TimerHandler, timeout?: number) => {
if (typeof handler === 'function' && (timeout ?? 0) >= 10000) {
intervalHandlers.push(handler as () => void);
}
return intervalHandlers.length;
});
vi.spyOn(window, 'clearInterval').mockImplementation(() => {
clearCount += 1;
});
mockDashboardApi({
libraryScanStatusSequence: [
[], // initial screen load
[], // trigger's immediate post-POST fetch: scanner hasn't started yet
[{ libraryId: 31, percent: 0.3 }], // first poll tick: now active
[] // second poll tick: scan finished
],
mediaSources: [
mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] })
]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
expect((await screen.findAllByText('Movies')).length).toBeGreaterThan(0);
fireEvent.click(screen.getByRole('button', { name: 'Scan Movies' }));
await waitFor(() => {
expect(window.fetch).toHaveBeenCalledWith('/api/libraries/31/scan', expect.objectContaining({ method: 'POST' }));
});
// The immediate post-trigger status fetch returned [] - without pending-id tracking
// the button would re-enable here and polling would never start. Both must hold.
await waitFor(() => {
expect(fetchCount('/api/libraries/scan-status')).toBe(2);
});
expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled();
expect(intervalHandlers.length).toBeGreaterThan(0);
const sourceFetchesBeforeCompletion = fetchCount('/api/media-sources');
intervalHandlers.forEach((handler) => handler());
expect(await screen.findByText('30%')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled();
intervalHandlers.forEach((handler) => handler());
await waitFor(() => {
expect(screen.queryByText('30%')).not.toBeInTheDocument();
});
expect(screen.getByRole('button', { name: 'Scan Movies' })).not.toBeDisabled();
expect(fetchCount('/api/media-sources')).toBe(sourceFetchesBeforeCompletion + 1);
expect(clearCount).toBeGreaterThan(0);
});
it('re-enables the scan button and stops polling when a queued scan never appears (grace window expiry)', async () => {
const intervalHandlers: Array<() => void> = [];
let clearCount = 0;
vi.spyOn(window, 'setInterval').mockImplementation((handler: TimerHandler, timeout?: number) => {
if (typeof handler === 'function' && (timeout ?? 0) >= 10000) {
intervalHandlers.push(handler as () => void);
}
return intervalHandlers.length;
});
vi.spyOn(window, 'clearInterval').mockImplementation(() => {
clearCount += 1;
});
mockDashboardApi({
libraryScanStatuses: [],
mediaSources: [
mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] })
]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
expect((await screen.findAllByText('Movies')).length).toBeGreaterThan(0);
fireEvent.click(screen.getByRole('button', { name: 'Scan Movies' }));
await waitFor(() => {
expect(window.fetch).toHaveBeenCalledWith('/api/libraries/31/scan', expect.objectContaining({ method: 'POST' }));
});
await waitFor(() => {
expect(fetchCount('/api/libraries/scan-status')).toBe(2);
});
expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled();
expect(intervalHandlers.length).toBeGreaterThan(0);
// The id never shows up in scan-status. It survives a couple of ticks...
intervalHandlers.forEach((handler) => handler());
await waitFor(() => {
expect(fetchCount('/api/libraries/scan-status')).toBe(3);
});
expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled();
// ...but expires once the grace window runs out, freeing the button and the poll.
intervalHandlers.forEach((handler) => handler());
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Scan Movies' })).not.toBeDisabled();
});
expect(clearCount).toBeGreaterThan(0);
});
it('re-enables the scan button and stops polling when scan-status errors repeatedly after a trigger (grace window on error)', async () => {
const intervalHandlers: Array<() => void> = [];
let clearCount = 0;
vi.spyOn(window, 'setInterval').mockImplementation((handler: TimerHandler, timeout?: number) => {
if (typeof handler === 'function' && (timeout ?? 0) >= 10000) {
intervalHandlers.push(handler as () => void);
}
return intervalHandlers.length;
});
vi.spyOn(window, 'clearInterval').mockImplementation(() => {
clearCount += 1;
});
mockDashboardApi({
libraryScanStatuses: [],
libraryScanStatusFailAfterTrigger: true,
mediaSources: [
mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] })
]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
expect((await screen.findAllByText('Movies')).length).toBeGreaterThan(0);
fireEvent.click(screen.getByRole('button', { name: 'Scan Movies' }));
await waitFor(() => {
expect(window.fetch).toHaveBeenCalledWith('/api/libraries/31/scan', expect.objectContaining({ method: 'POST' }));
});
// The immediate post-trigger status fetch errors (first grace-tick burn), but the
// button must stay disabled and the poll must stay armed - a transient failure must
// not be indistinguishable from "give up immediately".
await waitFor(() => {
expect(fetchCount('/api/libraries/scan-status')).toBe(2);
});
expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled();
expect(intervalHandlers.length).toBeGreaterThan(0);
// scan-status keeps erroring on every poll tick...
intervalHandlers.forEach((handler) => handler());
await waitFor(() => {
expect(fetchCount('/api/libraries/scan-status')).toBe(3);
});
expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled();
// ...but the same grace budget burns down on failures too, so persistent failure
// eventually frees the button and stops the interval instead of polling forever.
intervalHandlers.forEach((handler) => handler());
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Scan Movies' })).not.toBeDisabled();
});
expect(clearCount).toBeGreaterThan(0);
});
it('shows Libraries API errors and retries', async () => {
// The backend has no exception middleware, so a real failure is a bare 500 with no
// ProblemDetails body - do not invent one here (mirrors the scan-trigger-404 test below).
mockDashboardApi({
mediaSourcesFailuresBeforeSuccess: 2,
mediaSources: [mediaSource({ libraries: [] })]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
expect(await screen.findByText('Request failed with status 500')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
expect(await screen.findByRole('heading', { name: 'Media Libraries' })).toBeInTheDocument();
});
it('shows scan trigger failures without inventing application ProblemDetails', async () => {
mockDashboardApi({
mediaSources: [
mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] })
],
mutationFailures: {
'/api/libraries/31/scan': {
status: 404
}
}
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
expect((await screen.findAllByText('Movies')).length).toBeGreaterThan(0);
fireEvent.click(screen.getByRole('button', { name: 'Scan Movies' }));
expect(await screen.findByText('Request failed with status 404')).toBeInTheDocument();
});
it('scans every library in a source when its Scan-all button is clicked', async () => {
mockDashboardApi({
mediaSources: [
mediaSource({
id: 30,
libraries: [
library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' }),
library({ id: 32, itemCount: 5, mediaKind: 'Shows', name: 'TV Shows' })
],
name: 'Local'
})
]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
expect(await screen.findByText('TV Shows')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Scan all libraries in Local' }));
await waitFor(() => {
expect(window.fetch).toHaveBeenCalledWith('/api/libraries/31/scan', expect.objectContaining({ method: 'POST' }));
});
await waitFor(() => {
expect(window.fetch).toHaveBeenCalledWith('/api/libraries/32/scan', expect.objectContaining({ method: 'POST' }));
});
expect(fetchCount('/api/libraries/31/scan')).toBe(1);
expect(fetchCount('/api/libraries/32/scan')).toBe(1);
});
it('shows an empty Libraries state when no media sources exist', async () => {
mockDashboardApi({ mediaSources: [] });
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
expect(await screen.findByText('No media sources returned')).toBeInTheDocument();
});
it('toggles Show filler to refetch playout items with showFiller=true and badge filler rows', async () => {
mockDashboardApi({
playoutItems: [
@@ -1444,6 +1888,28 @@ function collection(overrides: Record<string, unknown> = {}): Record<string, unk
};
}
function library(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: 31,
itemCount: 0,
lastScan: null,
mediaKind: 'Movies',
name: 'Movies',
...overrides
};
}
function mediaSource(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
connectionAddress: null,
id: 30,
kind: 'Local',
libraries: [],
name: 'Local',
...overrides
};
}
// PlayoutListItemResponseModel: rail rows have no detail-only fields (playoutMode/scheduleFile).
function listPlayout(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
@@ -1593,7 +2059,13 @@ function mockDashboardApi({
confirm = false,
fillerPresets = [],
health = [],
libraryScanStatusFailAfterTrigger = false,
libraryScanStatuses = [],
libraryScanStatusesAfterMutation = null,
libraryScanStatusSequence = null,
mediaSources = [],
mediaSourcesFailure = null,
mediaSourcesFailuresBeforeSuccess = 0,
multiCollections = [],
mutationFailures = {},
playoutDetails = null,
@@ -1622,7 +2094,13 @@ function mockDashboardApi({
confirm?: boolean;
fillerPresets?: unknown[];
health?: unknown[];
libraryScanStatusFailAfterTrigger?: boolean;
libraryScanStatuses?: unknown[];
libraryScanStatusesAfterMutation?: unknown[] | null;
libraryScanStatusSequence?: unknown[][] | null;
mediaSources?: unknown[];
mediaSourcesFailure?: unknown;
mediaSourcesFailuresBeforeSuccess?: number;
multiCollections?: unknown[];
mutationFailures?: Record<string, unknown>;
playoutDetails?: unknown;
@@ -1646,9 +2124,13 @@ function mockDashboardApi({
} = {}) {
vi.spyOn(window, 'confirm').mockReturnValue(confirm);
vi.spyOn(window, 'prompt').mockReturnValue(prompt);
let currentLibraryScanStatuses = libraryScanStatuses;
let currentScheduleItems = scheduleItems;
let remainingMediaSourcesFailures = mediaSourcesFailuresBeforeSuccess;
let remainingScheduleItemFailures = scheduleItemFailuresBeforeSuccess;
let remainingPlayoutItemsFailures = playoutItemsFailuresBeforeSuccess;
let scanStatusSequenceIndex = 0;
let scanStatusShouldFail = false;
vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
const path = input.toString();
@@ -1723,9 +2205,53 @@ function mockDashboardApi({
}
if (path === '/api/media-sources') {
if (remainingMediaSourcesFailures > 0) {
remainingMediaSourcesFailures -= 1;
return Promise.resolve(
mediaSourcesFailure ? jsonResponse(mediaSourcesFailure, 500) : new Response(null, { status: 500 })
);
}
return Promise.resolve(jsonResponse(mediaSources));
}
if (path === '/api/libraries/scan-status') {
if (libraryScanStatusFailAfterTrigger && scanStatusShouldFail) {
return Promise.resolve(new Response(null, { status: 500 }));
}
if (libraryScanStatusSequence) {
const sequenceValue = libraryScanStatusSequence[Math.min(scanStatusSequenceIndex, libraryScanStatusSequence.length - 1)];
scanStatusSequenceIndex += 1;
currentLibraryScanStatuses = sequenceValue;
return Promise.resolve(jsonResponse(sequenceValue));
}
return Promise.resolve(jsonResponse(currentLibraryScanStatuses));
}
if (path.match(/^\/api\/libraries\/\d+\/scan$/)) {
if (path in mutationFailures) {
const failure = mutationFailures[path] as { detail?: string; status?: number; title?: string };
const status = failure.status ?? 422;
if (failure.detail || failure.title) {
return Promise.resolve(jsonResponse(failure, status));
}
return Promise.resolve(new Response(null, { status }));
}
currentLibraryScanStatuses = libraryScanStatusesAfterMutation ?? currentLibraryScanStatuses;
if (libraryScanStatusFailAfterTrigger) {
scanStatusShouldFail = true;
}
return Promise.resolve(new Response(null, { status: 200 }));
}
if (path === '/api/playouts') {
return Promise.resolve(jsonResponse(playouts));
}
+274
View File
@@ -15,27 +15,33 @@ import {
Cast,
Check,
ChevronDown,
Clapperboard,
CircleHelp,
Clock,
ClipboardCopy,
Copy,
Film,
FileImage,
Folder,
FolderInput,
FolderTree,
GripVertical,
Hash,
HardDrive,
Info,
LayoutDashboard,
LayoutGrid,
Library,
ListVideo,
MonitorPlay,
Music,
Plus,
Pencil,
Play,
Radio,
RefreshCw,
Search,
Server,
Settings,
Shuffle,
Sparkles,
@@ -81,12 +87,16 @@ import {
useDashboardHealthQuery,
useDashboardQuery,
useDashboardVersionQuery,
useLibrariesScreenQuery,
type ChannelState,
type ChannelSummary,
type DashboardChannel,
type SchedulePickerData,
type DashboardChannelState,
type DashboardHealthQueryState,
type LibraryScanStatus,
type MediaSource,
type MediaSourceLibrary,
type MediaCollection,
type ProgramSchedule,
type ProgramScheduleItem,
@@ -1821,6 +1831,266 @@ function formatScheduleCollectionType(value: string | undefined): string {
return formatScheduleEnum(value ?? 'Collection');
}
function LibrariesLoadingState() {
return (
<Card title={<h2>Loading libraries</h2>} subtitle="Fetching media sources and active scan status.">
<div className="ctv-dashboard-state">
<Spinner tone="muted" />
<span>Loading libraries</span>
</div>
</Card>
);
}
function LibrariesErrorState({ error, refresh }: { error: string; refresh: () => void }) {
return (
<Card
title={<h2>Libraries unavailable</h2>}
subtitle="The API returned an error while loading media sources."
actions={<Button onClick={refresh} startIcon={<RefreshCw aria-hidden="true" size={15} />} variant="secondary">Retry</Button>}
>
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{error}</span>
</div>
</Card>
);
}
function LibrariesEmptyState() {
return (
<Card title={<h2>No media sources</h2>} subtitle="Configure a source in the server setup screens before monitoring scans here.">
<div className="ctv-schedule-empty">No media sources returned</div>
</Card>
);
}
function LibrariesScreen() {
const query = useLibrariesScreenQuery();
if (query.status === 'loading') {
return <LibrariesLoadingState />;
}
if (query.status === 'error') {
return <LibrariesErrorState error={query.error} refresh={query.refresh} />;
}
if (query.data.sources.length === 0) {
return <LibrariesEmptyState />;
}
const totalItems = query.data.sources.reduce(
(sum, source) => sum + source.libraries.reduce((librarySum, library) => librarySum + library.itemCount, 0),
0
);
const scanStatusesByLibraryId = new Map(query.data.scanStatuses.map((status) => [status.libraryId, status]));
return (
<div className="ctv-libraries-screen">
<section className="ctv-libraries-header">
<div>
<span className="ctv-kicker">Sources</span>
<h2>Media Libraries</h2>
<p>Monitor connected media sources and trigger per-library scans.</p>
</div>
<div className="ctv-libraries-header-actions">
<Button disabled startIcon={<RefreshCw aria-hidden="true" size={15} />} variant="secondary">Scan All</Button>
<Button disabled startIcon={<Plus aria-hidden="true" size={15} />}>Add Source</Button>
</div>
</section>
{query.error && (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
<span>{query.error}</span>
</div>
)}
<div className="ctv-libraries-summary">
<span>{query.data.sources.length} source{query.data.sources.length === 1 ? '' : 's'}</span>
<span>{totalItems.toLocaleString()} items</span>
<span><StatusDot status={query.data.scanStatuses.length > 0 ? 'live' : 'ok'} size={7} />{query.data.scanStatuses.length} active scan{query.data.scanStatuses.length === 1 ? '' : 's'}</span>
</div>
<div className="ctv-libraries-grid">
{query.data.sources.map((source) => (
<MediaSourceCard
key={source.id}
onScanLibrary={query.scanLibrary}
scanStatusesByLibraryId={scanStatusesByLibraryId}
scanningLibraryIds={query.scanningLibraryIds}
source={source}
/>
))}
<section className="ctv-library-add-card" aria-label="Add media source">
<span className="ctv-library-kind-icon ctv-library-kind-local"><Plus aria-hidden="true" size={18} /></span>
<div>
<h3>Connect a media source</h3>
<p>Adding sources is deferred to the existing server setup screens.</p>
</div>
<div className="ctv-library-add-options" aria-hidden="true">
<span><HardDrive size={13} />Local folder</span>
<span><Server size={13} />Plex</span>
<span><Server size={13} />Jellyfin</span>
<span><Server size={13} />Emby</span>
</div>
</section>
</div>
</div>
);
}
function MediaSourceCard({
onScanLibrary,
scanStatusesByLibraryId,
scanningLibraryIds,
source
}: {
onScanLibrary: (libraryId: number) => Promise<void>;
scanStatusesByLibraryId: Map<number, LibraryScanStatus>;
scanningLibraryIds: Set<number>;
source: MediaSource;
}) {
const totalItems = source.libraries.reduce((sum, library) => sum + library.itemCount, 0);
const scanning = source.libraries.some((library) => scanningLibraryIds.has(library.id));
const scanSource = () => {
source.libraries.forEach((library) => {
void onScanLibrary(library.id);
});
};
return (
<section className="ctv-library-source-card" aria-label={`${source.name} media source`}>
<div className="ctv-library-source-head">
<span className={`ctv-library-kind-icon ctv-library-kind-${source.kind.toLowerCase()}`}>{sourceKindIcon(source.kind)}</span>
<div>
<div className="ctv-library-source-title">
<h3>{source.name}</h3>
<StatusDot status={scanning ? 'live' : 'ok'} label={scanning ? 'Scanning' : 'Synced'} size={7} />
</div>
<span>{source.connectionAddress ?? 'Local connection'}</span>
</div>
<div className="ctv-library-source-actions">
<IconButton disabled={scanning} onClick={scanSource} size="sm" title={`Scan all libraries in ${source.name}`}>
<RefreshCw aria-hidden="true" size={15} />
</IconButton>
<IconButton disabled size="sm" title={`Settings unavailable for ${source.name}`}>
<Settings aria-hidden="true" size={15} />
</IconButton>
</div>
</div>
<div className="ctv-library-list" role="list" aria-label={`${source.name} libraries`}>
{source.libraries.length === 0 ? (
<div className="ctv-library-empty">No synced libraries returned</div>
) : source.libraries.map((library) => (
<LibraryRow
key={library.id}
library={library}
onScanLibrary={onScanLibrary}
scanStatus={scanStatusesByLibraryId.get(library.id) ?? null}
scanning={scanningLibraryIds.has(library.id)}
/>
))}
</div>
<div className="ctv-library-source-foot">
<span><Clock aria-hidden="true" size={13} />{scanning ? 'Scanning now' : sourceLastScanLabel(source)}</span>
<span><code>{totalItems.toLocaleString()}</code> items · {source.libraries.length} librar{source.libraries.length === 1 ? 'y' : 'ies'}</span>
</div>
</section>
);
}
function LibraryRow({
library,
onScanLibrary,
scanStatus,
scanning
}: {
library: MediaSourceLibrary;
onScanLibrary: (libraryId: number) => Promise<void>;
scanStatus: LibraryScanStatus | null;
scanning: boolean;
}) {
const scanActive = scanStatus !== null;
const disabled = scanning || scanActive;
return (
<div className="ctv-library-row" role="listitem">
<span className="ctv-library-media-icon">{libraryMediaIcon(library.mediaKind)}</span>
<div className="ctv-library-row-main">
<strong>{library.name}</strong>
<span><span>{formatLibraryMediaKind(library.mediaKind)}</span> · <code>{library.itemCount.toLocaleString()}</code> items</span>
<small>{library.lastScan ? `Last scan ${formatDateTime(library.lastScan)}` : 'Never scanned'}</small>
</div>
<div className="ctv-library-row-status">
{scanStatus ? (
<div className="ctv-library-progress">
<ProgressBar value={scanStatus.percent} showLabel />
<Badge tone="accent" dot>Scanning</Badge>
</div>
) : (
<Badge tone="ok" dot>Synced</Badge>
)}
<IconButton disabled={disabled} onClick={() => void onScanLibrary(library.id)} size="sm" title={`Scan ${library.name}`}>
<RefreshCw aria-hidden="true" size={15} />
</IconButton>
</div>
</div>
);
}
function sourceKindIcon(kind: string): ReactNode {
return kind === 'Local'
? <HardDrive aria-hidden="true" size={18} />
: <Server aria-hidden="true" size={18} />;
}
function libraryMediaIcon(kind: string): ReactNode {
switch (kind) {
case 'Movies':
return <Clapperboard aria-hidden="true" size={14} />;
case 'Shows':
return <MonitorPlay aria-hidden="true" size={14} />;
case 'MusicVideos':
case 'Songs':
return <Music aria-hidden="true" size={14} />;
case 'Images':
return <FileImage aria-hidden="true" size={14} />;
case 'RemoteStreams':
return <Radio aria-hidden="true" size={14} />;
case 'OtherVideos':
return <Film aria-hidden="true" size={14} />;
default:
return <Folder aria-hidden="true" size={14} />;
}
}
function formatLibraryMediaKind(kind: string): string {
switch (kind) {
case 'MusicVideos':
return 'Music Videos';
case 'OtherVideos':
return 'Other Videos';
case 'RemoteStreams':
return 'Remote Streams';
default:
return kind;
}
}
function sourceLastScanLabel(source: MediaSource): string {
const scans = source.libraries
.map((library) => library.lastScan)
.filter((scan): scan is string => Boolean(scan))
.sort();
return scans.length > 0 ? `Last scan ${formatDateTime(scans[scans.length - 1])}` : 'Never scanned';
}
function PlayoutsLoadingState() {
return (
<Card title={<h2>Loading playouts</h2>} subtitle="Fetching playouts, channel state, and upcoming items.">
@@ -2278,6 +2548,10 @@ function ScreenContent({
return <PlayoutsScreen />;
}
if (route.id === 'libraries') {
return <LibrariesScreen />;
}
return <PlaceholderScreen route={route} />;
}
+1
View File
@@ -2,6 +2,7 @@ export * from './auth';
export * from './channels';
export * from './client';
export * from './dashboard';
export * from './libraries';
export * from './playouts';
export * from './schedules';
export * from './useChannelsQuery';
+337
View File
@@ -0,0 +1,337 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ApiError, request } from './client';
import type { components } from './generated/v1';
export type MediaSource = components['schemas']['MediaSourceResponseModel'];
export type MediaSourceLibrary = components['schemas']['MediaSourceLibraryResponseModel'];
export type LibraryScanStatus = components['schemas']['LibraryScanStatusResponseModel'];
export interface LibrariesScreenData {
scanStatuses: LibraryScanStatus[];
sources: MediaSource[];
}
export type LibrariesScreenQueryState =
| {
data: LibrariesScreenData;
error: string | null;
refresh: () => void;
scanLibrary: (libraryId: number) => Promise<void>;
scanningLibraryIds: Set<number>;
status: 'success';
}
| { data: null; error: string; refresh: () => void; status: 'error' }
| { data: null; error: null; refresh: () => void; status: 'loading' };
type LibrariesScreenState =
| { data: LibrariesScreenData; error: string | null; pendingLibraryIds: Set<number>; status: 'success' }
| { data: null; error: string; status: 'error' }
| { data: null; error: null; status: 'loading' };
// A trigger'd scan disappears from the button-disabled set once either: it has been
// observed at least once in scan-status (promoted to "active"), or this many poll
// ticks pass without ever appearing (the scanner never picked it up / it failed silently).
const PENDING_GRACE_TICKS = 3;
export function getMediaSources(): Promise<MediaSource[]> {
return request<MediaSource[]>('/api/media-sources');
}
export function getLibraryScanStatus(): Promise<LibraryScanStatus[]> {
// Wire contract: `percent` is a 0-1 FRACTION despite the field name - the backend
// never multiplies by 100 (known backend wart, tracked in the handoff backlog).
// Normalize to a 0-100 percentage once, here at the API boundary, so every
// consumer (ProgressBar, percent labels) works in ordinary percentage terms.
return request<LibraryScanStatus[]>('/api/libraries/scan-status').then((scanStatuses) =>
scanStatuses.map((scanStatus) => ({ ...scanStatus, percent: scanStatus.percent * 100 }))
);
}
export function scanLibrary(libraryId: number): Promise<void> {
return request<void>(`/api/libraries/${libraryId}/scan`, { method: 'POST' });
}
// Pure: computes the surviving pending-id set for one poll tick (success or failure) and
// mutates the grace-ticks map in place (delete on promote/expire, set on decrement) -
// callers must still write pendingIdsRef.current with the returned set themselves, and
// must call this exactly once per tick before that write to keep it a single, ref-free
// computation that's safe to run under StrictMode double-invocation.
function pruneGraceExpiredPending(
pendingIds: Set<number>,
graceTicks: Map<number, number>,
isSeenActive: (libraryId: number) => boolean
): Set<number> {
const nextPending = new Set<number>();
pendingIds.forEach((libraryId) => {
if (isSeenActive(libraryId)) {
// Seen active at least once - normal active/inactive pruning takes over.
graceTicks.delete(libraryId);
return;
}
const ticksRemaining = (graceTicks.get(libraryId) ?? PENDING_GRACE_TICKS) - 1;
if (ticksRemaining <= 0) {
// Grace window expired without ever appearing in scan-status - give up on it.
graceTicks.delete(libraryId);
return;
}
graceTicks.set(libraryId, ticksRemaining);
nextPending.add(libraryId);
});
return nextPending;
}
export function useLibrariesScreenQuery(pollMs = 10000): LibrariesScreenQueryState {
const [state, setState] = useState<LibrariesScreenState>({
data: null,
error: null,
status: 'loading'
});
const activeRef = useRef(true);
const hadScanInProgressRef = useRef(false);
// Mirrors of the corresponding state, kept in sync synchronously so triggerScan can
// guard against double-submits without waiting for a render.
const pendingIdsRef = useRef<Set<number>>(new Set());
const activeIdsRef = useRef<Set<number>>(new Set());
const pendingGraceTicksRef = useRef<Map<number, number>>(new Map());
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
const loadSources = useCallback(() => {
getMediaSources()
.then((sources) => {
if (!activeRef.current) {
return;
}
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: { scanStatuses: current.data.scanStatuses, sources },
error: null,
pendingLibraryIds: current.pendingLibraryIds,
status: 'success'
};
});
})
.catch(() => {
// A completion refresh should not hide the screen if the source reload fails.
});
}, []);
const loadScanStatuses = useCallback(() => {
return getLibraryScanStatus()
.then((scanStatuses) => {
if (!activeRef.current) {
return;
}
const activeScanIds = new Set(scanStatuses.map((scan) => scan.libraryId));
activeIdsRef.current = activeScanIds;
const hadScanInProgress = hadScanInProgressRef.current;
hadScanInProgressRef.current = scanStatuses.length > 0;
// Compute the pruned/promoted pending set - and write the grace-tick and pending
// refs - OUTSIDE the setState updater. Updaters must be pure: StrictMode double-
// invokes them (which would double-decrement grace ticks) and concurrent rendering
// may invoke-and-discard one. Mirrors how activeIdsRef is written above.
const nextPending = pruneGraceExpiredPending(pendingIdsRef.current, pendingGraceTicksRef.current, (libraryId) =>
activeScanIds.has(libraryId)
);
pendingIdsRef.current = nextPending;
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: { scanStatuses, sources: current.data.sources },
error: null,
pendingLibraryIds: nextPending,
status: 'success'
};
});
if (hadScanInProgress && scanStatuses.length === 0) {
loadSources();
}
})
.catch(() => {
if (!activeRef.current) {
return;
}
// A status-fetch failure never promotes a pending id to active, so it never
// survives via "seen active" - it just burns down the same grace budget as a
// successful poll that never saw it. This keeps a persistently-erroring endpoint
// from leaving the scan button disabled and the poll interval running forever.
// Active-scan state (activeIdsRef / scanStatuses) is left untouched: a transient
// failure must not kill an in-progress scan's UI.
const nextPending = pruneGraceExpiredPending(pendingIdsRef.current, pendingGraceTicksRef.current, () => false);
pendingIdsRef.current = nextPending;
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: current.data,
error: current.error,
pendingLibraryIds: nextPending,
status: 'success'
};
});
});
}, [loadSources]);
const load = useCallback(() => {
Promise.all([getMediaSources(), getLibraryScanStatus()])
.then(([sources, scanStatuses]) => {
if (!activeRef.current) {
return;
}
hadScanInProgressRef.current = scanStatuses.length > 0;
pendingIdsRef.current = new Set();
activeIdsRef.current = new Set(scanStatuses.map((scan) => scan.libraryId));
pendingGraceTicksRef.current = new Map();
setState({
data: { scanStatuses, sources },
error: null,
pendingLibraryIds: new Set(),
status: 'success'
});
})
.catch((error: unknown) => {
if (activeRef.current) {
setState({ data: null, error: messageFromLibrariesError(error, 'Unable to load libraries'), status: 'error' });
}
});
}, []);
useEffect(() => {
load();
}, [load]);
const hasActiveScans =
state.status === 'success' && (state.pendingLibraryIds.size > 0 || state.data.scanStatuses.length > 0);
useEffect(() => {
if (!hasActiveScans) {
return undefined;
}
const intervalId = window.setInterval(loadScanStatuses, Math.max(pollMs, 10000));
return () => {
window.clearInterval(intervalId);
};
}, [hasActiveScans, loadScanStatuses, pollMs]);
const refresh = useCallback(() => {
setState({ data: null, error: null, status: 'loading' });
load();
}, [load]);
const triggerScan = useCallback((libraryId: number): Promise<void> => {
if (pendingIdsRef.current.has(libraryId) || activeIdsRef.current.has(libraryId)) {
// Already pending or active - ignore the duplicate submission.
return Promise.resolve();
}
pendingIdsRef.current = new Set(pendingIdsRef.current).add(libraryId);
pendingGraceTicksRef.current.set(libraryId, PENDING_GRACE_TICKS);
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: current.data,
error: null,
pendingLibraryIds: new Set(current.pendingLibraryIds).add(libraryId),
status: 'success'
};
});
return scanLibrary(libraryId)
.then(() => loadScanStatuses())
.catch((error: unknown) => {
const pendingIds = new Set(pendingIdsRef.current);
pendingIds.delete(libraryId);
pendingIdsRef.current = pendingIds;
pendingGraceTicksRef.current.delete(libraryId);
if (!activeRef.current) {
return;
}
setState((current) => {
if (current.status !== 'success') {
return current;
}
const pendingLibraryIds = new Set(current.pendingLibraryIds);
pendingLibraryIds.delete(libraryId);
return {
data: current.data,
error: messageFromLibrariesError(error, 'Unable to scan library'),
pendingLibraryIds,
status: 'success'
};
});
});
}, [loadScanStatuses]);
if (state.status === 'success') {
const activeLibraryIds = new Set(state.data.scanStatuses.map((scan) => scan.libraryId));
const scanningLibraryIds = new Set([...state.pendingLibraryIds, ...activeLibraryIds]);
return {
data: state.data,
error: state.error,
refresh,
scanLibrary: triggerScan,
scanningLibraryIds,
status: 'success'
};
}
if (state.status === 'error') {
return { data: null, error: state.error, refresh, status: 'error' };
}
return { data: null, error: null, refresh, status: 'loading' };
}
function messageFromLibrariesError(error: unknown, fallback = 'Unable to load libraries'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}
+259 -1
View File
@@ -1650,6 +1650,248 @@
font-size: var(--text-2xs, 11px);
}
.ctv-libraries-screen {
display: flex;
flex-direction: column;
min-height: 0;
gap: var(--space-6, 12px);
}
.ctv-libraries-header {
display: flex;
align-items: center;
gap: var(--space-7, 16px);
justify-content: space-between;
border: 1px solid var(--border-hairline);
border-radius: var(--radius-md, 7px);
background: var(--surface-card);
padding: var(--space-7, 16px) var(--space-8, 20px);
}
.ctv-libraries-header h2 {
margin: var(--space-2, 4px) 0;
color: var(--text-primary);
font-size: var(--text-xl, 20px);
line-height: 1.15;
}
.ctv-libraries-header p {
margin: 0;
color: var(--text-secondary);
font-size: var(--text-sm, 13px);
}
.ctv-libraries-header-actions,
.ctv-libraries-summary {
display: flex;
align-items: center;
gap: var(--space-5, 10px);
flex-wrap: wrap;
}
.ctv-libraries-summary {
color: var(--text-secondary);
font-size: var(--text-xs, 12px);
}
.ctv-libraries-summary span {
display: inline-flex;
align-items: center;
gap: var(--space-3, 6px);
}
.ctv-libraries-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
align-items: start;
gap: var(--space-7, 16px);
}
.ctv-library-source-card,
.ctv-library-add-card {
overflow: hidden;
border: 1px solid var(--border-hairline);
border-radius: var(--radius-md, 7px);
background: var(--surface-card);
}
.ctv-library-source-head {
display: grid;
grid-template-columns: 34px minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-5, 10px);
border-bottom: 1px solid var(--border-hairline);
padding: var(--space-6, 12px) var(--space-7, 16px);
}
.ctv-library-kind-icon,
.ctv-library-media-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
}
.ctv-library-source-actions {
display: flex;
align-items: center;
gap: var(--space-2, 4px);
}
.ctv-library-kind-icon {
width: 34px;
height: 34px;
border-radius: var(--radius-sm, 5px);
background: var(--ctv-surface-3);
color: var(--ctv-accent);
}
.ctv-library-kind-plex {
color: #e5a00d;
}
.ctv-library-kind-jellyfin {
color: #8a5cf6;
}
.ctv-library-kind-emby {
color: #52b54b;
}
.ctv-library-source-title {
display: flex;
align-items: center;
min-width: 0;
gap: var(--space-4, 8px);
}
.ctv-library-source-title h3,
.ctv-library-add-card h3 {
margin: 0;
color: var(--text-primary);
font-size: var(--text-sm, 13px);
line-height: 1.15;
}
.ctv-library-source-head span:not(.ctv-library-kind-icon),
.ctv-library-source-foot,
.ctv-library-row-main span,
.ctv-library-row-main small,
.ctv-library-add-card p {
color: var(--text-secondary);
font-size: var(--text-xs, 12px);
}
.ctv-library-list {
display: grid;
}
.ctv-library-row {
display: grid;
grid-template-columns: 28px minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-5, 10px);
border-top: 1px solid var(--border-hairline);
padding: var(--space-5, 10px) var(--space-7, 16px);
}
.ctv-library-row:first-child {
border-top: 0;
}
.ctv-library-media-icon {
width: 28px;
height: 28px;
border-radius: var(--radius-xs, 3px);
background: var(--ctv-bg-sunken);
color: var(--text-secondary);
}
.ctv-library-row-main {
display: grid;
min-width: 0;
gap: var(--space-2, 4px);
}
.ctv-library-row-main strong {
overflow: hidden;
color: var(--text-primary);
font-size: var(--text-sm, 13px);
text-overflow: ellipsis;
white-space: nowrap;
}
.ctv-library-row-status {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-4, 8px);
}
.ctv-library-progress {
display: grid;
width: 118px;
gap: var(--space-3, 6px);
}
.ctv-library-source-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-5, 10px);
border-top: 1px solid var(--border-hairline);
background: var(--ctv-bg-sunken);
padding: var(--space-5, 10px) var(--space-7, 16px);
}
.ctv-library-source-foot span {
display: inline-flex;
align-items: center;
gap: var(--space-3, 6px);
}
.ctv-library-empty {
color: var(--text-disabled);
font-size: var(--text-xs, 12px);
padding: var(--space-7, 16px);
}
.ctv-library-add-card {
display: flex;
min-height: 190px;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: var(--space-6, 12px);
border-style: dashed;
border-color: var(--border-control);
padding: var(--space-7, 16px);
}
.ctv-library-add-card p {
margin: var(--space-2, 4px) 0 0;
line-height: 1.4;
}
.ctv-library-add-options {
display: flex;
flex-wrap: wrap;
gap: var(--space-3, 6px);
}
.ctv-library-add-options span {
display: inline-flex;
align-items: center;
gap: var(--space-3, 6px);
border: 1px solid var(--border-hairline);
border-radius: var(--radius-sm, 5px);
background: var(--ctv-surface-2);
color: var(--text-secondary);
font-size: var(--text-xs, 12px);
font-weight: var(--weight-medium, 500);
padding: var(--space-3, 6px) var(--space-5, 10px);
}
@media (max-width: 980px) {
.ctv-app-shell {
grid-template-columns: 1fr;
@@ -1721,10 +1963,26 @@
.ctv-schedule-form-grid,
.ctv-playouts-grid,
.ctv-playout-detail-grid,
.ctv-playouts-cards {
.ctv-playouts-cards,
.ctv-libraries-grid {
grid-template-columns: 1fr;
}
.ctv-libraries-header,
.ctv-library-source-foot {
align-items: flex-start;
flex-direction: column;
}
.ctv-library-row {
grid-template-columns: 28px minmax(0, 1fr);
}
.ctv-library-row-status {
grid-column: 2 / -1;
justify-content: flex-start;
}
.ctv-schedule-block-main {
grid-template-columns: 16px 38px minmax(0, 1fr);
}