#91b: wire deep-scan + external-collections buttons into LibrariesScreen #272
@@ -273,14 +273,20 @@ The removal PR is **gated** — it starts only after these clear: ~~#202 (media-
|
||||
**DONE 2026-07-11**, ~~#235 F9 (deep-scan + external-collections-scan API)~~ **API DONE (#235 slice B)**:
|
||||
`POST /api/libraries/{id}/scan?deep=` now threads deep-scan, and `POST /api/media-sources/{plex|jellyfin|emby}/{id}/scan-collections?deep=`
|
||||
covers external-collections scan (the two `Libraries.razor` parity gaps) — the SPA `Libraries.razor` port can now
|
||||
proceed, and the **mandatory cold adversarial pass** (#91 comment 2026-07-09). **Remaining SPA affordance for
|
||||
the removal PR** (API + thin `web/src/api/libraries.ts` clients — `scanLibrary(id, deep)`, `scanCollections` —
|
||||
already shipped by #235): `LibrariesScreen` currently exposes only quick-scan; the removal PR must add the
|
||||
**deep-scan** and **external-collections-scan** buttons (wiring the shipped clients) before deleting
|
||||
`Libraries.razor`, or that capability is lost. (#204's id-carrying pattern
|
||||
redirects landed 2026-07-11; its catch-all fallback that replaces `MapFallbackToPage("/_Host")` is folded
|
||||
into Step 2 below, since it can only ship when `_Host` is deleted.) When those close, the removal-PR routine
|
||||
executes, in order:
|
||||
proceed, and the **mandatory cold adversarial pass** (#91 comment 2026-07-09). ~~**Remaining SPA affordance for
|
||||
the removal PR**: `LibrariesScreen` exposes only quick-scan; add the **deep-scan** and **external-collections-scan**
|
||||
buttons before deleting `Libraries.razor`.~~ **SPA affordance DONE 2026-07-11** (landed ahead of the removal PR
|
||||
so the deletion diff stays pure): `LibrariesScreen` now wires the shipped `scanLibrary(id, deep)` +
|
||||
`scanCollections(family, id, deep)` clients — a **Deep Scan Library** button on each remote library row and an
|
||||
**External Collections** section (quick + deep per remote source), matching all four `Libraries.razor` scan actions.
|
||||
The External Collections rows derive client-side from `getMediaSources()` (no new endpoint): the media-sources API
|
||||
handler already filters each source's `libraries` to sync-enabled entries, so a remote source with a non-empty
|
||||
`libraries` list is exactly `GetExternalCollections`'s `Libraries.Any(ShouldSyncItems)` filter. Collections scans
|
||||
have no scan-status poll surface, so their button pending state is optimistic + timeout-bounded (follow-up #271: a
|
||||
collections scan-status endpoint would let it reconcile like library scans). (#204's id-carrying
|
||||
pattern redirects landed 2026-07-11; its catch-all fallback that replaces `MapFallbackToPage("/_Host")` is folded
|
||||
into Step 2 below, since it can only ship when `_Host` is deleted.) With the SPA parity done, the removal PR's
|
||||
remaining pre-work is just the **mandatory cold adversarial pass**; the runbook then executes, in order:
|
||||
|
||||
1. **Cut the rollback tag `blazor-final`** on the pre-deletion `main` commit — the *first* action, before
|
||||
deleting anything. Exact command + restore path: `docs/decisions.md` 2026-07-11 "Pre-removal Blazor
|
||||
|
||||
@@ -777,3 +777,29 @@ the controller pre-guard) so a direct caller can't trip the `Max()`-on-empty cra
|
||||
the versioned aggregates — several of which this sweep also touched (post-commit token, a different line
|
||||
region). Low git-conflict risk, but merge `main` in and expect to see the `CancellationToken.None`
|
||||
convention already present on the post-commit enqueues.
|
||||
|
||||
## 2026-07-11 — External Collections in the SPA derives from `getMediaSources()`; collections scans are optimistically bounded (#91b Libraries.razor parity)
|
||||
|
||||
**Context.** The last SPA pre-work before deleting Blazor `Libraries.razor` was wiring the shipped
|
||||
`scanLibrary(id, deep)` + `scanCollections(family, id, deep)` clients (F9 API, #235) into `LibrariesScreen`,
|
||||
matching the page's four scan actions: quick-library (already had it), deep-library, quick-collections,
|
||||
deep-collections. Landed as its own pure-SPA PR ahead of the removal PR so the deletion diff stays surgical.
|
||||
|
||||
**Decision 1 — derive the External Collections rows client-side, no new endpoint.** Blazor's second table
|
||||
comes from the `GetExternalCollections` MediatR query (one row per Plex/Jellyfin/Emby source with
|
||||
`Libraries.Any(ShouldSyncItems)`), which has no REST wrapper. Rather than add one, the SPA derives the rows
|
||||
from the existing `GET /api/media-sources`: `GetAllMediaSourcesForApiHandler` already filters each source's
|
||||
`libraries` to sync-enabled entries (`ShouldIncludeLibrary` = `ShouldSyncItems` for remote families), so a
|
||||
remote source with a **non-empty `libraries` list** is *exactly* the `Libraries.Any(ShouldSyncItems)` filter.
|
||||
No extra endpoint, no extra fetch, single source of truth. (If `MediaSourceLibraryResponseModel` ever stops
|
||||
filtering to sync-enabled libraries, this equivalence breaks — a test asserting the row set guards it.)
|
||||
|
||||
**Decision 2 — collections-scan buttons use optimistic, timeout-bounded pending (no poll reconcile).** Library
|
||||
scans reconcile their optimistic "pending" flag against `GET /api/libraries/scan-status`. Collections scans
|
||||
have **no** such surface — that endpoint is library-keyed, and Blazor only ever observed collections locks via
|
||||
in-process `IEntityLocker` `Are{X}CollectionsLocked` events with no HTTP mirror. So the `useCollectionsScan`
|
||||
hook disables a button optimistically on click and clears it after a fixed `COLLECTIONS_PENDING_TIMEOUT_MS`
|
||||
(a 409 "already scanning" is benign and keeps it disabled until the timeout; a 404/network clears it and
|
||||
surfaces the error). This is the honest ceiling of what the current API exposes. **Follow-up (backlog):** a
|
||||
collections scan-status endpoint would let the SPA reconcile collections pending against a live active set the
|
||||
way library scans do; filed as **#271** so the timeout isn't mistaken for the intended end state.
|
||||
|
||||
+106
-4
@@ -16,7 +16,7 @@ export type LibrariesScreenQueryState =
|
||||
data: LibrariesScreenData;
|
||||
error: string | null;
|
||||
refresh: () => void;
|
||||
scanLibrary: (libraryId: number) => Promise<void>;
|
||||
scanLibrary: (libraryId: number, deep?: boolean) => Promise<void>;
|
||||
scanningLibraryIds: Set<number>;
|
||||
status: 'success';
|
||||
}
|
||||
@@ -315,9 +315,10 @@ export function useLibrariesScreenQuery(pollMs = 10000): LibrariesScreenQuerySta
|
||||
});
|
||||
}, []);
|
||||
|
||||
const triggerScan = useCallback((libraryId: number): Promise<void> => {
|
||||
const triggerScan = useCallback((libraryId: number, deep = false): Promise<void> => {
|
||||
if (pendingIdsRef.current.has(libraryId) || activeIdsRef.current.has(libraryId)) {
|
||||
// Already pending or active - ignore the duplicate submission.
|
||||
// Already pending or active - ignore the duplicate submission. Quick and deep scans share the
|
||||
// same per-library lock, so both affordances gate on (and reconcile through) this one id set.
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
@@ -338,7 +339,7 @@ export function useLibrariesScreenQuery(pollMs = 10000): LibrariesScreenQuerySta
|
||||
};
|
||||
});
|
||||
|
||||
return scanLibrary(libraryId)
|
||||
return scanLibrary(libraryId, deep)
|
||||
.then(() => {
|
||||
// 202 Accepted - a scan is genuinely queued. Poll scan-status; the pending flag is
|
||||
// promoted to "active" once the scan appears there.
|
||||
@@ -399,6 +400,107 @@ export function useLibrariesScreenQuery(pollMs = 10000): LibrariesScreenQuerySta
|
||||
return { data: null, error: null, refresh, status: 'loading' };
|
||||
}
|
||||
|
||||
// --- External collections scan (Plex/Jellyfin/Emby) ---
|
||||
|
||||
// A pending collections scan re-enables its button after this bound. Unlike library scans, an
|
||||
// external-collections scan has NO authoritative "in progress" REST surface: /api/libraries/scan-status
|
||||
// is library-keyed, and Blazor only ever observed collections locks through in-process IEntityLocker
|
||||
// events (Are{X}CollectionsLocked) that have no HTTP mirror. So we can't reconcile "pending" against a
|
||||
// live active set the way library scans do - we optimistically disable, then give up after this bound
|
||||
// so a button can't wedge disabled forever. (A collections scan-status endpoint would let us reconcile
|
||||
// properly - see the #91b follow-up note.)
|
||||
const COLLECTIONS_PENDING_TIMEOUT_MS = 30000;
|
||||
|
||||
export interface CollectionsScanState {
|
||||
error: string | null;
|
||||
scan: (source: CollectionsScanSource, sourceId: number, deep: boolean) => Promise<void>;
|
||||
scanningKeys: Set<string>;
|
||||
}
|
||||
|
||||
// Stable key for a per-(family, source) collections scan, used by both the hook and the screen so the
|
||||
// "is this row scanning?" lookup has exactly one definition.
|
||||
export function collectionsScanKey(source: CollectionsScanSource, sourceId: number): string {
|
||||
return `${source}:${sourceId}`;
|
||||
}
|
||||
|
||||
export function useCollectionsScan(): CollectionsScanState {
|
||||
const [scanningKeys, setScanningKeys] = useState<Set<string>>(new Set());
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Mirror kept in sync synchronously so scan() can guard double-submits without waiting for a render.
|
||||
const scanningKeysRef = useRef<Set<string>>(new Set());
|
||||
const timeoutsRef = useRef<Map<string, number>>(new Map());
|
||||
const activeRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = true;
|
||||
// Identity is stable across the hook's life (we only .set/.delete entries, never reassign the
|
||||
// Map), so capturing it here is the same instance the cleanup clears at unmount.
|
||||
const timeouts = timeoutsRef.current;
|
||||
|
||||
return () => {
|
||||
activeRef.current = false;
|
||||
timeouts.forEach((timeoutId) => window.clearTimeout(timeoutId));
|
||||
timeouts.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const clearKey = useCallback((key: string) => {
|
||||
const timeoutId = timeoutsRef.current.get(key);
|
||||
if (timeoutId !== undefined) {
|
||||
window.clearTimeout(timeoutId);
|
||||
timeoutsRef.current.delete(key);
|
||||
}
|
||||
|
||||
const next = new Set(scanningKeysRef.current);
|
||||
next.delete(key);
|
||||
scanningKeysRef.current = next;
|
||||
|
||||
if (activeRef.current) {
|
||||
setScanningKeys(next);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scan = useCallback(
|
||||
(source: CollectionsScanSource, sourceId: number, deep: boolean): Promise<void> => {
|
||||
const key = collectionsScanKey(source, sourceId);
|
||||
if (scanningKeysRef.current.has(key)) {
|
||||
// Already pending - ignore the duplicate submission (quick and deep share the source lock).
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const pending = new Set(scanningKeysRef.current).add(key);
|
||||
scanningKeysRef.current = pending;
|
||||
setScanningKeys(pending);
|
||||
setError(null);
|
||||
|
||||
const timeoutId = window.setTimeout(() => clearKey(key), COLLECTIONS_PENDING_TIMEOUT_MS);
|
||||
timeoutsRef.current.set(key, timeoutId);
|
||||
|
||||
return scanCollections(source, sourceId, deep)
|
||||
.then(() => {
|
||||
// 202 Accepted - a scan is genuinely queued. Keep the button disabled until the bounded
|
||||
// timeout expires (there is no completion signal to reconcile against).
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (err instanceof ApiError && err.status === 409) {
|
||||
// 409 Conflict - a collections scan is already running for this source. Benign: keep the
|
||||
// optimistic pending flag (button stays disabled) with no error; it clears on the timeout.
|
||||
return;
|
||||
}
|
||||
|
||||
// 404 (source missing) / network error - nothing was queued, so re-enable and surface it.
|
||||
clearKey(key);
|
||||
if (activeRef.current) {
|
||||
setError(messageFromLibrariesError(err, 'Unable to scan collections'));
|
||||
}
|
||||
});
|
||||
},
|
||||
[clearKey]
|
||||
);
|
||||
|
||||
return { error, scan, scanningKeys };
|
||||
}
|
||||
|
||||
function messageFromLibrariesError(error: unknown, fallback = 'Unable to load libraries'): string {
|
||||
if (error instanceof ApiError) {
|
||||
return error.detail ?? error.message;
|
||||
|
||||
@@ -74,7 +74,14 @@ function mockApi(options: MockOptions = {}): MockHandle {
|
||||
return Promise.resolve(jsonResponse(options.libraryScanStatuses ?? []));
|
||||
}
|
||||
|
||||
if (/^\/api\/libraries\/\d+\/scan$/.test(url) && method === 'POST') {
|
||||
if (/^\/api\/libraries\/\d+\/scan(\?deep=true)?$/.test(url) && method === 'POST') {
|
||||
if (url in mutationFailures) {
|
||||
return Promise.resolve(jsonResponse(mutationFailures[url], mutationFailures[url].status ?? 422));
|
||||
}
|
||||
return Promise.resolve(new Response(null, { status: 202 }));
|
||||
}
|
||||
|
||||
if (/^\/api\/media-sources\/(plex|jellyfin|emby)\/\d+\/scan-collections(\?deep=true)?$/.test(url) && method === 'POST') {
|
||||
if (url in mutationFailures) {
|
||||
return Promise.resolve(jsonResponse(mutationFailures[url], mutationFailures[url].status ?? 422));
|
||||
}
|
||||
@@ -369,6 +376,153 @@ describe('LibrariesScreen', () => {
|
||||
expect(await screen.findByText('No media sources returned')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('deep scan + external collections (#91b Libraries.razor parity)', () => {
|
||||
// A remote source with two sync-enabled libraries (deep-scan available) plus an External
|
||||
// Collections row; a Local source (no deep, no collections row); and a remote source with NO
|
||||
// sync-enabled libraries (no External Collections row).
|
||||
function paritySources(): unknown[] {
|
||||
return [
|
||||
mediaSource({
|
||||
id: 30,
|
||||
kind: 'Local',
|
||||
libraries: [library({ id: 31, mediaKind: 'Movies', name: 'Movies' })],
|
||||
name: 'Local'
|
||||
}),
|
||||
mediaSource({
|
||||
connectionAddress: 'https://plex.example.test',
|
||||
id: 40,
|
||||
kind: 'Plex',
|
||||
libraries: [library({ id: 41, mediaKind: 'Shows', name: 'TV Shows' })],
|
||||
name: 'Plex Server'
|
||||
}),
|
||||
mediaSource({
|
||||
connectionAddress: 'https://jellyfin.example.test',
|
||||
id: 50,
|
||||
kind: 'Jellyfin',
|
||||
libraries: [],
|
||||
name: 'Jellyfin Home'
|
||||
})
|
||||
];
|
||||
}
|
||||
|
||||
it('deep-scans a remote library via POST /api/libraries/{id}/scan?deep=true', async () => {
|
||||
const handle = mockApi({ mediaSources: paritySources() });
|
||||
|
||||
render(<LibrariesScreen />);
|
||||
await screen.findByText('TV Shows');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Deep scan TV Shows' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(handle.fetchSpy).toHaveBeenCalledWith('/api/libraries/41/scan?deep=true', expect.objectContaining({ method: 'POST' }));
|
||||
});
|
||||
expect(fetchCount(handle, '/api/libraries/41/scan')).toBe(0);
|
||||
});
|
||||
|
||||
it('offers a deep-scan button only for remote libraries, never Local', async () => {
|
||||
mockApi({ mediaSources: paritySources() });
|
||||
|
||||
render(<LibrariesScreen />);
|
||||
await screen.findByText('TV Shows');
|
||||
|
||||
// Local library "Movies" has a quick scan but no deep scan.
|
||||
expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Deep scan Movies' })).not.toBeInTheDocument();
|
||||
// Remote library "TV Shows" has both.
|
||||
expect(screen.getByRole('button', { name: 'Scan TV Shows' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Deep scan TV Shows' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders an External Collections row only for remote sources with sync-enabled libraries', async () => {
|
||||
mockApi({ mediaSources: paritySources() });
|
||||
|
||||
render(<LibrariesScreen />);
|
||||
await screen.findByText('TV Shows');
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'External Collections' })).toBeInTheDocument();
|
||||
// Plex Server has a sync-enabled library → a collections row.
|
||||
expect(screen.getByRole('button', { name: 'Scan Plex Server collections' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Deep scan Plex Server collections' })).toBeInTheDocument();
|
||||
// Jellyfin Home has no sync-enabled library → no collections row; Local never gets one.
|
||||
expect(screen.queryByRole('button', { name: 'Scan Jellyfin Home collections' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Scan Local collections' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the External Collections section entirely when no remote source has sync-enabled libraries', async () => {
|
||||
mockApi({
|
||||
mediaSources: [
|
||||
mediaSource({ id: 30, kind: 'Local', libraries: [library({ id: 31, name: 'Movies' })], name: 'Local' })
|
||||
]
|
||||
});
|
||||
|
||||
render(<LibrariesScreen />);
|
||||
expect((await screen.findAllByText('Movies')).length).toBeGreaterThan(0);
|
||||
|
||||
expect(screen.queryByRole('heading', { name: 'External Collections' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('queues a quick collections scan via POST /api/media-sources/{family}/{id}/scan-collections', async () => {
|
||||
const handle = mockApi({ mediaSources: paritySources() });
|
||||
|
||||
render(<LibrariesScreen />);
|
||||
await screen.findByRole('button', { name: 'Scan Plex Server collections' });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Scan Plex Server collections' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(handle.fetchSpy).toHaveBeenCalledWith('/api/media-sources/plex/40/scan-collections', expect.objectContaining({ method: 'POST' }));
|
||||
});
|
||||
// The button is optimistically disabled while the scan is pending.
|
||||
expect(screen.getByRole('button', { name: 'Scan Plex Server collections' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('queues a deep collections scan with ?deep=true', async () => {
|
||||
const handle = mockApi({ mediaSources: paritySources() });
|
||||
|
||||
render(<LibrariesScreen />);
|
||||
await screen.findByRole('button', { name: 'Deep scan Plex Server collections' });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Deep scan Plex Server collections' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(handle.fetchSpy).toHaveBeenCalledWith('/api/media-sources/plex/40/scan-collections?deep=true', expect.objectContaining({ method: 'POST' }));
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a 409 "already scanning" collections response as benign (button stays disabled, no error)', async () => {
|
||||
mockApi({
|
||||
mediaSources: paritySources(),
|
||||
mutationFailures: { '/api/media-sources/plex/40/scan-collections': { status: 409 } }
|
||||
});
|
||||
|
||||
render(<LibrariesScreen />);
|
||||
await screen.findByRole('button', { name: 'Scan Plex Server collections' });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Scan Plex Server collections' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Scan Plex Server collections' })).toBeDisabled();
|
||||
});
|
||||
expect(screen.queryByText(/Unable to scan collections/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Request failed/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('surfaces a collections scan failure and re-enables the button', async () => {
|
||||
mockApi({
|
||||
mediaSources: paritySources(),
|
||||
mutationFailures: { '/api/media-sources/plex/40/scan-collections': { status: 404 } }
|
||||
});
|
||||
|
||||
render(<LibrariesScreen />);
|
||||
await screen.findByRole('button', { name: 'Scan Plex Server collections' });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Scan Plex Server collections' }));
|
||||
|
||||
expect(await screen.findByText('Request failed with status 404')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Scan Plex Server collections' })).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hub wiring (slice S5)', () => {
|
||||
it('Add Source menu navigates to the local-new and each family route', async () => {
|
||||
const pushSpy = vi.spyOn(window.history, 'pushState');
|
||||
|
||||
@@ -6,11 +6,13 @@ import {
|
||||
Film,
|
||||
Folder,
|
||||
HardDrive,
|
||||
Library,
|
||||
MonitorPlay,
|
||||
Music,
|
||||
Plus,
|
||||
Radio,
|
||||
RefreshCw,
|
||||
ScanSearch,
|
||||
Server,
|
||||
Settings,
|
||||
TriangleAlert
|
||||
@@ -25,7 +27,10 @@ import {
|
||||
StatusDot
|
||||
} from '../components';
|
||||
import {
|
||||
collectionsScanKey,
|
||||
useCollectionsScan,
|
||||
useLibrariesScreenQuery,
|
||||
type CollectionsScanSource,
|
||||
type LibraryScanStatus,
|
||||
type MediaSource,
|
||||
type MediaSourceLibrary
|
||||
@@ -41,6 +46,7 @@ import {
|
||||
|
||||
export function LibrariesScreen() {
|
||||
const query = useLibrariesScreenQuery();
|
||||
const collectionsScan = useCollectionsScan();
|
||||
|
||||
if (query.status === 'loading') {
|
||||
return <LibrariesLoadingState />;
|
||||
@@ -60,6 +66,21 @@ export function LibrariesScreen() {
|
||||
);
|
||||
const scanStatusesByLibraryId = new Map(query.data.scanStatuses.map((status) => [status.libraryId, status]));
|
||||
|
||||
// External Collections mirrors Blazor Libraries.razor's second table: one row per Plex/Jellyfin/Emby
|
||||
// source that has at least one sync-enabled library. GetAllMediaSourcesForApiHandler already filters
|
||||
// each source's `libraries` to sync-enabled entries (ShouldIncludeLibrary), so a remote source with a
|
||||
// non-empty `libraries` list is exactly the `Libraries.Any(ShouldSyncItems)` filter GetExternalCollections
|
||||
// applies - no separate endpoint needed.
|
||||
const collectionsRows = query.data.sources
|
||||
.map((source) => {
|
||||
const family = parseRemoteFamily(source.kind);
|
||||
if (family === null || source.libraries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return { family, name: source.name, sourceId: source.id };
|
||||
})
|
||||
.filter((row): row is { family: CollectionsScanSource; name: string; sourceId: number } => row !== null);
|
||||
|
||||
return (
|
||||
<div className="ctv-libraries-screen">
|
||||
<section className="ctv-libraries-header">
|
||||
@@ -97,10 +118,81 @@ export function LibrariesScreen() {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{collectionsRows.length > 0 && (
|
||||
<ExternalCollectionsSection
|
||||
error={collectionsScan.error}
|
||||
onScanCollections={collectionsScan.scan}
|
||||
rows={collectionsRows}
|
||||
scanningKeys={collectionsScan.scanningKeys}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// External Collections table (parity with Blazor Libraries.razor's second table). Each row is a
|
||||
// remote source with sync-enabled libraries; the quick/deep buttons queue that source's collections
|
||||
// scan. There is no scan-status poll surface for collections (see useCollectionsScan), so a row's
|
||||
// "scanning" state is optimistic and bounded by a timeout rather than reconciled against a live set.
|
||||
function ExternalCollectionsSection({
|
||||
error,
|
||||
onScanCollections,
|
||||
rows,
|
||||
scanningKeys
|
||||
}: {
|
||||
error: string | null;
|
||||
onScanCollections: (source: CollectionsScanSource, sourceId: number, deep: boolean) => Promise<void>;
|
||||
rows: Array<{ family: CollectionsScanSource; name: string; sourceId: number }>;
|
||||
scanningKeys: Set<string>;
|
||||
}) {
|
||||
return (
|
||||
<section className="ctv-library-source-card" aria-label="External collections">
|
||||
<div className="ctv-library-source-head">
|
||||
<span className="ctv-library-kind-icon"><Library aria-hidden="true" size={18} /></span>
|
||||
<div>
|
||||
<div className="ctv-library-source-title">
|
||||
<h3>External Collections</h3>
|
||||
</div>
|
||||
<span>Sync Plex, Jellyfin, and Emby collections into ErsatzTV.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="ctv-channels-error" role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={15} />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ctv-library-list" role="list" aria-label="External collections list">
|
||||
{rows.map((row) => {
|
||||
const scanning = scanningKeys.has(collectionsScanKey(row.family, row.sourceId));
|
||||
|
||||
return (
|
||||
<div className="ctv-library-row" key={collectionsScanKey(row.family, row.sourceId)} role="listitem">
|
||||
<span className={`ctv-library-kind-icon ctv-library-kind-${row.family}`}><Server aria-hidden="true" size={16} /></span>
|
||||
<div className="ctv-library-row-main">
|
||||
<strong>{row.name}</strong>
|
||||
<span>{familyLabel(row.family)} collections</span>
|
||||
</div>
|
||||
<div className="ctv-library-row-status">
|
||||
{scanning ? <Badge tone="accent" dot>Scanning</Badge> : <Badge tone="ok" dot>Idle</Badge>}
|
||||
<IconButton disabled={scanning} onClick={() => void onScanCollections(row.family, row.sourceId, true)} size="sm" title={`Deep scan ${row.name} collections`}>
|
||||
<ScanSearch aria-hidden="true" size={15} />
|
||||
</IconButton>
|
||||
<IconButton disabled={scanning} onClick={() => void onScanCollections(row.family, row.sourceId, false)} size="sm" title={`Scan ${row.name} collections`}>
|
||||
<RefreshCw aria-hidden="true" size={15} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// "Add Source" dropdown: Local library opens the create editor; each remote family opens its screen.
|
||||
// Mirrors the AddToMenu popover pattern (click-outside + Escape close). Navigation goes through
|
||||
// navigateToPath, which App's popstate owner turns into the right libraries sub-path (design §D.2).
|
||||
@@ -203,7 +295,7 @@ function MediaSourceCard({
|
||||
scanningLibraryIds,
|
||||
source
|
||||
}: {
|
||||
onScanLibrary: (libraryId: number) => Promise<void>;
|
||||
onScanLibrary: (libraryId: number, deep?: boolean) => Promise<void>;
|
||||
scanStatusesByLibraryId: Map<number, LibraryScanStatus>;
|
||||
scanningLibraryIds: Set<number>;
|
||||
source: MediaSource;
|
||||
@@ -250,6 +342,7 @@ function MediaSourceCard({
|
||||
) : source.libraries.map((library) => (
|
||||
<LibraryRow
|
||||
key={library.id}
|
||||
allowDeepScan={!isLocal}
|
||||
editable={isLocal}
|
||||
library={library}
|
||||
onScanLibrary={onScanLibrary}
|
||||
@@ -268,15 +361,17 @@ function MediaSourceCard({
|
||||
}
|
||||
|
||||
function LibraryRow({
|
||||
allowDeepScan,
|
||||
editable,
|
||||
library,
|
||||
onScanLibrary,
|
||||
scanStatus,
|
||||
scanning
|
||||
}: {
|
||||
allowDeepScan: boolean;
|
||||
editable: boolean;
|
||||
library: MediaSourceLibrary;
|
||||
onScanLibrary: (libraryId: number) => Promise<void>;
|
||||
onScanLibrary: (libraryId: number, deep?: boolean) => Promise<void>;
|
||||
scanStatus: LibraryScanStatus | null;
|
||||
scanning: boolean;
|
||||
}) {
|
||||
@@ -300,6 +395,11 @@ function LibraryRow({
|
||||
) : (
|
||||
<Badge tone="ok" dot>Synced</Badge>
|
||||
)}
|
||||
{allowDeepScan && (
|
||||
<IconButton disabled={disabled} onClick={() => void onScanLibrary(library.id, true)} size="sm" title={`Deep scan ${library.name}`}>
|
||||
<ScanSearch aria-hidden="true" size={15} />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton disabled={disabled} onClick={() => void onScanLibrary(library.id)} size="sm" title={`Scan ${library.name}`}>
|
||||
<RefreshCw aria-hidden="true" size={15} />
|
||||
</IconButton>
|
||||
|
||||
Reference in New Issue
Block a user