Merge remote-tracking branch 'origin/main' into feat/253-pr3-diff-scalar
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m21s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m12s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled

# Conflicts:
#	docs/decisions.md
This commit was merged in pull request #270.
This commit is contained in:
2026-07-11 20:45:22 +02:00
103 changed files with 4634 additions and 314 deletions
+20 -3
View File
@@ -1,4 +1,4 @@
import { ApiError, request } from './client';
import { ApiError, request, requestWithMeta, type ResponseWithMeta } from './client';
import type { components } from './generated/v1';
export type DecoTemplateGroup = components['schemas']['DecoTemplateGroupResponseModel'];
@@ -46,8 +46,25 @@ export function getDecoTemplateItems(id: number): Promise<DecoTemplateItem[]> {
return request<DecoTemplateItem[]>(`/api/deco-templates/${id}/items`);
}
export function replaceDecoTemplate(id: number, body: ReplaceDecoTemplateRequest): Promise<DecoTemplateWithItems> {
return request<DecoTemplateWithItems>(`/api/deco-templates/${id}`, { body, method: 'PUT' });
/** Load deco template items together with the deco template's concurrency ETag (issue #253). */
export function getDecoTemplateItemsWithMeta(id: number): Promise<ResponseWithMeta<DecoTemplateItem[]>> {
return requestWithMeta<DecoTemplateItem[]>(`/api/deco-templates/${id}/items`);
}
/**
* Replace a deco template. Pass the last-seen ETag as `If-Match` to reject a stale overwrite with
* 412; the resolved value carries the new ETag for a subsequent save (issue #253).
*/
export function replaceDecoTemplate(
id: number,
body: ReplaceDecoTemplateRequest,
ifMatch?: string | null
): Promise<ResponseWithMeta<DecoTemplateWithItems>> {
return requestWithMeta<DecoTemplateWithItems>(`/api/deco-templates/${id}`, {
body,
method: 'PUT',
headers: ifMatch ? { 'If-Match': ifMatch } : undefined
});
}
export function messageFromDecoTemplateError(error: unknown, fallback = 'Unable to load deco templates'): string {
+7
View File
@@ -1094,6 +1094,7 @@ export interface components {
"buildStatus": null | components["schemas"]["PlayoutBuildStatusResponseModel"];
"decoId": null | number;
"decoName": null | string;
"isLocked": boolean;
};
"PlayoutScheduleKind": "None" | "Classic" | "Block" | "Sequential" | "Scripted" | "ExternalJson";
"PlayoutSettingsResponseModel": {
@@ -1154,6 +1155,7 @@ export interface components {
"shuffleScheduleItems": boolean;
"randomStartPoint": boolean;
"fixedStartTimeBehavior": components["schemas"]["FixedStartTimeBehavior"];
"version": number;
};
"RemoteConnectionResponseModel": {
"address": null | string;
@@ -1244,6 +1246,11 @@ export interface components {
"selectedName": null | string;
"firstRunPlaybackOrder": components["schemas"]["PlaybackOrder"];
"rerunPlaybackOrder": components["schemas"]["PlaybackOrder"];
};
"ResetAllPlayoutsResponseModel": {
"queuedPlayoutIds": Array<number>;
"skippedLocked": Array<number>;
"skippedUnsupported": Array<number>;
};
"ResolutionResponseModel": {
"id": number;
+20 -1
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { scanLibrary, scanShow } from './libraries';
import { scanCollections, scanLibrary, scanShow } from './libraries';
function noContent(): Response {
return new Response(null, { status: 200 });
@@ -22,6 +22,25 @@ describe('libraries api client', () => {
expect(fetchMock).toHaveBeenCalledWith('/api/libraries/4/scan', expect.objectContaining({ method: 'POST' }));
});
it('scanLibrary appends ?deep=true for a deep scan', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
await scanLibrary(4, true);
expect(fetchMock).toHaveBeenCalledWith('/api/libraries/4/scan?deep=true', expect.objectContaining({ method: 'POST' }));
});
it('scanCollections POSTs to the media-source scan-collections endpoint', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
await scanCollections('jellyfin', 7);
expect(fetchMock).toHaveBeenCalledWith('/api/media-sources/jellyfin/7/scan-collections', expect.objectContaining({ method: 'POST' }));
});
it('scanCollections appends ?deep=true for a deep scan', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
await scanCollections('plex', 3, true);
const { url } = lastCall(fetchMock);
expect(url).toBe('/api/media-sources/plex/3/scan-collections?deep=true');
});
it('scanShow POSTs the show id and deepScan flag', async () => {
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
await scanShow(4, { deepScan: true, showId: 42 });
+129 -9
View File
@@ -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';
}
@@ -42,8 +42,24 @@ export function getLibraryScanStatus(): Promise<LibraryScanStatus[]> {
);
}
export function scanLibrary(libraryId: number): Promise<void> {
return request<void>(`/api/libraries/${libraryId}/scan`, { method: 'POST' });
// Queues a library scan. `deep` requests a deep (force-metadata) scan — the API equivalent of the
// legacy Blazor "Deep Scan Library" button (#235 F9); omit or pass false for a quick scan. Returns
// 202 when queued, 404 (missing), 409 (already scanning), 422 (sync disabled) — see §3b.
export function scanLibrary(libraryId: number, deep = false): Promise<void> {
const query = deep ? '?deep=true' : '';
return request<void>(`/api/libraries/${libraryId}/scan${query}`, { method: 'POST' });
}
// Media-source families that support an external-collections scan (#235 F9). Matches the three
// media-source controllers #202 introduced.
export type CollectionsScanSource = 'emby' | 'jellyfin' | 'plex';
// Queues an external-collections scan for a Plex/Jellyfin/Emby media source — the API equivalent of
// the legacy Blazor "Scan Collections" button (#235 F9). `deep` requests a deep scan. Returns 202
// when queued, 404 (source missing), 409 (a collections scan is already running for that source).
export function scanCollections(source: CollectionsScanSource, sourceId: number, deep = false): Promise<void> {
const query = deep ? '?deep=true' : '';
return request<void>(`/api/media-sources/${source}/${sourceId}/scan-collections${query}`, { method: 'POST' });
}
export interface ScanShowParams {
@@ -51,9 +67,11 @@ export interface ScanShowParams {
deepScan?: boolean;
}
// Queues a scan of a single show (by id) within a library. Returns 200 on success, 404 when the
// show id doesn't exist in the library, 400 when the library doesn't support single-show
// scanning. Body keys are `showId` and `deepScan` (see LibrariesController.ScanShowRequest).
// Queues a scan of a single show (by id) within a library. Returns 202 when queued, 404 when the
// show id doesn't exist in the library, 409 when a scan is already running, and 422 when the
// library doesn't support single-show scanning / sync is disabled / the scan failed to start (all
// error bodies are ProblemDetails; #235 normalized the old conflated 400). Body keys are `showId`
// and `deepScan` (see LibrariesController.ScanShowRequest).
export function scanShow(libraryId: number, params: ScanShowParams): Promise<void> {
return request<void>(`/api/libraries/${libraryId}/scan-show`, {
body: { deepScan: params.deepScan ?? false, showId: params.showId },
@@ -297,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();
}
@@ -320,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.
@@ -381,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;
+19 -4
View File
@@ -1,4 +1,4 @@
import { ApiError, request } from './client';
import { ApiError, request, requestWithMeta, type ResponseWithMeta } from './client';
import type { components } from './generated/v1';
export type AddItemsToPlaylistRequest = components['schemas']['AddItemsToPlaylistRequest'];
@@ -41,13 +41,28 @@ export function getPlaylistItems(id: number): Promise<PlaylistItem[]> {
return request<PlaylistItem[]>(`/api/playlists/${id}/items`);
}
/** Load playlist items together with the playlist's concurrency ETag (issue #253). */
export function getPlaylistItemsWithMeta(id: number): Promise<ResponseWithMeta<PlaylistItem[]>> {
return requestWithMeta<PlaylistItem[]>(`/api/playlists/${id}/items`);
}
export function createPlaylist(body: CreatePlaylistRequest): Promise<Playlist> {
return request<Playlist>('/api/playlists', { body, method: 'POST' });
}
// PUT = rename + replace the full item list; returns the persisted (re-indexed) items.
export function updatePlaylist(id: number, body: ReplacePlaylistRequest): Promise<PlaylistItem[]> {
return request<PlaylistItem[]>(`/api/playlists/${id}`, { body, method: 'PUT' });
// PUT = rename + replace the full item list; returns the persisted (re-indexed) items. Pass the
// last-seen ETag as `If-Match` to reject a stale overwrite with 412; the resolved value carries
// the new ETag for a subsequent save (issue #253).
export function updatePlaylist(
id: number,
body: ReplacePlaylistRequest,
ifMatch?: string | null
): Promise<ResponseWithMeta<PlaylistItem[]>> {
return requestWithMeta<PlaylistItem[]>(`/api/playlists/${id}`, {
body,
method: 'PUT',
headers: ifMatch ? { 'If-Match': ifMatch } : undefined
});
}
export function deletePlaylist(id: number): Promise<void> {
+7 -2
View File
@@ -108,8 +108,13 @@ export function getPlayoutChannelStates(): Promise<PlayoutChannelState[]> {
return request<PlayoutChannelState[]>('/api/channels/state');
}
export function resetAllPlayouts(): Promise<void> {
return request<void>('/api/playouts/reset-all', { method: 'POST' });
export type ResetAllPlayoutsResult = components['schemas']['ResetAllPlayoutsResponseModel'];
// Queues a reset of every eligible playout. Returns 202 with a body reporting which playouts were
// queued vs skipped (locked, or an unsupported ExternalJson/None schedule kind) — #235 replaced the
// old silent skip. The caller may surface `skipped*` to explain why some playouts didn't reset.
export function resetAllPlayouts(): Promise<ResetAllPlayoutsResult> {
return request<ResetAllPlayoutsResult>('/api/playouts/reset-all', { method: 'POST' });
}
export function deletePlayout(playoutId: number): Promise<void> {
+20 -6
View File
@@ -1,4 +1,4 @@
import { ApiError, request } from './client';
import { ApiError, request, requestWithMeta, type ResponseWithMeta } from './client';
import type { components } from './generated/v1';
// FillerPreset is exported by ./pickers — import (don't re-export) to avoid a duplicate `export *`
// name in api/index.ts. LanguageCode/getLanguages moved to ./languages (also re-exported via
@@ -46,17 +46,31 @@ export function getScheduleItems(scheduleId: number): Promise<ScheduleItemsRespo
return request<ScheduleItemsResponse>(`/api/schedules/${scheduleId}/items`);
}
/** Load schedule items together with the schedule's concurrency ETag (issue #253). */
export function getScheduleItemsWithMeta(
scheduleId: number
): Promise<ResponseWithMeta<ScheduleItemsResponse>> {
return requestWithMeta<ScheduleItemsResponse>(`/api/schedules/${scheduleId}/items`);
}
export function addScheduleItem(scheduleId: number, body: ScheduleItemRequest): Promise<ScheduleItem> {
return request<ScheduleItem>(`/api/schedules/${scheduleId}/items`, { body, method: 'POST' });
}
// Destructive replace: the server deletes+recreates every item row (new ids) and triggers playout
// rebuilds. The editor batches all local draft edits into this single call. See docs/decisions.md.
// Positional in-place reconcile: the server reuses same-typed item rows (keeping fill-group state) and
// triggers playout rebuilds. The editor batches all local draft edits into this single call. Pass the
// last-seen ETag as `If-Match` to reject a stale overwrite with 412; the resolved value carries the new
// ETag for a subsequent save (issue #253). See docs/decisions.md.
export function replaceScheduleItems(
scheduleId: number,
body: ReplaceScheduleItemsRequest
): Promise<ScheduleItem[]> {
return request<ScheduleItem[]>(`/api/schedules/${scheduleId}/items`, { body, method: 'PUT' });
body: ReplaceScheduleItemsRequest,
ifMatch?: string | null
): Promise<ResponseWithMeta<ScheduleItem[]>> {
return requestWithMeta<ScheduleItem[]>(`/api/schedules/${scheduleId}/items`, {
body,
method: 'PUT',
headers: ifMatch ? { 'If-Match': ifMatch } : undefined
});
}
export function deleteScheduleItem(scheduleId: number, itemId: number): Promise<void> {
+20 -3
View File
@@ -1,4 +1,4 @@
import { ApiError, request } from './client';
import { ApiError, request, requestWithMeta, type ResponseWithMeta } from './client';
import type { components } from './generated/v1';
export type TemplateGroup = components['schemas']['TemplateGroupResponseModel'];
@@ -47,8 +47,25 @@ export function getTemplateItems(id: number): Promise<TemplateItem[]> {
return request<TemplateItem[]>(`/api/templates/${id}/items`);
}
export function replaceTemplate(id: number, body: ReplaceTemplateRequest): Promise<TemplateWithItems> {
return request<TemplateWithItems>(`/api/templates/${id}`, { body, method: 'PUT' });
/** Load template items together with the template's concurrency ETag (issue #253). */
export function getTemplateItemsWithMeta(id: number): Promise<ResponseWithMeta<TemplateItem[]>> {
return requestWithMeta<TemplateItem[]>(`/api/templates/${id}/items`);
}
/**
* Replace a template. Pass the last-seen ETag as `If-Match` to reject a stale overwrite with 412;
* the resolved value carries the new ETag for a subsequent save (issue #253).
*/
export function replaceTemplate(
id: number,
body: ReplaceTemplateRequest,
ifMatch?: string | null
): Promise<ResponseWithMeta<TemplateWithItems>> {
return requestWithMeta<TemplateWithItems>(`/api/templates/${id}`, {
body,
method: 'PUT',
headers: ifMatch ? { 'If-Match': ifMatch } : undefined
});
}
export function copyTemplate(id: number, body: CopyTemplateRequest): Promise<Template> {