From 295004ab124d14a3535e9b23b73efff0000b4404 Mon Sep 17 00:00:00 2001 From: Timothy Date: Sat, 11 Jul 2026 15:23:14 +0200 Subject: [PATCH 01/12] feat(api): add media-source write API shared DTOs (#202 slice S0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Response DTOs (ErsatzTV.Core/Api/MediaSources) and request DTOs (ErsatzTV/Controllers/Api/Requests) per the #202 design doc §B — the shared shapes that backend slices S1 (local libraries), S2 (Plex), and S3 (Jellyfin/Emby) will consume. No controllers or handler changes; DTOs are unused so far. Notable: RemoteConnectionResponseModel deliberately never carries the raw API key (secure connection contract); SaveRemoteConnectionRequest's To{Jellyfin,Emby}Command(existingApiKey) retains the existing key when the incoming ApiKey is blank/omitted. No conventions changed; nothing to update in docs/api-conventions.md. --- .../LocalLibraryDetailResponseModel.cs | 11 +++++++ .../LocalLibraryPathResponseModel.cs | 3 ++ .../MediaSources/LocalLibraryResponseModel.cs | 5 ++++ .../LocalPathCheckResponseModel.cs | 3 ++ .../PathReplacementResponseModel.cs | 3 ++ .../MediaSources/PlexPinFlowResponseModel.cs | 3 ++ .../RemoteConnectionResponseModel.cs | 5 ++++ .../RemoteLibraryResponseModel.cs | 5 ++++ .../RemoteMediaSourceItemResponseModel.cs | 3 ++ .../RemoteMediaSourceStateResponseModel.cs | 6 ++++ .../Api/Requests/CreateLocalLibraryRequest.cs | 9 ++++++ .../Api/Requests/LocalPathCheckRequest.cs | 4 +++ .../Requests/MoveLocalLibraryPathRequest.cs | 8 +++++ .../Requests/PathReplacementItemRequest.cs | 5 ++++ .../RemoteLibraryPreferenceRequest.cs | 3 ++ .../ReplacePathReplacementsRequest.cs | 29 +++++++++++++++++++ .../ReplaceRemoteLibraryPreferencesRequest.cs | 29 +++++++++++++++++++ .../Requests/SaveRemoteConnectionRequest.cs | 29 +++++++++++++++++++ .../Requests/UpdateLocalLibraryPathRequest.cs | 10 +++++++ .../Api/Requests/UpdateLocalLibraryRequest.cs | 16 ++++++++++ 20 files changed, 189 insertions(+) create mode 100644 ErsatzTV.Core/Api/MediaSources/LocalLibraryDetailResponseModel.cs create mode 100644 ErsatzTV.Core/Api/MediaSources/LocalLibraryPathResponseModel.cs create mode 100644 ErsatzTV.Core/Api/MediaSources/LocalLibraryResponseModel.cs create mode 100644 ErsatzTV.Core/Api/MediaSources/LocalPathCheckResponseModel.cs create mode 100644 ErsatzTV.Core/Api/MediaSources/PathReplacementResponseModel.cs create mode 100644 ErsatzTV.Core/Api/MediaSources/PlexPinFlowResponseModel.cs create mode 100644 ErsatzTV.Core/Api/MediaSources/RemoteConnectionResponseModel.cs create mode 100644 ErsatzTV.Core/Api/MediaSources/RemoteLibraryResponseModel.cs create mode 100644 ErsatzTV.Core/Api/MediaSources/RemoteMediaSourceItemResponseModel.cs create mode 100644 ErsatzTV.Core/Api/MediaSources/RemoteMediaSourceStateResponseModel.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/CreateLocalLibraryRequest.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/LocalPathCheckRequest.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/MoveLocalLibraryPathRequest.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/PathReplacementItemRequest.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/RemoteLibraryPreferenceRequest.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/ReplacePathReplacementsRequest.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/ReplaceRemoteLibraryPreferencesRequest.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/SaveRemoteConnectionRequest.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/UpdateLocalLibraryPathRequest.cs create mode 100644 ErsatzTV/Controllers/Api/Requests/UpdateLocalLibraryRequest.cs diff --git a/ErsatzTV.Core/Api/MediaSources/LocalLibraryDetailResponseModel.cs b/ErsatzTV.Core/Api/MediaSources/LocalLibraryDetailResponseModel.cs new file mode 100644 index 000000000..2f824207a --- /dev/null +++ b/ErsatzTV.Core/Api/MediaSources/LocalLibraryDetailResponseModel.cs @@ -0,0 +1,11 @@ +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Core.Api.MediaSources; + +public record LocalLibraryDetailResponseModel( + int Id, + string Name, + LibraryMediaKind MediaKind, + bool IsLocked, + int MediaItemCount, + List Paths); diff --git a/ErsatzTV.Core/Api/MediaSources/LocalLibraryPathResponseModel.cs b/ErsatzTV.Core/Api/MediaSources/LocalLibraryPathResponseModel.cs new file mode 100644 index 000000000..9fd9084c0 --- /dev/null +++ b/ErsatzTV.Core/Api/MediaSources/LocalLibraryPathResponseModel.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Core.Api.MediaSources; + +public record LocalLibraryPathResponseModel(int Id, string Path, int MediaItemCount); diff --git a/ErsatzTV.Core/Api/MediaSources/LocalLibraryResponseModel.cs b/ErsatzTV.Core/Api/MediaSources/LocalLibraryResponseModel.cs new file mode 100644 index 000000000..7bb4bb8d2 --- /dev/null +++ b/ErsatzTV.Core/Api/MediaSources/LocalLibraryResponseModel.cs @@ -0,0 +1,5 @@ +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Core.Api.MediaSources; + +public record LocalLibraryResponseModel(int Id, string Name, LibraryMediaKind MediaKind, bool IsLocked); diff --git a/ErsatzTV.Core/Api/MediaSources/LocalPathCheckResponseModel.cs b/ErsatzTV.Core/Api/MediaSources/LocalPathCheckResponseModel.cs new file mode 100644 index 000000000..d8e192c96 --- /dev/null +++ b/ErsatzTV.Core/Api/MediaSources/LocalPathCheckResponseModel.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Core.Api.MediaSources; + +public record LocalPathCheckResponseModel(bool Exists); diff --git a/ErsatzTV.Core/Api/MediaSources/PathReplacementResponseModel.cs b/ErsatzTV.Core/Api/MediaSources/PathReplacementResponseModel.cs new file mode 100644 index 000000000..40883f44a --- /dev/null +++ b/ErsatzTV.Core/Api/MediaSources/PathReplacementResponseModel.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Core.Api.MediaSources; + +public record PathReplacementResponseModel(int Id, string RemotePath, string LocalPath); diff --git a/ErsatzTV.Core/Api/MediaSources/PlexPinFlowResponseModel.cs b/ErsatzTV.Core/Api/MediaSources/PlexPinFlowResponseModel.cs new file mode 100644 index 000000000..141db29f3 --- /dev/null +++ b/ErsatzTV.Core/Api/MediaSources/PlexPinFlowResponseModel.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Core.Api.MediaSources; + +public record PlexPinFlowResponseModel(string AuthUrl); diff --git a/ErsatzTV.Core/Api/MediaSources/RemoteConnectionResponseModel.cs b/ErsatzTV.Core/Api/MediaSources/RemoteConnectionResponseModel.cs new file mode 100644 index 000000000..c514a1af6 --- /dev/null +++ b/ErsatzTV.Core/Api/MediaSources/RemoteConnectionResponseModel.cs @@ -0,0 +1,5 @@ +namespace ErsatzTV.Core.Api.MediaSources; + +// NEVER include the raw API key in this DTO — the secure connection contract (design #202 finding 1) +// requires the key never leave the server; callers only learn whether one is configured. +public record RemoteConnectionResponseModel(string Address, bool HasApiKey); diff --git a/ErsatzTV.Core/Api/MediaSources/RemoteLibraryResponseModel.cs b/ErsatzTV.Core/Api/MediaSources/RemoteLibraryResponseModel.cs new file mode 100644 index 000000000..307f0b853 --- /dev/null +++ b/ErsatzTV.Core/Api/MediaSources/RemoteLibraryResponseModel.cs @@ -0,0 +1,5 @@ +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Core.Api.MediaSources; + +public record RemoteLibraryResponseModel(int Id, string Name, LibraryMediaKind MediaKind, bool ShouldSyncItems); diff --git a/ErsatzTV.Core/Api/MediaSources/RemoteMediaSourceItemResponseModel.cs b/ErsatzTV.Core/Api/MediaSources/RemoteMediaSourceItemResponseModel.cs new file mode 100644 index 000000000..3e18cc656 --- /dev/null +++ b/ErsatzTV.Core/Api/MediaSources/RemoteMediaSourceItemResponseModel.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Core.Api.MediaSources; + +public record RemoteMediaSourceItemResponseModel(int Id, string Name, string Address); diff --git a/ErsatzTV.Core/Api/MediaSources/RemoteMediaSourceStateResponseModel.cs b/ErsatzTV.Core/Api/MediaSources/RemoteMediaSourceStateResponseModel.cs new file mode 100644 index 000000000..0654d7bf7 --- /dev/null +++ b/ErsatzTV.Core/Api/MediaSources/RemoteMediaSourceStateResponseModel.cs @@ -0,0 +1,6 @@ +namespace ErsatzTV.Core.Api.MediaSources; + +public record RemoteMediaSourceStateResponseModel( + bool IsAuthorized, + bool IsLocked, + List Servers); diff --git a/ErsatzTV/Controllers/Api/Requests/CreateLocalLibraryRequest.cs b/ErsatzTV/Controllers/Api/Requests/CreateLocalLibraryRequest.cs new file mode 100644 index 000000000..b882fa247 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/CreateLocalLibraryRequest.cs @@ -0,0 +1,9 @@ +using ErsatzTV.Application.Libraries; +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record CreateLocalLibraryRequest(string Name, LibraryMediaKind MediaKind, List Paths) +{ + public CreateLocalLibrary ToCommand() => new(Name, MediaKind, Paths ?? []); +} diff --git a/ErsatzTV/Controllers/Api/Requests/LocalPathCheckRequest.cs b/ErsatzTV/Controllers/Api/Requests/LocalPathCheckRequest.cs new file mode 100644 index 000000000..116b7ef93 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/LocalPathCheckRequest.cs @@ -0,0 +1,4 @@ +namespace ErsatzTV.Controllers.Api.Requests; + +// No command mapping — the controller checks filesystem existence directly (see design #202 §C2). +public record LocalPathCheckRequest(string Path); diff --git a/ErsatzTV/Controllers/Api/Requests/MoveLocalLibraryPathRequest.cs b/ErsatzTV/Controllers/Api/Requests/MoveLocalLibraryPathRequest.cs new file mode 100644 index 000000000..c4581bda8 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/MoveLocalLibraryPathRequest.cs @@ -0,0 +1,8 @@ +using ErsatzTV.Application.Libraries; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record MoveLocalLibraryPathRequest(int TargetLibraryId) +{ + public MoveLocalLibraryPath ToCommand(int pathId) => new(pathId, TargetLibraryId); +} diff --git a/ErsatzTV/Controllers/Api/Requests/PathReplacementItemRequest.cs b/ErsatzTV/Controllers/Api/Requests/PathReplacementItemRequest.cs new file mode 100644 index 000000000..7fe37da13 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/PathReplacementItemRequest.cs @@ -0,0 +1,5 @@ +namespace ErsatzTV.Controllers.Api.Requests; + +// Id 0/absent = new; existing Id = update; existing row whose Id is absent from the request = delete +// (design #202 §C4b) — plus a per-row ownership guard and nonblank validation enforced by the handler. +public record PathReplacementItemRequest(int Id, string RemotePath, string LocalPath); diff --git a/ErsatzTV/Controllers/Api/Requests/RemoteLibraryPreferenceRequest.cs b/ErsatzTV/Controllers/Api/Requests/RemoteLibraryPreferenceRequest.cs new file mode 100644 index 000000000..186b63980 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/RemoteLibraryPreferenceRequest.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Controllers.Api.Requests; + +public record RemoteLibraryPreferenceRequest(int Id, bool ShouldSyncItems); diff --git a/ErsatzTV/Controllers/Api/Requests/ReplacePathReplacementsRequest.cs b/ErsatzTV/Controllers/Api/Requests/ReplacePathReplacementsRequest.cs new file mode 100644 index 000000000..dd93ff02f --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/ReplacePathReplacementsRequest.cs @@ -0,0 +1,29 @@ +using ErsatzTV.Application.Emby; +using ErsatzTV.Application.Jellyfin; +using ErsatzTV.Application.Plex; + +namespace ErsatzTV.Controllers.Api.Requests; + +public record ReplacePathReplacementsRequest(List Items) +{ + public UpdatePlexPathReplacements ToPlexCommand(int sourceId) => + new( + sourceId, + (Items ?? []) + .Select(item => new PlexPathReplacementItem(item.Id, item.RemotePath, item.LocalPath)) + .ToList()); + + public UpdateJellyfinPathReplacements ToJellyfinCommand(int sourceId) => + new( + sourceId, + (Items ?? []) + .Select(item => new JellyfinPathReplacementItem(item.Id, item.RemotePath, item.LocalPath)) + .ToList()); + + public UpdateEmbyPathReplacements ToEmbyCommand(int sourceId) => + new( + sourceId, + (Items ?? []) + .Select(item => new EmbyPathReplacementItem(item.Id, item.RemotePath, item.LocalPath)) + .ToList()); +} diff --git a/ErsatzTV/Controllers/Api/Requests/ReplaceRemoteLibraryPreferencesRequest.cs b/ErsatzTV/Controllers/Api/Requests/ReplaceRemoteLibraryPreferencesRequest.cs new file mode 100644 index 000000000..de2665748 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/ReplaceRemoteLibraryPreferencesRequest.cs @@ -0,0 +1,29 @@ +using ErsatzTV.Application.Emby; +using ErsatzTV.Application.Jellyfin; +using ErsatzTV.Application.Plex; + +namespace ErsatzTV.Controllers.Api.Requests; + +// Shared by Plex/Jellyfin/Emby (design #202 §C4a) — the PUT body is the complete set of the +// source's libraries; a row absent from the request is left untouched, not deleted. The controller +// validates the id set against that source's known libraries before dispatch. +public record ReplaceRemoteLibraryPreferencesRequest(List Libraries) +{ + public UpdatePlexLibraryPreferences ToPlexCommand() => + new( + (Libraries ?? []) + .Select(item => new PlexLibraryPreference(item.Id, item.ShouldSyncItems)) + .ToList()); + + public UpdateJellyfinLibraryPreferences ToJellyfinCommand() => + new( + (Libraries ?? []) + .Select(item => new JellyfinLibraryPreference(item.Id, item.ShouldSyncItems)) + .ToList()); + + public UpdateEmbyLibraryPreferences ToEmbyCommand() => + new( + (Libraries ?? []) + .Select(item => new EmbyLibraryPreference(item.Id, item.ShouldSyncItems)) + .ToList()); +} diff --git a/ErsatzTV/Controllers/Api/Requests/SaveRemoteConnectionRequest.cs b/ErsatzTV/Controllers/Api/Requests/SaveRemoteConnectionRequest.cs new file mode 100644 index 000000000..e776d98ac --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/SaveRemoteConnectionRequest.cs @@ -0,0 +1,29 @@ +using ErsatzTV.Application.Emby; +using ErsatzTV.Application.Jellyfin; +using ErsatzTV.Core.Emby; +using ErsatzTV.Core.Jellyfin; + +namespace ErsatzTV.Controllers.Api.Requests; + +// ApiKey blank/omitted = retain the existing key (design #202 secure connection contract, finding 1) +// — the caller passes the source's current key so ToCommand can substitute it when the incoming +// value is blank. A non-blank value sets a new key. Required on first connect (no existing key), +// which the controller/handler enforces before dispatch. +public record SaveRemoteConnectionRequest(string Address, string ApiKey) +{ + public SaveJellyfinSecrets ToJellyfinCommand(string existingApiKey) => + new( + new JellyfinSecrets + { + Address = Address, + ApiKey = string.IsNullOrWhiteSpace(ApiKey) ? existingApiKey : ApiKey + }); + + public SaveEmbySecrets ToEmbyCommand(string existingApiKey) => + new( + new EmbySecrets + { + Address = Address, + ApiKey = string.IsNullOrWhiteSpace(ApiKey) ? existingApiKey : ApiKey + }); +} diff --git a/ErsatzTV/Controllers/Api/Requests/UpdateLocalLibraryPathRequest.cs b/ErsatzTV/Controllers/Api/Requests/UpdateLocalLibraryPathRequest.cs new file mode 100644 index 000000000..c9c49eb6b --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/UpdateLocalLibraryPathRequest.cs @@ -0,0 +1,10 @@ +using ErsatzTV.Application.Libraries; + +namespace ErsatzTV.Controllers.Api.Requests; + +// Id 0/absent = new path (matches UpdateLocalLibraryHandler's normalized-path-string identity merge; +// the Id is advisory only — see design #202 §C4c). +public record UpdateLocalLibraryPathRequest(int Id, string Path) +{ + public UpdateLocalLibraryPath ToCommand() => new(Id, Path); +} diff --git a/ErsatzTV/Controllers/Api/Requests/UpdateLocalLibraryRequest.cs b/ErsatzTV/Controllers/Api/Requests/UpdateLocalLibraryRequest.cs new file mode 100644 index 000000000..4e8145f2b --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/UpdateLocalLibraryRequest.cs @@ -0,0 +1,16 @@ +using ErsatzTV.Application.Libraries; + +namespace ErsatzTV.Controllers.Api.Requests; + +// MediaKind is immutable after create, so it is deliberately absent here (structural immutability +// instead of a 422 — see design #202 §C3). +public record UpdateLocalLibraryRequest(string Name, List Paths) +{ + public UpdateLocalLibrary ToCommand(int id) => + new( + id, + Name, + (Paths ?? []) + .Select(path => path.ToCommand()) + .ToList()); +} -- 2.47.3 From 99fab8e0bbfe2b375f79b12da7a5918a3474f2d6 Mon Sep 17 00:00:00 2001 From: Timothy Date: Sat, 11 Jul 2026 15:29:15 +0200 Subject: [PATCH 02/12] refactor(spa): extract LibrariesScreen from App.tsx (#202 slice S4) Move the Libraries domain verbatim out of web/src/App.tsx into web/src/screens/LibrariesScreen.tsx (zero-prop, self-sufficient), mirroring the ChannelsScreen extraction (#244). Pure structural move: no API, route, CSS, or visual change. App.tsx retains only the import + the dispatch. - 10 symbols moved (LibrariesLoadingState -> sourceLastScanLabel); App.tsx's formatDateTime is inlined into the moved screen so it has no import back into App.tsx (behavior-identical), matching the Channels precedent. - Libraries behavior tests moved to a colocated LibrariesScreen.test.tsx with its own scoped fetch mock (renders directly); App.test.tsx keeps one nav-smoke test for the route. - Pruned now-dead App.tsx imports (Server, MonitorPlay, Music, FileImage, Folder, HardDrive icons; useLibrariesScreenQuery, LibraryScanStatus/MediaSource/ MediaSourceLibrary types) and the now-dead runPollTick test helper (its doc comment named it Libraries-specific). - Disabled "Add Source"/gear/"Scan All" affordances are unchanged (wired in later S5/S6 slices, not here). Verified: web vitest 584 passed, eslint clean, tsc/vite build clean, check:api no drift. refs #202 --- web/src/App.test.tsx | 486 +---------------------- web/src/App.tsx | 271 +------------ web/src/screens/LibrariesScreen.test.tsx | 369 +++++++++++++++++ web/src/screens/LibrariesScreen.tsx | 309 ++++++++++++++ 4 files changed, 686 insertions(+), 749 deletions(-) create mode 100644 web/src/screens/LibrariesScreen.test.tsx create mode 100644 web/src/screens/LibrariesScreen.tsx diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index e519a56f2..2cf49cf58 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -1,4 +1,4 @@ -import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { App } from './App'; import { Button, Checkbox, Input, ProgressBar, Switch, Tabs, Toast, Tooltip } from './components'; @@ -590,44 +590,15 @@ 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 () => { + it('renders the extracted Libraries screen from live media source and scan status APIs (#202)', async () => { + // Thin route/composition smoke test: the full Libraries behavioral suite lives colocated in + // web/src/screens/LibrariesScreen.test.tsx. This only asserts the nav wires the extracted screen + // into the shell and it mounts + hits its own endpoints. mockDashboardApi({ - libraryScanStatuses: [{ libraryId: 31, percent: 0.625 }], + libraryScanStatuses: [{ libraryId: 31, percent: 0.5 }], 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' + libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] }) ] }); @@ -637,436 +608,11 @@ describe('ChicoryTV SPA scaffold', () => { 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'); - await runPollTick(intervalHandlers); - - 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, 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: [ - [{ 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'); - await runPollTick(intervalHandlers); - - 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'); - - await runPollTick(intervalHandlers); - expect(screen.getByText('30%')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled(); - - await runPollTick(intervalHandlers); - 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('reconciles a 409 "already scanning" against scan-status without an error toast (#232)', async () => { - // POST /scan now returns 409 when a scan is already in progress. That is benign - the SPA - // must NOT show an error; it reconciles against scan-status (which reports the active scan) - // and keeps the button disabled while that scan runs. - mockDashboardApi({ - libraryScanStatusSequence: [ - [], // initial screen load: nothing active yet - [{ libraryId: 31, percent: 0.3 }] // post-409 reconcile fetch: the in-progress scan - ], - mediaSources: [ - mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] }) - ], - mutationFailures: { - '/api/libraries/31/scan': { status: 409 } - } - }); - - 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 reconcile fetch surfaces the already-running scan; the button stays disabled... - expect(await screen.findByText('30%')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled(); - - // ...and no error toast is shown for the benign conflict. - expect(screen.queryByText(/Request failed/)).not.toBeInTheDocument(); - expect(screen.queryByText(/Unable to scan/)).not.toBeInTheDocument(); - }); - - it('surfaces a 422 "sync disabled" scan rejection and leaves the button enabled (#232)', async () => { - // POST /scan now returns 422 when item sync is disabled for the library. Nothing was queued, - // so the button must not be stuck disabled and the error must be surfaced. - mockDashboardApi({ - libraryScanStatuses: [], - mediaSources: [ - mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] }) - ], - mutationFailures: { - '/api/libraries/31/scan': { detail: 'Item sync is disabled for library 31.', status: 422 } - } - }); - - 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('Item sync is disabled for library 31.')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Scan Movies' })).not.toBeDisabled(); - }); - - it('re-enables the scan button after the grace window if a 202 scan never appears active (#232)', async () => { - // A 202 genuinely queues a (forced) scan, but a short/empty library can finish before the next - // 10s poll ever observes it in scan-status - or the scanner may never register it. The optimistic - // "pending" flag must not wedge the button disabled forever: it expires after PENDING_GRACE_TICKS - // polls that never see the library active, then the button re-enables. - 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(() => 0); - - mockDashboardApi({ - // initial load, immediate post-POST fetch, then every poll: the scan never shows up - libraryScanStatusSequence: [[], [], [], [], []], - 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' })); - }); - - // Optimistically disabled; the immediate post-POST fetch ([]) burned one grace tick but keeps it pending. - await waitFor(() => { - expect(fetchCount('/api/libraries/scan-status')).toBe(2); - }); - expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled(); - - // Grace is PENDING_GRACE_TICKS (3): the immediate fetch used one, two more unseen polls expire it. - await runPollTick(intervalHandlers); - expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled(); - - await runPollTick(intervalHandlers); - expect(screen.getByRole('button', { name: 'Scan Movies' })).not.toBeDisabled(); - }); - - it('keeps the scan button disabled on a 409 even when scan-status has not yet caught up (#232)', async () => { - // 409 means the library is already scanning (a lock is held). The SPA must NOT re-enable the - // button just because scan-status has not yet listed the in-progress scan (the queue->active - // lag) - otherwise the user can fire repeated 409s. The pending flag is retained (button stays - // disabled, no error toast) and is reconciled/expired by the normal polling path. - mockDashboardApi({ - libraryScanStatusSequence: [ - [], // initial screen load - [] // post-409 reconcile fetch: the in-progress scan is not visible in scan-status yet - ], - mediaSources: [ - mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] }) - ], - mutationFailures: { - '/api/libraries/31/scan': { status: 409 } - } - }); - - 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' })); - }); - // Reconcile fetch happened and returned nothing active, yet the button stays disabled... - await waitFor(() => { - expect(fetchCount('/api/libraries/scan-status')).toBe(2); - }); - expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled(); - - // ...and no error toast is shown for the benign conflict. - expect(screen.queryByText(/Request failed/)).not.toBeInTheDocument(); - expect(screen.queryByText(/Unable to scan/)).not.toBeInTheDocument(); - }); - - 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: [ @@ -3810,24 +3356,6 @@ function mockDashboardApi({ }); } -// Deterministically fires one captured poll tick for the mocked-setInterval polling tests. -// -// The Libraries poll interval is registered in a passive useEffect that only runs after -// an out-of-act state commit (the async fetch that flips hasActiveScans), so `findBy*` -// queries - which resolve on the DOM mutation, via MutationObserver - can win the race -// against React's effect flush and observe an empty handler list (the #157 flake). -// The leading empty act() flushes those pending effects, the length assertion turns a -// still-missing registration into a clear failure instead of a silent no-op forEach, -// and the act() around the handlers applies the fetch microtasks + state updates before -// the caller asserts, so no assertion depends on wall-clock scheduling. -async function runPollTick(intervalHandlers: Array<() => void>): Promise { - await act(async () => {}); - expect(intervalHandlers.length).toBeGreaterThan(0); - await act(async () => { - intervalHandlers.forEach((handler) => handler()); - }); -} - function fetchCount(path: string): number { return vi.mocked(window.fetch).mock.calls.filter(([input]) => input.toString() === path).length; } diff --git a/web/src/App.tsx b/web/src/App.tsx index bb4f2fae1..76e8b4910 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -23,11 +23,8 @@ import { ClipboardCopy, Crosshair, Film, - FileImage, Eraser, - Folder, FolderTree, - HardDrive, Info, Layers, LayoutDashboard, @@ -37,8 +34,6 @@ import { ListChecks, ListMusic, ListVideo, - MonitorPlay, - Music, Plus, Pencil, Play, @@ -47,7 +42,6 @@ import { Repeat, ScrollText, Search, - Server, Settings, SlidersHorizontal, Sparkles, @@ -69,6 +63,7 @@ import { MultiCollectionsScreen } from './screens/MultiCollectionsScreen'; import { PlaylistsScreen } from './screens/PlaylistsScreen'; import { RerunCollectionsScreen } from './screens/RerunCollectionsScreen'; import { ChannelsScreen } from './screens/ChannelsScreen'; +import { LibrariesScreen } from './screens/LibrariesScreen'; import { SchedulesScreen } from './screens/SchedulesScreen'; import { canLeaveCurrentScreen } from './navigationGuard'; import { @@ -135,7 +130,6 @@ import { defaultGuideWindowStart, GUIDE_WINDOW_MS, useGuideScreenQuery, - useLibrariesScreenQuery, type ChannelState, type ChannelGuideChannel, type ChannelGuideProgramme, @@ -144,9 +138,6 @@ import { type DecoListItem, type DashboardChannelState, type DashboardHealthQueryState, - type LibraryScanStatus, - type MediaSource, - type MediaSourceLibrary, type PlayoutDetail, type PlayoutScheduleKind, type ProgramSchedule, @@ -1450,266 +1441,6 @@ function programmeMatchesNowPlaying(programme: ClippedProgramme, nowPlaying: Cha programme.stop.getTime() === nowPlayingFinish.getTime(); } -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' - ?