feat(api): #271 collections scan-status REST surface + authoritative SPA reconcile
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 10s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m51s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped

Add GET /api/media-sources/collections-scan-status (MediaSourcesController →
GetCollectionsScanStatus handler) reporting which media-source families
(plex/jellyfin/emby) currently hold their external-collections scan lock,
reading IEntityLocker.Are{X}CollectionsLocked(). The lock is family-global
(no source id) and boolean (no percent), so the DTO carries just {family} and
returns only active families — the counterpart to GET /api/libraries/scan-status.

SPA: useCollectionsScan now polls this endpoint and reconciles optimistic
pending against the active-family set (seeding on mount so an in-progress scan
disables buttons immediately), using the same grace-tick helper as library
scans (now generic over the pending key type). Drops COLLECTIONS_PENDING_TIMEOUT_MS
— a long deep scan no longer re-enables the button early, and a fast scan no
longer wedges it disabled for the full timeout. A row shows Scanning when its
family is active or it has an in-grace optimistic pending key.

Tests: handler (3), controller route+delegation (2), SPA api fn + hook reconcile
(mount-seed / 202-promote / 409-keeps-disabled / 404-error). OpenAPI + TS types
regenerated. Docs: api-conventions §3b, blazor-route-parity §5, decisions.md.

Unblocks #91b (arc item 4): Libraries.razor's collections-scan affordance now
has full authoritative parity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 14:23:58 +02:00
co-authored by Claude Opus 4.8
parent c40e78d840
commit 6d31758cca
15 changed files with 495 additions and 67 deletions
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.MediaSources;
namespace ErsatzTV.Application.MediaSources;
public record GetCollectionsScanStatus : IRequest<List<CollectionsScanStatusResponseModel>>;
@@ -0,0 +1,38 @@
#nullable enable
using ErsatzTV.Core.Api.MediaSources;
using ErsatzTV.Core.Interfaces.Locking;
namespace ErsatzTV.Application.MediaSources;
// Reports which media-source families currently hold their external-collections scan lock. The lock
// is the running scan (the scan-collections controllers acquire it before enqueueing and the scanner
// releases it on completion), so IEntityLocker is the authoritative source — analogous to how
// GetLibraryScanStatus reads IScannerProxyService.GetActiveScans(). Collections locks are family-global,
// so this returns at most one entry per family, and only for families actively scanning.
public class GetCollectionsScanStatusHandler(IEntityLocker entityLocker)
: IRequestHandler<GetCollectionsScanStatus, List<CollectionsScanStatusResponseModel>>
{
public Task<List<CollectionsScanStatusResponseModel>> Handle(
GetCollectionsScanStatus request,
CancellationToken cancellationToken)
{
var result = new List<CollectionsScanStatusResponseModel>();
if (entityLocker.ArePlexCollectionsLocked())
{
result.Add(new CollectionsScanStatusResponseModel("plex"));
}
if (entityLocker.AreJellyfinCollectionsLocked())
{
result.Add(new CollectionsScanStatusResponseModel("jellyfin"));
}
if (entityLocker.AreEmbyCollectionsLocked())
{
result.Add(new CollectionsScanStatusResponseModel("emby"));
}
return Task.FromResult(result);
}
}
@@ -0,0 +1,8 @@
#nullable enable
namespace ErsatzTV.Core.Api.MediaSources;
// One entry per media-source family (plex/jellyfin/emby) whose external-collections scan is currently
// running. Collections locks are family-global (there is no per-source collections lock), so an entry
// means every source of that family is scanning. Mirrors the shape of LibraryScanStatusResponseModel,
// but there is no percent: collections scans expose only a boolean lock, not progress.
public record CollectionsScanStatusResponseModel(string Family);
@@ -0,0 +1,60 @@
using ErsatzTV.Application.MediaSources;
using ErsatzTV.Core.Api.MediaSources;
using ErsatzTV.Core.Interfaces.Locking;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.MediaSources;
[TestFixture]
public class GetCollectionsScanStatusHandlerTests
{
[Test]
public async Task Handle_Should_Return_Empty_When_No_Family_Is_Locked()
{
var locker = Substitute.For<IEntityLocker>();
locker.ArePlexCollectionsLocked().Returns(false);
locker.AreJellyfinCollectionsLocked().Returns(false);
locker.AreEmbyCollectionsLocked().Returns(false);
var handler = new GetCollectionsScanStatusHandler(locker);
List<CollectionsScanStatusResponseModel> result =
await handler.Handle(new GetCollectionsScanStatus(), CancellationToken.None);
result.ShouldBeEmpty();
}
[Test]
public async Task Handle_Should_Return_Only_Locked_Families()
{
var locker = Substitute.For<IEntityLocker>();
locker.ArePlexCollectionsLocked().Returns(true);
locker.AreJellyfinCollectionsLocked().Returns(false);
locker.AreEmbyCollectionsLocked().Returns(true);
var handler = new GetCollectionsScanStatusHandler(locker);
List<CollectionsScanStatusResponseModel> result =
await handler.Handle(new GetCollectionsScanStatus(), CancellationToken.None);
result.Select(r => r.Family).ShouldBe(["plex", "emby"]);
}
[Test]
public async Task Handle_Should_Return_All_Families_When_All_Locked()
{
var locker = Substitute.For<IEntityLocker>();
locker.ArePlexCollectionsLocked().Returns(true);
locker.AreJellyfinCollectionsLocked().Returns(true);
locker.AreEmbyCollectionsLocked().Returns(true);
var handler = new GetCollectionsScanStatusHandler(locker);
List<CollectionsScanStatusResponseModel> result =
await handler.Handle(new GetCollectionsScanStatus(), CancellationToken.None);
result.Select(r => r.Family).ShouldBe(["plex", "jellyfin", "emby"]);
}
}
@@ -36,6 +36,33 @@ public class MediaSourcesControllerTests
attribute.Name.ShouldBe("GetMediaSources");
}
[Test]
public void CollectionsScanStatus_Should_Expose_Idiomatic_Rest_Route()
{
MethodInfo action = typeof(MediaSourcesController)
.GetMethod(nameof(MediaSourcesController.GetCollectionsScanStatus))
?? throw new AssertionException("Missing action GetCollectionsScanStatus");
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
attribute.HttpMethods.ShouldContain("GET");
attribute.Template.ShouldBe("/api/media-sources/collections-scan-status");
attribute.Name.ShouldBe("GetCollectionsScanStatus");
}
[Test]
public async Task GetCollectionsScanStatus_Should_Return_Results_From_Mediator()
{
var expected = new List<CollectionsScanStatusResponseModel> { new("plex"), new("emby") };
_mediator.Send(Arg.Any<GetCollectionsScanStatus>(), Arg.Any<CancellationToken>())
.Returns(expected);
List<CollectionsScanStatusResponseModel> result =
await _controller.GetCollectionsScanStatus(CancellationToken.None);
result.ShouldBe(expected);
}
[Test]
public async Task GetAll_Should_Return_Results_From_Mediator()
{
@@ -16,4 +16,18 @@ public class MediaSourcesController(IMediator mediator) : ControllerBase
[ProducesResponseType(typeof(List<MediaSourceResponseModel>), StatusCodes.Status200OK)]
public async Task<List<MediaSourceResponseModel>> GetAll(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllMediaSourcesForApi(), cancellationToken);
[HttpGet("/api/media-sources/collections-scan-status", Name = "GetCollectionsScanStatus")]
[Tags("Media Sources")]
[EndpointSummary("Get active external-collections scan status")]
[EndpointDescription(
"Returns the media-source families (plex/jellyfin/emby) whose external-collections scan lock is " +
"currently held. Collections locks are family-global, so an entry means every source of that family " +
"is scanning. Empty when nothing is scanning. The SPA polls this to reconcile the collections scan " +
"buttons against a live active set, matching the library-scan UX.")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<CollectionsScanStatusResponseModel>), StatusCodes.Status200OK)]
public async Task<List<CollectionsScanStatusResponseModel>> GetCollectionsScanStatus(
CancellationToken cancellationToken) =>
await mediator.Send(new GetCollectionsScanStatus(), cancellationToken);
}
+65
View File
@@ -10357,6 +10357,60 @@
]
}
},
"/api/media-sources/collections-scan-status": {
"get": {
"tags": [
"Media Sources"
],
"summary": "Get active external-collections scan status",
"description": "Returns the media-source families (plex/jellyfin/emby) whose external-collections scan lock is currently held. Collections locks are family-global, so an entry means every source of that family is scanning. Empty when nothing is scanning. The SPA polls this to reconcile the collections scan buttons against a live active set, matching the library-scan UX.",
"operationId": "GetCollectionsScanStatus",
"responses": {
"200": {
"description": "OK",
"content": {
"text/plain": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/CollectionsScanStatusResponseModel"
}
}
},
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/CollectionsScanStatusResponseModel"
}
}
},
"text/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/CollectionsScanStatusResponseModel"
}
}
}
}
},
"401": {
"description": "API key missing or invalid.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
},
"security": [
{ }
]
}
},
"/api/movies/{id}": {
"get": {
"tags": [
@@ -23680,6 +23734,17 @@
],
"type": "string"
},
"CollectionsScanStatusResponseModel": {
"required": [
"family"
],
"type": "object",
"properties": {
"family": {
"type": "string"
}
}
},
"CollectionType": {
"enum": [
"Collection",
+9
View File
@@ -168,6 +168,15 @@ true, deep)` to the scanner channel and returns **202**. `ScannerService` releas
enqueue throws. `POST /api/libraries/{id}/scan?deep=` similarly threads an optional `[FromQuery] bool
deep` into `QueueLibraryScanByLibraryId(id, DeepScan)`.
**Status counterpart for a lock-backed async op.** A queue-triggering endpoint whose "is it running?"
state lives in a lock/registry should expose a **GET status surface** the SPA can poll to reconcile its
optimistic pending flag, rather than relying on a client-side timeout. Two exemplars:
`GET /api/libraries/scan-status` reads `IScannerProxyService.GetActiveScans()` (per-library, with
percent); `GET /api/media-sources/collections-scan-status` (#271) reads
`IEntityLocker.Are{X}CollectionsLocked()` and returns one `{family}` entry per **family-global**
collections lock that's held (no id, no percent — the lock granularity dictates the DTO shape). Return
only the *active* entries (empty list = nothing running), mirroring the queue op's own lock.
`NotFoundError : BaseError` lives in `ErsatzTV.Core/Errors/NotFoundError.cs` — return it from a
handler's validation when a lookup fails, so the controller-side mapping falls out for free.
+2 -2
View File
@@ -325,8 +325,8 @@ so the deletion diff stays pure): `LibrariesScreen` now wires the shipped `scanL
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
now reconcile against `GET /api/media-sources/collections-scan-status` (family-global lock state), like library
scans do — #271 replaced the original optimistic timeout with authoritative polling. (#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 and the
**mandatory cold adversarial pass** complete, the runbook was executed, in order:
+29
View File
@@ -846,6 +846,35 @@ surfaces the error). This is the honest ceiling of what the current API exposes.
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.
## 2026-07-12 — External-collections scans get an authoritative status surface (#271); the SPA timeout is retired
**Context.** The 2026-07-11 entry above shipped collections-scan buttons with an optimistic,
`COLLECTIONS_PENDING_TIMEOUT_MS`-bounded pending flag because collections locks had **no** HTTP mirror
(unlike library scans, which reconcile against `GET /api/libraries/scan-status`). That was the honest
ceiling of the API at the time and was filed as **#271** so the timeout wasn't mistaken for the end state.
This entry closes that follow-up.
**Decision 1 — one family-global status endpoint, reading `IEntityLocker`.** New
`GET /api/media-sources/collections-scan-status` (`MediaSourcesController` → `GetCollectionsScanStatus`
handler) returns one `CollectionsScanStatusResponseModel { family }` entry per media-source family
(`plex`/`jellyfin`/`emby`) whose collections lock is currently held, and only for active ones — the direct
counterpart to `GET /api/libraries/scan-status`. It reads `IEntityLocker.Are{X}CollectionsLocked()` (the lock
*is* the running scan — the scan-collections controllers acquire it before enqueueing and the scanner releases
it on completion), analogous to how the library endpoint reads `IScannerProxyService.GetActiveScans()`. Two
deliberate shape differences from libraries: (a) **family-global, not per-source** — the collections lock takes
no source id (`LockPlexCollections()`), so an entry means *every* source of that family is scanning, matching
Blazor's all-rows-disabled behavior (per the PR #272 review note); (b) **no percent** — collections scans
expose only a boolean lock, not progress.
**Decision 2 — the SPA reconciles authoritatively; the fixed timeout is removed.** `useCollectionsScan` now
polls the new endpoint (seeding on mount, so a scan already running when the screen opens disables the buttons
immediately — the old timeout couldn't) and reconciles optimistic pending against the active-family set using
the **same `pruneGraceExpiredPending` grace-tick helper** the library hook uses (now generic over the pending
key type). A row shows "Scanning" when its family is in the active set **or** it has a still-in-grace optimistic
pending key. The grace window is kept (not the old wholesale timeout) to absorb the click→observed-active lag and
the fast-scan-between-polls race — the same bounded-pending discipline #232/#230 established for library scans.
`COLLECTIONS_PENDING_TIMEOUT_MS` is gone.
## 2026-07-11 — Blazor Server UI removed (#91 phase b)
The #91 phase (b) removal PR deletes the legacy Blazor Server UI now that the ChicoryTV SPA has parity
+2 -1
View File
@@ -2,7 +2,7 @@
*Generated by `scripts/generate-endpoint-index.py` from `ErsatzTV/wwwroot/openapi/v1.json`. Do not edit by hand -- regenerated by `scripts/update-openapi.sh`.*
158 endpoints, 243 operations.
159 endpoints, 244 operations.
## Artists
@@ -227,6 +227,7 @@
| Method | Path | Operation | Summary |
|---|---|---|---|
| GET | `/api/media-sources` | GetMediaSources | Get all media sources with their libraries |
| GET | `/api/media-sources/collections-scan-status` | GetCollectionsScanStatus | Get active external-collections scan status |
## Movies
+3
View File
@@ -255,6 +255,9 @@ export interface components {
"ChannelTranscodeMode": "OnDemand";
"ChannelWatermarkImageSource": "Custom" | "ChannelLogo" | "Resource";
"ChannelWatermarkMode": "None" | "Permanent" | "Intermittent" | "OpacityExpression";
"CollectionsScanStatusResponseModel": {
"family": string;
};
"CollectionType": "Collection" | "TelevisionShow" | "TelevisionSeason" | "Artist" | "MultiCollection" | "SmartCollection" | "Playlist" | "RerunFirstRun" | "RerunRerun" | "SearchQuery" | "Movie" | "Episode" | "MusicVideo" | "OtherVideo" | "Song" | "Image" | "RemoteStream" | "FakeCollection" | "FakePlaylistItem";
"CombinedVersion": {
"apiVersion": number;
+103 -1
View File
@@ -1,15 +1,56 @@
import { renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { scanCollections, scanLibrary, scanShow } from './libraries';
import {
getCollectionsScanStatus,
scanCollections,
scanLibrary,
scanShow,
useCollectionsScan
} from './libraries';
function noContent(): Response {
return new Response(null, { status: 200 });
}
function json(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' }, status });
}
function lastCall(fetchMock: ReturnType<typeof vi.spyOn>) {
const call = fetchMock.mock.calls[fetchMock.mock.calls.length - 1];
return { init: call[1] as RequestInit | undefined, url: String(call[0]) };
}
// Stateful collections backend mock: the scan-collections POST acquires the family-global lock
// synchronously (as the real controller does before returning 202/409), so a subsequent status GET
// reports that family active. `postStatus` overrides the POST response for error-path tests.
function mockCollectionsBackend(opts: { postStatus?: number; seededFamilies?: string[] } = {}) {
const active = new Set<string>(opts.seededFamilies ?? []);
const fetchMock = vi.spyOn(window, 'fetch').mockImplementation((input, init) => {
const url = String(input);
const method = (init?.method ?? 'GET').toUpperCase();
if (url.includes('/api/media-sources/collections-scan-status')) {
return Promise.resolve(json([...active].map((family) => ({ family }))));
}
const match = url.match(/\/api\/media-sources\/(\w+)\/\d+\/scan-collections/);
if (match && method === 'POST') {
const status = opts.postStatus ?? 202;
if (status === 202 || status === 409) {
// Both mean a collections scan is now running for this family -> the lock is held.
active.add(match[1]);
return Promise.resolve(status === 202 ? noContent() : json({ detail: 'already scanning' }, 409));
}
return Promise.resolve(json({ detail: 'not found' }, status));
}
return Promise.resolve(json({ detail: 'unexpected' }, 500));
});
return { active, fetchMock };
}
describe('libraries api client', () => {
beforeEach(() => {
window.localStorage.clear();
@@ -56,4 +97,65 @@ describe('libraries api client', () => {
const { init } = lastCall(fetchMock);
expect(JSON.parse(String(init?.body))).toEqual({ deepScan: false, showId: 17 });
});
it('getCollectionsScanStatus GETs the collections scan-status endpoint', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(json([{ family: 'plex' }]));
const result = await getCollectionsScanStatus();
expect(fetchMock).toHaveBeenCalledWith('/api/media-sources/collections-scan-status', expect.anything());
expect(result).toEqual([{ family: 'plex' }]);
});
});
describe('useCollectionsScan', () => {
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it('seeds activeFamilies from the initial poll (scan already in progress on mount)', async () => {
mockCollectionsBackend({ seededFamilies: ['plex'] });
const { result } = renderHook(() => useCollectionsScan());
await waitFor(() => expect(result.current.activeFamilies.has('plex')).toBe(true));
expect(result.current.pendingKeys.size).toBe(0);
});
it('optimistically marks pending on scan, then promotes to active once the family is observed', async () => {
mockCollectionsBackend();
const { result } = renderHook(() => useCollectionsScan());
await waitFor(() => expect(result.current.activeFamilies.size).toBe(0));
await result.current.scan('jellyfin', 7, false);
// The scan()'s 202 poll observes the (synchronously-locked) family active and reconciles.
await waitFor(() => expect(result.current.activeFamilies.has('jellyfin')).toBe(true));
expect(result.current.pendingKeys.has('jellyfin:7')).toBe(false);
expect(result.current.error).toBeNull();
});
it('keeps the family disabled and surfaces no error on a 409 (already scanning)', async () => {
mockCollectionsBackend({ postStatus: 409 });
const { result } = renderHook(() => useCollectionsScan());
await waitFor(() => expect(result.current.activeFamilies.size).toBe(0));
await result.current.scan('emby', 3, true);
await waitFor(() => expect(result.current.activeFamilies.has('emby')).toBe(true));
expect(result.current.error).toBeNull();
});
it('clears pending and surfaces an error on a 404 (source missing)', async () => {
mockCollectionsBackend({ postStatus: 404 });
const { result } = renderHook(() => useCollectionsScan());
await waitFor(() => expect(result.current.activeFamilies.size).toBe(0));
await result.current.scan('plex', 9, false);
await waitFor(() => expect(result.current.error).not.toBeNull());
expect(result.current.pendingKeys.has('plex:9')).toBe(false);
expect(result.current.activeFamilies.has('plex')).toBe(false);
});
});
+117 -56
View File
@@ -5,6 +5,7 @@ import type { components } from './generated/v1';
export type MediaSource = components['schemas']['MediaSourceResponseModel'];
export type MediaSourceLibrary = components['schemas']['MediaSourceLibraryResponseModel'];
export type LibraryScanStatus = components['schemas']['LibraryScanStatusResponseModel'];
export type CollectionsScanStatus = components['schemas']['CollectionsScanStatusResponseModel'];
export interface LibrariesScreenData {
scanStatuses: LibraryScanStatus[];
@@ -93,32 +94,34 @@ const PENDING_GRACE_TICKS = 3;
// there). A pending id not yet seen active is kept, but only for PENDING_GRACE_TICKS polls: the
// per-id counter is decremented each tick and the id is dropped once it hits zero. Mutates the
// grace-ticks map in place (delete on promote/expire, set on decrement); callers must still write
// pendingIdsRef.current with the returned set themselves, and must call this exactly once per tick
// their pending ref with the returned set themselves, and must call this exactly once per tick
// before that write to keep it a single, ref-free computation safe under StrictMode double-invocation.
function pruneGraceExpiredPending(
pendingIds: Set<number>,
graceTicks: Map<number, number>,
isSeenActive: (libraryId: number) => boolean
): Set<number> {
const nextPending = new Set<number>();
// Generic over the pending key type so both library scans (keyed by numeric library id) and
// collections scans (keyed by `${family}:${sourceId}` string) share one definition.
function pruneGraceExpiredPending<T>(
pendingIds: Set<T>,
graceTicks: Map<T, number>,
isSeenActive: (id: T) => boolean
): Set<T> {
const nextPending = new Set<T>();
pendingIds.forEach((libraryId) => {
if (isSeenActive(libraryId)) {
pendingIds.forEach((id) => {
if (isSeenActive(id)) {
// Seen active at least once - normal active/inactive pruning takes over.
graceTicks.delete(libraryId);
graceTicks.delete(id);
return;
}
const ticksRemaining = (graceTicks.get(libraryId) ?? PENDING_GRACE_TICKS) - 1;
const ticksRemaining = (graceTicks.get(id) ?? PENDING_GRACE_TICKS) - 1;
if (ticksRemaining <= 0) {
// Grace window expired without ever appearing in scan-status - give up on it.
graceTicks.delete(libraryId);
graceTicks.delete(id);
return;
}
graceTicks.set(libraryId, ticksRemaining);
nextPending.add(libraryId);
graceTicks.set(id, ticksRemaining);
nextPending.add(id);
});
return nextPending;
@@ -402,19 +405,22 @@ export function useLibrariesScreenQuery(pollMs = 10000): LibrariesScreenQuerySta
// --- 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;
// Fetch the media-source families whose external-collections scan is currently running. Collections
// locks are family-global, so this returns at most one entry per family and only for active ones -
// the authoritative counterpart to /api/libraries/scan-status for collections (#271, #91b F9).
export function getCollectionsScanStatus(): Promise<CollectionsScanStatus[]> {
return request<CollectionsScanStatus[]>('/api/media-sources/collections-scan-status');
}
export interface CollectionsScanState {
// Families (plex/jellyfin/emby) whose collections lock is currently held, per the authoritative
// poll. Every source of an active family should render as scanning (the lock is family-global).
activeFamilies: Set<CollectionsScanSource>;
error: string | null;
// Optimistically-pending `${family}:${sourceId}` keys not yet confirmed active by a poll (bridges
// the click -> observed-active window; grace-bounded so a fast/failed scan can't wedge a button).
pendingKeys: Set<string>;
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
@@ -423,68 +429,123 @@ export function collectionsScanKey(source: CollectionsScanSource, sourceId: numb
return `${source}:${sourceId}`;
}
export function useCollectionsScan(): CollectionsScanState {
const [scanningKeys, setScanningKeys] = useState<Set<string>>(new Set());
// Recover the family from a `${family}:${sourceId}` key (the family segment has no colon).
function collectionsScanKeyFamily(key: string): CollectionsScanSource {
return key.slice(0, key.indexOf(':')) as CollectionsScanSource;
}
export function useCollectionsScan(pollMs = 10000): CollectionsScanState {
const [activeFamilies, setActiveFamilies] = useState<Set<CollectionsScanSource>>(new Set());
const [pendingKeys, setPendingKeys] = 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());
// Mirrors kept in sync synchronously so scan() can guard double-submits without waiting for a render.
const activeFamiliesRef = useRef<Set<CollectionsScanSource>>(new Set());
const pendingKeysRef = useRef<Set<string>>(new Set());
// Per-pending-key countdown of remaining polls before we give up waiting for it to appear active.
const pendingGraceTicksRef = useRef<Map<string, number>>(new Map());
const activeRef = useRef(true);
const loadScanStatus = useCallback(() => {
return getCollectionsScanStatus()
.then((statuses) => {
if (!activeRef.current) {
return;
}
const active = new Set<CollectionsScanSource>(
statuses.map((status) => status.family as CollectionsScanSource)
);
activeFamiliesRef.current = active;
// Reconcile optimistic pending against the authoritative active families - a pending key
// whose family is scanning is promoted (dropped from pending; the active-family set now
// disables it), and one never seen active expires after the bounded grace window. Compute
// OUTSIDE setState (updaters must be pure under StrictMode) and write the ref here.
const nextPending = pruneGraceExpiredPending(pendingKeysRef.current, pendingGraceTicksRef.current, (key) =>
active.has(collectionsScanKeyFamily(key))
);
pendingKeysRef.current = nextPending;
setActiveFamilies(active);
setPendingKeys(nextPending);
})
.catch(() => {
if (!activeRef.current) {
return;
}
// A persistently failing poll must not leave optimistically-pending buttons stuck disabled
// forever. Burn a grace tick for every pending key (none can be confirmed active while the
// fetch is failing) so pending still expires within the bounded window.
const nextPending = pruneGraceExpiredPending(pendingKeysRef.current, pendingGraceTicksRef.current, () => false);
pendingKeysRef.current = nextPending;
setPendingKeys(nextPending);
});
}, []);
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;
// Seed the active set on mount so a collections scan already in progress (started by another tab
// or a scheduled sync) disables the buttons immediately - the old timeout approach couldn't.
void loadScanStatus();
return () => {
activeRef.current = false;
timeouts.forEach((timeoutId) => window.clearTimeout(timeoutId));
timeouts.clear();
};
}, []);
}, [loadScanStatus]);
const clearKey = useCallback((key: string) => {
const timeoutId = timeoutsRef.current.get(key);
if (timeoutId !== undefined) {
window.clearTimeout(timeoutId);
timeoutsRef.current.delete(key);
const hasActive = pendingKeys.size > 0 || activeFamilies.size > 0;
useEffect(() => {
if (!hasActive) {
return undefined;
}
const next = new Set(scanningKeysRef.current);
const intervalId = window.setInterval(loadScanStatus, Math.max(pollMs, 10000));
return () => {
window.clearInterval(intervalId);
};
}, [hasActive, loadScanStatus, pollMs]);
const clearKey = useCallback((key: string) => {
const next = new Set(pendingKeysRef.current);
next.delete(key);
scanningKeysRef.current = next;
pendingKeysRef.current = next;
pendingGraceTicksRef.current.delete(key);
if (activeRef.current) {
setScanningKeys(next);
setPendingKeys(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).
if (pendingKeysRef.current.has(key) || activeFamiliesRef.current.has(source)) {
// Already pending, or the whole family is already scanning (family-global lock) - ignore the
// duplicate submission. Quick and deep scans share the same lock, so both gate on this.
return Promise.resolve();
}
const pending = new Set(scanningKeysRef.current).add(key);
scanningKeysRef.current = pending;
setScanningKeys(pending);
// Optimistically mark pending; the POST response and the next poll tell us how to reconcile.
const pending = new Set(pendingKeysRef.current).add(key);
pendingKeysRef.current = pending;
pendingGraceTicksRef.current.set(key, PENDING_GRACE_TICKS);
setPendingKeys(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).
// 202 Accepted - a scan is genuinely queued (the controller already holds the family lock).
// Poll; the pending key is promoted to "active" once the family appears in scan-status.
void loadScanStatus();
})
.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.
// 409 Conflict - a collections scan is already running for this family. Benign: keep the
// optimistic pending flag (button stays disabled), no error; a poll reconciles it.
void loadScanStatus();
return;
}
@@ -495,10 +556,10 @@ export function useCollectionsScan(): CollectionsScanState {
}
});
},
[clearKey]
[clearKey, loadScanStatus]
);
return { error, scan, scanningKeys };
return { activeFamilies, error, pendingKeys, scan };
}
function messageFromLibrariesError(error: unknown, fallback = 'Unable to load libraries'): string {
+13 -7
View File
@@ -121,10 +121,11 @@ export function LibrariesScreen() {
{collectionsRows.length > 0 && (
<ExternalCollectionsSection
activeFamilies={collectionsScan.activeFamilies}
error={collectionsScan.error}
onScanCollections={collectionsScan.scan}
pendingKeys={collectionsScan.pendingKeys}
rows={collectionsRows}
scanningKeys={collectionsScan.scanningKeys}
/>
)}
</div>
@@ -133,18 +134,22 @@ export function LibrariesScreen() {
// 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.
// scan. A row's "scanning" state is reconciled against the authoritative collections scan-status poll
// (#271): the collections lock is family-global, so every source of an active family shows scanning
// (matching Blazor's all-rows-disabled behavior), plus a short-lived optimistic pending flag bridges
// the click -> observed-active window.
function ExternalCollectionsSection({
activeFamilies,
error,
onScanCollections,
rows,
scanningKeys
pendingKeys,
rows
}: {
activeFamilies: Set<CollectionsScanSource>;
error: string | null;
onScanCollections: (source: CollectionsScanSource, sourceId: number, deep: boolean) => Promise<void>;
pendingKeys: Set<string>;
rows: Array<{ family: CollectionsScanSource; name: string; sourceId: number }>;
scanningKeys: Set<string>;
}) {
return (
<section className="ctv-library-source-card" aria-label="External collections">
@@ -167,7 +172,8 @@ function ExternalCollectionsSection({
<div className="ctv-library-list" role="list" aria-label="External collections list">
{rows.map((row) => {
const scanning = scanningKeys.has(collectionsScanKey(row.family, row.sourceId));
const scanning =
activeFamilies.has(row.family) || pendingKeys.has(collectionsScanKey(row.family, row.sourceId));
return (
<div className="ctv-library-row" key={collectionsScanKey(row.family, row.sourceId)} role="listitem">