This commit was merged in pull request #128.
This commit is contained in:
@@ -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
@@ -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} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user