diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 53395d23d..9c74732f1 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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 = {}): Record = {}): Record { + return { + id: 31, + itemCount: 0, + lastScan: null, + mediaKind: 'Movies', + name: 'Movies', + ...overrides + }; +} + +function mediaSource(overrides: Record = {}): Record { + 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 = {}): Record { 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; 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)); } diff --git a/web/src/App.tsx b/web/src/App.tsx index 907921ea6..9a14922f7 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 ( + Loading libraries} subtitle="Fetching media sources and active scan status."> +
+ + Loading libraries +
+
+ ); +} + +function LibrariesErrorState({ error, refresh }: { error: string; refresh: () => void }) { + return ( + Libraries unavailable} + subtitle="The API returned an error while loading media sources." + actions={} + > +
+
+
+ ); +} + +function LibrariesEmptyState() { + return ( + No media sources} subtitle="Configure a source in the server setup screens before monitoring scans here."> +
No media sources returned
+
+ ); +} + +function LibrariesScreen() { + const query = useLibrariesScreenQuery(); + + if (query.status === 'loading') { + return ; + } + + if (query.status === 'error') { + return ; + } + + if (query.data.sources.length === 0) { + return ; + } + + 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 ( +
+
+
+ Sources +

Media Libraries

+

Monitor connected media sources and trigger per-library scans.

+
+
+ + +
+
+ + {query.error && ( +
+
+ )} + +
+ {query.data.sources.length} source{query.data.sources.length === 1 ? '' : 's'} + {totalItems.toLocaleString()} items + 0 ? 'live' : 'ok'} size={7} />{query.data.scanStatuses.length} active scan{query.data.scanStatuses.length === 1 ? '' : 's'} +
+ +
+ {query.data.sources.map((source) => ( + + ))} +
+ +
+

Connect a media source

+

Adding sources is deferred to the existing server setup screens.

+
+ +
+
+
+ ); +} + +function MediaSourceCard({ + onScanLibrary, + scanStatusesByLibraryId, + scanningLibraryIds, + source +}: { + onScanLibrary: (libraryId: number) => Promise; + scanStatusesByLibraryId: Map; + scanningLibraryIds: Set; + 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 ( +
+
+ {sourceKindIcon(source.kind)} +
+
+

{source.name}

+ +
+ {source.connectionAddress ?? 'Local connection'} +
+
+ + + + +
+
+ +
+ {source.libraries.length === 0 ? ( +
No synced libraries returned
+ ) : source.libraries.map((library) => ( + + ))} +
+ +
+ + {totalItems.toLocaleString()} items · {source.libraries.length} librar{source.libraries.length === 1 ? 'y' : 'ies'} +
+
+ ); +} + +function LibraryRow({ + library, + onScanLibrary, + scanStatus, + scanning +}: { + library: MediaSourceLibrary; + onScanLibrary: (libraryId: number) => Promise; + scanStatus: LibraryScanStatus | null; + scanning: boolean; +}) { + const scanActive = scanStatus !== null; + const disabled = scanning || scanActive; + + return ( +
+ {libraryMediaIcon(library.mediaKind)} +
+ {library.name} + {formatLibraryMediaKind(library.mediaKind)} · {library.itemCount.toLocaleString()} items + {library.lastScan ? `Last scan ${formatDateTime(library.lastScan)}` : 'Never scanned'} +
+
+ {scanStatus ? ( +
+ + Scanning +
+ ) : ( + Synced + )} + void onScanLibrary(library.id)} size="sm" title={`Scan ${library.name}`}> + +
+
+ ); +} + +function sourceKindIcon(kind: string): ReactNode { + return kind === 'Local' + ?