Implements the ChicoryTV Libraries screen from design-system/templates/chicorytv-admin/ScheduleLibrary.jsx, wired to the live media-source and library scan APIs.
Mapping / Scope
Prototype need
Live API mapping
Notes
Media source cards
GET /api/media-sources -> MediaSourceResponseModel[]
{ id, name, mediaKind, lastScan, itemCount }; tests use generated/backend enum values such as Movies, Shows, OtherVideos, MusicVideos.
Scan progress
GET /api/libraries/scan-status -> LibraryScanStatusResponseModel[]
Polls only while at least one scan-status entry exists, at a minimum 10s interval; stops when no scans remain and refreshes sources once on completion.
Trigger scan
POST /api/libraries/{id}/scan
Uses no request body; handles 200 OK success and bare 404 failure. Global write-key ProblemDetails still surfaces via ApiError.detail when present.
Scan show
POST /api/libraries/{id}/scan-show
Not wired because the prototype has no show-title workflow.
Visible deferrals: Add Source, Scan All, and source settings are disabled because the current API surface does not provide those SPA workflows.
## Summary
Implements the ChicoryTV Libraries screen from `design-system/templates/chicorytv-admin/ScheduleLibrary.jsx`, wired to the live media-source and library scan APIs.
## Mapping / Scope
| Prototype need | Live API mapping | Notes |
| --- | --- | --- |
| Media source cards | `GET /api/media-sources` -> `MediaSourceResponseModel[]` | Exact `kind` values from backend mapper: `Local`, `Plex`, `Jellyfin`, `Emby`; `connectionAddress` nullable. |
| Library rows | `MediaSourceResponseModel.libraries[]` | `{ id, name, mediaKind, lastScan, itemCount }`; tests use generated/backend enum values such as `Movies`, `Shows`, `OtherVideos`, `MusicVideos`. |
| Scan progress | `GET /api/libraries/scan-status` -> `LibraryScanStatusResponseModel[]` | Polls only while at least one scan-status entry exists, at a minimum 10s interval; stops when no scans remain and refreshes sources once on completion. |
| Trigger scan | `POST /api/libraries/{id}/scan` | Uses no request body; handles 200 OK success and bare 404 failure. Global write-key ProblemDetails still surfaces via `ApiError.detail` when present. |
| Scan show | `POST /api/libraries/{id}/scan-show` | Not wired because the prototype has no show-title workflow. |
Visible deferrals: `Add Source`, `Scan All`, and source settings are disabled because the current API surface does not provide those SPA workflows.
## Files changed
- `web/src/api/libraries.ts`
- `web/src/api/index.ts`
- `web/src/App.tsx`
- `web/src/App.test.tsx`
- `web/src/shell.css`
## Verification
- `cd web && npm ci` passed
- `cd web && npm run lint` passed
- `cd web && npm run typecheck` passed
- `cd web && npm test -- --run` passed: 70 tests
- `cd web && npm run build` passed
- `TZ=UTC dotnet build ErsatzTV.sln` passed (existing NuGet/nullability warnings only)
- `TZ=UTC dotnet test ErsatzTV.Tests/ErsatzTV.Tests.csproj --no-build` passed: 368 tests
- `TZ=UTC dotnet test ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj --no-build` passed: 493 passed, 1 skipped
Closes #88
Verdict: APPROVE with findings — no blockers, but 2 production-visible SUBSTANTIALS in the scan flow + 1 substantial fidelity drop + nits. Notably the cleanest PR of the series on contracts and process: mapper-exact kind fixtures, honest bare-404 scan test, mount-delta isolation, genuine poll stop/refetch-once transition test, responsive collapse present, a11y solid.
SUBSTANTIAL
Scan progress renders ~0% for the whole scan: the wire value of LibraryScanStatusResponseModel.percent is a 0–1 fraction (traced end-to-end: scanner computes fractions, proxy/handler never ×100) but the UI feeds it to ProgressBar (clamped 0–100) — a half-done scan shows a 0.5% bar. Tests mask it with invented 0–100 fixtures (the #109/#127 class). Fix: ×100 in the frontend + fractional wire-value fixtures; backend field name/scale wart noted for the backlog.
Polling frequently never starts after triggering a scan: POST /scan returns 200 on queueing, but scan-status only reports scans the scanner actually started (guaranteed seconds of lag on Plex/Jellyfin/Emby — a full libraries-sync runs first). The single immediate status fetch lands in that gap → the just-triggered id is pruned from scanningLibraryIds (button re-enables mid-scan) and the scanStatuses.length === 0 guard never starts the interval → no progress, no completion refresh until manual reload. Fix: pending-ids set that survives pruning until seen active (or grace window); poll while pending ∪ active is nonempty.
Per-source "Scan" action silently dropped: the prototype's source-card header has Scan + Settings; the PR ships only Settings (visibly deferred). Needs the visible-deferral treatment (or wiring it to scan all the source's libraries).
NITS
The media-sources-500 test invents a ProblemDetails body the backend can't produce (no exception middleware exists) — should be a bare 500 asserting the generic fallback, the exact honest pattern the PR itself uses for the scan-trigger 404.
No fractional/0/100 percent edge tests (backend emits decimals like 62.5... of a fraction — folds into fix 1's fixture correction).
Poll effect depends on state → the interval is torn down and recreated every tick; should depend on a derived hasActiveScans boolean.
triggerScan lacks the ref-based double-submit guard (#125/#127 convention) — and finding 2's prune race actively re-enables the button.
CSS: --radius-xs fallback 4px vs real 3px (every other usage in the file says 3px).
Source-card StatusDot conveys scanning/ok by color alone (no label/text) — the per-library rows pair it with a Badge; the source-level dot should too (or use StatusDot's label).
Verified clean
Endpoints/DTO shapes exact vs backend; kind strings match GetAllMediaSourcesForApiHandler exactly; scan POST bare-200/404 handling precisely matches the controller; mount discipline (one sources + one status fetch, StrictMode-safe); poll stops on empty and the completion source-refetch fires exactly once; failed poll ticks don't kill the interval; no fetches in setState updaters; orphan status rows safe; concurrent scans compose; responsive collapse present for all new grids; no phantom component props; no new deps; scope = 5 declared files; scan-show genuinely absent from the prototype (not a drop); per-library buttons and progress bars properly labeled.
## Fable review — PR #128 (head 5c4acb9c), read-only, 3-lens subagent review
**Verdict: APPROVE with findings — no blockers, but 2 production-visible SUBSTANTIALS in the scan flow + 1 substantial fidelity drop + nits. Notably the cleanest PR of the series on contracts and process: mapper-exact `kind` fixtures, honest bare-404 scan test, mount-delta isolation, genuine poll stop/refetch-once transition test, responsive collapse present, a11y solid.**
### SUBSTANTIAL
1. **Scan progress renders ~0% for the whole scan**: the wire value of `LibraryScanStatusResponseModel.percent` is a **0–1 fraction** (traced end-to-end: scanner computes fractions, proxy/handler never ×100) but the UI feeds it to `ProgressBar` (clamped 0–100) — a half-done scan shows a 0.5% bar. Tests mask it with invented 0–100 fixtures (the #109/#127 class). Fix: ×100 in the frontend + fractional wire-value fixtures; backend field name/scale wart noted for the backlog.
2. **Polling frequently never starts after triggering a scan**: POST /scan returns 200 on *queueing*, but scan-status only reports scans the scanner actually *started* (guaranteed seconds of lag on Plex/Jellyfin/Emby — a full libraries-sync runs first). The single immediate status fetch lands in that gap → the just-triggered id is pruned from `scanningLibraryIds` (button re-enables mid-scan) and the `scanStatuses.length === 0` guard never starts the interval → no progress, no completion refresh until manual reload. Fix: pending-ids set that survives pruning until seen active (or grace window); poll while `pending ∪ active` is nonempty.
3. **Per-source "Scan" action silently dropped**: the prototype's source-card header has Scan + Settings; the PR ships only Settings (visibly deferred). Needs the visible-deferral treatment (or wiring it to scan all the source's libraries).
### NITS
4. The media-sources-500 test invents a ProblemDetails body the backend can't produce (no exception middleware exists) — should be a bare 500 asserting the generic fallback, the exact honest pattern the PR itself uses for the scan-trigger 404.
5. No fractional/0/100 percent edge tests (backend emits decimals like 62.5... of a fraction — folds into fix 1's fixture correction).
6. `await loadScanStatuses()` awaits `undefined` (callback returns void) — misleading no-op.
7. Poll effect depends on `state` → the interval is torn down and recreated every tick; should depend on a derived `hasActiveScans` boolean.
8. `triggerScan` lacks the ref-based double-submit guard (#125/#127 convention) — and finding 2's prune race actively re-enables the button.
9. CSS: `--radius-xs` fallback 4px vs real 3px (every other usage in the file says 3px).
10. Source-card `StatusDot` conveys scanning/ok by color alone (no label/text) — the per-library rows pair it with a Badge; the source-level dot should too (or use StatusDot's `label`).
### Verified clean
Endpoints/DTO shapes exact vs backend; `kind` strings match `GetAllMediaSourcesForApiHandler` exactly; scan POST bare-200/404 handling precisely matches the controller; mount discipline (one sources + one status fetch, StrictMode-safe); poll stops on empty and the completion source-refetch fires exactly once; failed poll ticks don't kill the interval; no fetches in setState updaters; orphan status rows safe; concurrent scans compose; responsive collapse present for all new grids; no phantom component props; no new deps; scope = 5 declared files; scan-show genuinely absent from the prototype (not a drop); per-library buttons and progress bars properly labeled.
- Normalize scan-status percent at the API boundary: the wire value is a
0-1 fraction despite the field name; the bar previously showed ~0% for
the whole scan (fixtures now use fractional wire values incl. 0/1)
- Fix the poll-start race: a triggered library enters a pending set that
survives status pruning until seen active or a 3-tick grace window
expires; the poll runs while pending ∪ active is nonempty (previously
the queue-to-start lag could mean polling never started and the button
re-enabled mid-scan); grace ticks also drain on persistent status-fetch
errors so the UI can't stick
- Keep setState updaters pure: pending/grace bookkeeping hoisted into
pruneGraceExpiredPending() outside the updater (StrictMode-safe)
- Wire the prototype's per-source Scan button (one POST per library,
disabled while any of its libraries is pending/active)
- Honest bare-500 media-sources failure test (backend has no exception
middleware); loadScanStatuses returns its promise; poll effect depends
on derived hasActiveScans; synchronous ref double-submit guard;
--radius-xs fallback 3px; StatusDot label on source cards (75 tests)
Review: PR #128 findings
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
timothy
merged commit d63719ece9 into main2026-07-05 19:06:51 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
Implements the ChicoryTV Libraries screen from
design-system/templates/chicorytv-admin/ScheduleLibrary.jsx, wired to the live media-source and library scan APIs.Mapping / Scope
GET /api/media-sources->MediaSourceResponseModel[]kindvalues from backend mapper:Local,Plex,Jellyfin,Emby;connectionAddressnullable.MediaSourceResponseModel.libraries[]{ id, name, mediaKind, lastScan, itemCount }; tests use generated/backend enum values such asMovies,Shows,OtherVideos,MusicVideos.GET /api/libraries/scan-status->LibraryScanStatusResponseModel[]POST /api/libraries/{id}/scanApiError.detailwhen present.POST /api/libraries/{id}/scan-showVisible deferrals:
Add Source,Scan All, and source settings are disabled because the current API surface does not provide those SPA workflows.Files changed
web/src/api/libraries.tsweb/src/api/index.tsweb/src/App.tsxweb/src/App.test.tsxweb/src/shell.cssVerification
cd web && npm cipassedcd web && npm run lintpassedcd web && npm run typecheckpassedcd web && npm test -- --runpassed: 70 testscd web && npm run buildpassedTZ=UTC dotnet build ErsatzTV.slnpassed (existing NuGet/nullability warnings only)TZ=UTC dotnet test ErsatzTV.Tests/ErsatzTV.Tests.csproj --no-buildpassed: 368 testsTZ=UTC dotnet test ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj --no-buildpassed: 493 passed, 1 skippedCloses #88
Fable review — PR #128 (head
5c4acb9c), read-only, 3-lens subagent reviewVerdict: APPROVE with findings — no blockers, but 2 production-visible SUBSTANTIALS in the scan flow + 1 substantial fidelity drop + nits. Notably the cleanest PR of the series on contracts and process: mapper-exact
kindfixtures, honest bare-404 scan test, mount-delta isolation, genuine poll stop/refetch-once transition test, responsive collapse present, a11y solid.SUBSTANTIAL
LibraryScanStatusResponseModel.percentis a 0–1 fraction (traced end-to-end: scanner computes fractions, proxy/handler never ×100) but the UI feeds it toProgressBar(clamped 0–100) — a half-done scan shows a 0.5% bar. Tests mask it with invented 0–100 fixtures (the #109/#127 class). Fix: ×100 in the frontend + fractional wire-value fixtures; backend field name/scale wart noted for the backlog.scanningLibraryIds(button re-enables mid-scan) and thescanStatuses.length === 0guard never starts the interval → no progress, no completion refresh until manual reload. Fix: pending-ids set that survives pruning until seen active (or grace window); poll whilepending ∪ activeis nonempty.NITS
await loadScanStatuses()awaitsundefined(callback returns void) — misleading no-op.state→ the interval is torn down and recreated every tick; should depend on a derivedhasActiveScansboolean.triggerScanlacks the ref-based double-submit guard (#125/#127 convention) — and finding 2's prune race actively re-enables the button.--radius-xsfallback 4px vs real 3px (every other usage in the file says 3px).StatusDotconveys scanning/ok by color alone (no label/text) — the per-library rows pair it with a Badge; the source-level dot should too (or use StatusDot'slabel).Verified clean
Endpoints/DTO shapes exact vs backend;
kindstrings matchGetAllMediaSourcesForApiHandlerexactly; scan POST bare-200/404 handling precisely matches the controller; mount discipline (one sources + one status fetch, StrictMode-safe); poll stops on empty and the completion source-refetch fires exactly once; failed poll ticks don't kill the interval; no fetches in setState updaters; orphan status rows safe; concurrent scans compose; responsive collapse present for all new grids; no phantom component props; no new deps; scope = 5 declared files; scan-show genuinely absent from the prototype (not a drop); per-library buttons and progress bars properly labeled.