Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8d4979b0c | ||
|
|
7d93688841 | ||
|
|
d9ffbde79c | ||
|
|
d63719ece9 | ||
|
|
bee686c846 | ||
|
|
5c4acb9c7b | ||
|
|
223ef70912 | ||
|
|
a8dd88ef73 | ||
|
|
c4190e3a54 | ||
|
|
1840ef83b1 | ||
|
|
c5021e28da | ||
|
|
229f7cc1b0 | ||
|
|
f18f2dc058 | ||
|
|
2e58fa1815 | ||
|
|
8f792d964e | ||
|
|
7ee6e9870c | ||
|
|
ecf6cf7513 | ||
|
|
15c6cdd086 | ||
|
|
39d76080de | ||
|
|
4ffd777b49 |
@@ -5,131 +5,162 @@ UPDATING THIS FILE in place (rewrite the state section and the queue for the nex
|
||||
always holds the current handoff. History: created 2026-07-02 after the plan audit (#59 epic)
|
||||
filed backend gap issues #100–#111; a 7–9-way parallel workflow build once exhausted RAM, so
|
||||
builds are limited to 2–3 concurrent, never wide fan-outs. Backend gaps all landed by
|
||||
2026-07-04 (#105/#108/#100+#101+#107+#110/#103+#106/#104/#111/#102 → PRs #113–#119). The
|
||||
MERGE PASS (2026-07-04) then landed the whole SPA stack on main via PR #120.
|
||||
2026-07-04 (PRs #113–#119); merge pass PR #120; live-data screens so far: #109 Dashboard
|
||||
(PR #123), #84 Channels (PR #124), #86 Schedule editor (PR #125), #87 Playouts (PR #127),
|
||||
#88 Libraries (PR #128).
|
||||
|
||||
**Session state (2026-07-04, post-#121 merge)**: main = cc6ffcb8 (PR #121 "#99 direct
|
||||
stream session tracking"): design-system/ + web/ SPA foundation (#78–#83), backend
|
||||
#96/#98/#97, Dockerfile hotfix #122, and direct-stream session tracker #99 support are all
|
||||
on main. #97 is closed; #99 remains open for the final `/api/channels/state` wiring only
|
||||
(combine `IFFmpegSegmenterService.IsActive` with `IDirectStreamSessionTracker`). Test
|
||||
baselines on main after #121: ErsatzTV.Tests **368**, Core.Tests **493** (+1 skip); web/:
|
||||
`npm run typecheck` + `build` clean (outputs to ErsatzTV/wwwroot/app/, gitignored; SPA
|
||||
types = web/src/api/generated/v1.d.ts, regen via `npm run generate:api`). Post-merge main
|
||||
run 224 is fully green, including `Build & push image (amd64)`.
|
||||
- **#99 / PR #121 state**: merged after Fable APPROVE. Round-2 fixes removed
|
||||
`DirectStreamSessionTracker` empty-dictionary pruning to avoid orphaned live sessions and
|
||||
bypassed direct-session tracking for HEAD probes in `TrackedFileStreamResult`.
|
||||
- Worktrees: .worktrees/issue-99-session-tracking can be removed after this doc update;
|
||||
issue-97/feat-102 removed. The main checkout still sits on docs/59-ui-redesign-brief —
|
||||
fully merged now, safe to switch to main.
|
||||
**PROCESS (2026-07-05, binding)**: Fable writes each Codex prompt AND performs the PR review
|
||||
(via review subagents — parallel lenses: correctness fork + cheaper contract/tests +
|
||||
design-system agents; plus a fork verification pass over any fix diff). Codex implements.
|
||||
Every Codex prompt MUST mandate: (a) subagents where appropriate at fitting effort/model
|
||||
levels, (b) `npm ci` in each fresh worktree. Review-driven fixes are applied by fitting
|
||||
subagents too (NITS: Fable fixes on the branch; SUBSTANTIAL: back to Codex verbatim or fixed
|
||||
in-session — user decides). Merges need explicit user consent (standing consent for this
|
||||
stretch: merge when reviewers are happy and tests pass).
|
||||
|
||||
**Session state (2026-07-05, post-#88)**: main = d63719ec (PR #128 merge): Libraries screen
|
||||
live — source cards (media-sources), per-library scan progress + triggers, per-source scan,
|
||||
conditional 10s scan-status polling (pending∪active drives the interval; 3-tick grace window
|
||||
covers the queue-to-start lag; drains on persistent errors; refetches sources once on
|
||||
completion). Review found 2 production-visible substantials fixed pre-merge (bee686c8):
|
||||
percent was a 0–1 wire fraction (bar showed ~0%), and the poll-start race. Fork verification
|
||||
caught a StrictMode-unsafe ref mutation inside a setState updater — fixed via a pure hoist.
|
||||
Baselines: ErsatzTV.Tests **368**, Core.Tests **493** (+1 skip); web tests **75**,
|
||||
lint/typecheck/build clean. #84/#86/#87/#88 CLOSED. Main checkout sits on
|
||||
docs/59-ui-redesign-brief (fully merged, safe to switch).
|
||||
|
||||
**Lessons for all remaining prompts** (accumulated):
|
||||
- The image-build job runs ONLY on main pushes — PR CI cannot catch Dockerfile breakage.
|
||||
Any Dockerfile change: verify base tags exist (hub.docker.com API) and, where docker is
|
||||
available, `docker build --target <stage>` locally before merging. (`node:22-noble` cost
|
||||
a red main run; node official images have no `-noble` variant.)
|
||||
- DTO records in ErsatzTV.Core/Api MUST get file-scoped `#nullable enable` — else the spec
|
||||
emits `["null","string"]` unions and SPA types get needless `| null`. #96/#98 DTOs were
|
||||
retrofitted in 8d89ab16; ChannelStateResponseModel complied from the start. NOTE:
|
||||
ErsatzTV.Application has NO nullable context — `string?` there trips CS8632.
|
||||
- NSubstitute + ConfigElementKey: `ConfigElementKey.X` is a fresh instance per access —
|
||||
stub with `Arg.Any<ConfigElementKey>()` disambiguated by the generic `<T>`.
|
||||
- `Option<T>.ToNullable()` doesn't exist here; use `MatchUnsafe(v => (T?)v, () => null)`.
|
||||
- ./scripts/update-openapi.sh only runs `-t:GenerateOpenApiDocuments` — do a normal
|
||||
`dotnet build ErsatzTV/ErsatzTV.csproj` FIRST or it fails with MSB3680.
|
||||
- Id-taking child-collection GETs 404 on unknown parent via pre-check + ApiResults
|
||||
(precedent: ScheduleController.GetItems) + an OpenApiErrorResponseContractTests entry.
|
||||
- Backlog nits (unfiled): unclamped pageSize on paged endpoints; PlayoutController
|
||||
Create/Delete lack route Name=; PlayoutController.GetItems existence pre-check is heavy;
|
||||
>30 MB uploads return bare Kestrel 413; artwork content-type trusted from client (#66);
|
||||
schedule duration estimator: expression/zero Count → null estimate, materializes each
|
||||
referenced collection per GET. From #102 (also on #85): GET /api/guide runs the full
|
||||
21-include eager-load per channel per request — trim projection or add caching before the
|
||||
EPG grid polls it; `fillerKind` is almost always `None` (projector merges filler into
|
||||
adjacent programmes) — discrete filler entries need a JSON-only projection mode.
|
||||
- Fixture fidelity (#109/#127/#128): fixtures must be what the actually-called endpoint
|
||||
returns UNDER THE QUERY THE CLIENT SENDS. #128: `percent` is a 0–1 fraction despite its
|
||||
name (bar showed ~0%; 0–100 fixtures masked it). Enum-with-None fields are never
|
||||
truthiness-checked. Verify UNITS/scale of numeric wire fields against the producing code,
|
||||
not the field name.
|
||||
- Trigger≠started (#128): a 202/200 on a trigger endpoint means QUEUED; status endpoints may
|
||||
lag by seconds. Pending-state with a grace window bridges the gap; poll while
|
||||
pending∪active nonempty; drain grace on persistent errors so UIs can't stick.
|
||||
- setState updaters must be PURE — no fetches (#127) and no ref mutations (#128); hoist
|
||||
side-effects/bookkeeping out. StrictMode double-invokes updaters and the test renderer
|
||||
doesn't, so tests won't catch it — reviewers must.
|
||||
- OpenAPI can UNDER-report the wire (#125/#126); check the serializer before widening types.
|
||||
- Every new multi-column grid → the @media (max-width: 980px) collapse block; var() fallback
|
||||
= the token's resolved value; verify the token EXISTS (--ctv-surface-1, --text-faint don't).
|
||||
- Prototype affordances: implemented or VISIBLY deferred — never silently dropped
|
||||
(#127 toggle = half a blocker; #128 per-source Scan).
|
||||
- Actionable = visible (#84); disable all mutation triggers while mutating; ref-based
|
||||
double-submit guards; mutations never refetch the world (#125/#127).
|
||||
- A screen listing N entities lets the user pick among them (#125).
|
||||
- Honest tests: no scenarios the backend can't produce (no exception middleware → failures
|
||||
are BARE 4xx/5xx, not ProblemDetails, unless the controller explicitly returns them);
|
||||
mount call-counts assert the DELTA across navigation (Dashboard prefetches
|
||||
/api/playouts AND /api/media-sources); paging labels don't lie; test the search/filter UI
|
||||
you ship; status-dot state never color-only (StatusDot has a label prop).
|
||||
- The image-build job runs ONLY on main pushes. DTO records in Core/Api need `#nullable
|
||||
enable`; Application has NO nullable context. NSubstitute+ConfigElementKey:
|
||||
`Arg.Any<ConfigElementKey>()` + `<T>`. `Option<T>.ToNullable()` → `MatchUnsafe`.
|
||||
update-openapi.sh needs a prior normal build. Child GETs 404 unknown parents via pre-check.
|
||||
- Backlog nits (unfiled): unclamped pageSize; PlayoutController Create/Delete lack Name=;
|
||||
heavy GetItems pre-check; >30 MB uploads → bare 413; artwork content-type trusted (#66);
|
||||
schedule estimator materializes collections per GET; /api/health TTL cache; NEW (#128):
|
||||
`LibraryScanStatusResponseModel.percent` is a 0–1 fraction under a percent name — rename
|
||||
or ×100 server-side eventually; no Dialog/Modal component yet (likely needed by #89);
|
||||
1 pre-existing --text-faint usage in shell.css. Filed: #126 (OpenAPI polymorphism gap).
|
||||
|
||||
---
|
||||
|
||||
# PROMPT FOR CODEX — #109: Dashboard follow-up — replace stubbed data with real sources
|
||||
# PROMPT FOR CODEX — #85: Guide / EPG screen (channels × time grid)
|
||||
|
||||
You are Codex working solo in /Users/timothy/ersatztv (ErsatzTV fork; the React SPA
|
||||
"ChicoryTV" lives in web/ — Vite + TS, typed client in web/src/api/, generated types
|
||||
web/src/api/generated/v1.d.ts). Read CLAUDE.md and docs/contributing.md first; follow the
|
||||
SPA foundation's existing patterns (#78–#83) — match, don't invent.
|
||||
You are Codex, the IMPLEMENTER, in /Users/timothy/ersatztv (ErsatzTV fork; React SPA
|
||||
"ChicoryTV" in web/ — Vite + TS, typed client web/src/api/, generated types
|
||||
web/src/api/generated/v1.d.ts). Fable (Claude) reviews your PR read-only afterwards — do NOT
|
||||
merge. Read CLAUDE.md and docs/contributing.md first; follow the SPA patterns from Channels
|
||||
(PR #124), Schedule editor (PR #125), Playouts (PR #127), Libraries (PR #128) — match, don't
|
||||
invent.
|
||||
|
||||
HARD CONSTRAINTS:
|
||||
- Work in a NEW git worktree: `git worktree add .worktrees/issue-109-dashboard -b
|
||||
feat/109-dashboard-data origin/main` (branch from origin/main; never touch the main
|
||||
checkout or other .worktrees/*).
|
||||
- Max 2–3 concurrent builds machine-wide; keep to ONE dotnet build at a time here.
|
||||
- USE SUBAGENTS where appropriate, at fitting effort and model levels: cheap/fast agents for
|
||||
mechanical work (fixtures, CSS transcription, test boilerplate); higher-effort agents only
|
||||
for judgment work (grid virtualization/layout math, time-window state). Keep orchestration
|
||||
and final assembly in your main session.
|
||||
- Worktree: `git worktree add .worktrees/issue-85-epg -b feat/85-epg origin/main`.
|
||||
Never touch the main checkout or other .worktrees/*.
|
||||
- Run `npm ci` in web/ in the worktree BEFORE any lint/typecheck/test/build.
|
||||
- Max 2–3 concurrent builds machine-wide; ONE dotnet build at a time here.
|
||||
- NEVER set ETV_UPDATE_GOLDENS. A golden-file diff means your code is wrong.
|
||||
- Do NOT merge anything. Open the PR, get CI green, and stop.
|
||||
- Backend scope guard: this is a FRONTEND issue. If a widget needs data no endpoint
|
||||
provides, stub that widget's source cleanly and note it in the PR — do not add endpoints.
|
||||
- FRONTEND-only: no new endpoints, no ErsatzTV.Core/Application changes. Missing data =
|
||||
VISIBLE deferral (disabled + note) — never silently drop a prototype affordance.
|
||||
- CONTRACT RULE: fixture values must be what the actually-called endpoint returns UNDER THE
|
||||
QUERY THE CLIENT SENDS; verify units/scale of numeric fields against the producing backend
|
||||
code, never the field name (#128's percent lesson). Enum-with-None fields are never
|
||||
truthiness-checked (#127's fillerKind lesson — this endpoint HAS fillerKind!).
|
||||
|
||||
## Context
|
||||
- main = post-#120/#122/#121: full backend read API + SPA foundation + direct-stream
|
||||
session tracking seam. Baselines: ErsatzTV.Tests 368, Core.Tests 493(+1 skip); web
|
||||
`npm run typecheck` + `npm run build` clean.
|
||||
Gitea: http://192.168.1.95:3000/timothy/ersatztv (basic auth timothy:ded89Lm4).
|
||||
- Issue #109: the Dashboard screen (#83) shipped with stubbed data. Replace the stubs with
|
||||
the real sources now on main: /api/health (system health), /api/channels +
|
||||
/api/channels/state (channel counts / on-air), /api/playouts (playout status),
|
||||
/api/version. Read the issue body for the agreed widget list, and locate the stub layer
|
||||
from #83 in web/src/ before writing anything.
|
||||
- CRITICAL (#108 lesson): GET /api/health re-runs all ~14 checks per request (only the
|
||||
warn/error summary is cached) — fetch health ON DEMAND (mount + manual refresh), NOT on a
|
||||
fast poll. If live health is wanted, that's a backend TTL-cache follow-up issue, not this
|
||||
PR. Other widgets may poll gently (≥30s) if the design calls for it.
|
||||
- Note: /api/channels/state `onAir` is still segmenter-session-based until the final #99
|
||||
wiring combines it with `IDirectStreamSessionTracker` — render it as-is; no workarounds.
|
||||
## Task
|
||||
Issue #85: recreate design-system/templates/chicorytv-admin/Epg.jsx wired to the JSON guide.
|
||||
Dark channels×time grid, "now" marker, live focal color, jump-to-now. Acceptance: matches
|
||||
prototype; live guide data; all 3 themes.
|
||||
|
||||
## Context (main = d63719ec; baselines: ErsatzTV.Tests 368, Core.Tests 493+1skip, web 75)
|
||||
- Gitea: http://192.168.1.95:3000/timothy/ersatztv (basic auth timothy:ded89Lm4).
|
||||
- API surface (verify in v1.d.ts; regen `npm run generate:api` if stale):
|
||||
- GET /api/guide?start=<ISO>&end=<ISO> (both optional DateTimeOffset; start defaults to
|
||||
now, end to start + configured XmltvDaysToBuild) → ChannelGuideResponseModel { start,
|
||||
end, channels: [{ number, name, programmes: [{ start, stop, title, subTitle (nullable),
|
||||
category (nullable), fillerKind }] }] }.
|
||||
- CRITICAL COST NOTE (#102): the guide endpoint runs a heavy 21-include eager-load PER
|
||||
CHANNEL PER REQUEST. Request a BOUNDED window (e.g. now-1h → now+12h), fetch ONCE on
|
||||
mount + on explicit user navigation (prev/next window, jump-to-now beyond the loaded
|
||||
range) — NEVER poll /api/guide. The "now" marker advances client-side on a timer
|
||||
(minute-ish tick moving the marker/highlight; NO refetch).
|
||||
- fillerKind in the JSON guide is almost always 'None' — the shared projector merges
|
||||
filler into adjacent programmes. Do NOT truthiness-check it; discrete filler rows would
|
||||
need a backend projection mode (out of scope — no UI toggle for it).
|
||||
- GET /api/channels/state (onAir) may drive the live focal treatment; if used, poll ≥30s
|
||||
(Channels precedent) — it's the only thing allowed on an interval besides the client-side
|
||||
now-marker tick.
|
||||
- Channel logos: check what the prototype uses; the SPA has ChannelLogo (initials-based).
|
||||
/iptv logo URLs exist but check what Channels/Dashboard screens do — match precedent.
|
||||
- Grid math: programmes can start before / end after the requested window — clip to the
|
||||
window; overlapping/adjacent programmes must not produce negative/NaN widths; empty
|
||||
channels (no programmes) render an empty row, not a crash; timezone = render in the
|
||||
BROWSER's local time (the wire carries offsets; Date parsing is TZ-safe).
|
||||
- No mutations on this screen. Read-only + navigation state only.
|
||||
- CSS: design-system tokens only; verify each token EXISTS; fallback = resolved value; new
|
||||
multi-column grids → the 980px collapse block; the grid scrolls in its OWN container
|
||||
(both axes as needed) — never page-level horizontal scroll.
|
||||
- Tests: mount fetch delta (guide fetched exactly once; no guide polling — assert interval
|
||||
ticks don't refetch /api/guide); window navigation triggers exactly one fetch per step;
|
||||
jump-to-now; now-marker position math (fixed fake now vs fixture times); programme
|
||||
clipping at window edges; empty channels; error+retry; fixture fillerKind 'None' (real
|
||||
wire value) with subTitle/category nulls exercised.
|
||||
|
||||
## Process
|
||||
1. Comment on issue #109 with findings + approach before coding.
|
||||
2. Implement: swap stubs for typed client calls (follow #81's client/query patterns);
|
||||
loading/error/empty states per the design-system components. No `any` casts around
|
||||
generated types.
|
||||
3. Verify: `cd web && npm ci && npm run typecheck && npm run build`; plus
|
||||
`dotnet build ErsatzTV.sln` and TZ=UTC dotnet test of ErsatzTV.Tests + Core.Tests
|
||||
(sequentially) to prove no backend regression (expect 364 / 488+1skip). If feasible, run
|
||||
the SPA against a live backend (dotnet run + vite dev proxy) and sanity-check the
|
||||
Dashboard renders real data.
|
||||
4. Push, open PR → main: `feat(web): Dashboard real data sources (#109)`, body lists each
|
||||
widget → endpoint mapping and the health fetch policy; `closes #109`. Poll CI by head SHA
|
||||
(/api/v1/repos/timothy/ersatztv/commits/<sha>/status) until green. Do not merge.
|
||||
5. Comment progress on #109 as you go.
|
||||
1. Comment on issue #85 with findings + approach (prototype surface → endpoint/DTO mapping
|
||||
table, scope decisions, chosen window size) BEFORE coding.
|
||||
2. Implement; comment progress on #85 as you go.
|
||||
3. Verify: cd web && npm ci && npm run lint && npm run typecheck && npm test -- --run &&
|
||||
npm run build; then TZ=UTC dotnet build ErsatzTV.sln; TZ=UTC dotnet test ErsatzTV.Tests +
|
||||
Core.Tests sequentially (expect 368 / 493+1skip — no backend regression).
|
||||
4. Push, open PR → main: "feat(web): Guide / EPG screen (#85)", body lists the mapping table
|
||||
and scope decisions; `closes #85`. Poll CI by head SHA until green. Do NOT merge.
|
||||
|
||||
## On completion — REQUIRED final output
|
||||
Print a fenced handoff prompt addressed to Claude (Fable) asking it to:
|
||||
- Review the PR diff READ-ONLY in one Fable pass (endpoint usage correctness, health
|
||||
polling discipline, type safety, design-system adherence, scope).
|
||||
- Classify findings: NITS Fable may fix directly on the branch; SUBSTANTIAL issues (wrong
|
||||
data contracts, polling violations, state-management flaws) go back to Codex — Fable
|
||||
composes a follow-up Codex prompt with the findings verbatim.
|
||||
- After review: comment the verdict on the PR and #109; on approval + user "merge" consent,
|
||||
merge, verify main's post-merge run (image job included!), then update THIS handoff file
|
||||
(pop #109, next prompt = #84 Channels screen for Codex, record PR number + main SHA +
|
||||
baselines) and push it to main.
|
||||
Include: PR number, branch, head SHA, files changed, widget→endpoint mapping, test/web
|
||||
results, anything deferred or uncertain.
|
||||
Print a fenced handoff prompt addressed to Claude (Fable) asking it to review the PR
|
||||
READ-ONLY (Fable runs its own review subagents). Include: PR number, branch, head SHA, base,
|
||||
files changed, the mapping table, verification commands + results, deferred/uncertain list,
|
||||
and the finding classification (NITS = Fable fixes on the branch; SUBSTANTIAL = back to
|
||||
Codex verbatim or fixed in-session with subagents — user decides). After approval + user
|
||||
merge consent, Fable merges, verifies main's post-merge run (image job included), updates
|
||||
THIS handoff (pop #85, next prompt = #89 Channel Builder, record PR + main SHA + baselines),
|
||||
and pushes it to main.
|
||||
|
||||
---
|
||||
|
||||
## Issue queue (work top-down)
|
||||
0. HOUSEKEEPING (carry into next session): remove .worktrees/issue-99-session-tracking when
|
||||
no longer needed; #99 remains open for the final `/api/channels/state` onAir wiring.
|
||||
1. #109 Dashboard follow-up ← CODEX PROMPT above (review/merge/doc-update falls to the
|
||||
Fable session that Codex's end-of-run handoff spawns).
|
||||
2. #84 Channels screen (deps #96/#98/#97 on main; live treatment via /api/channels/state —
|
||||
note its onAir is segmenter-only until #99/#121 lands and gets wired).
|
||||
3. UX conversion order: #86 Schedule editor (#111 durations) → #87 Playouts → #88 Libraries
|
||||
→ #85 EPG (#102 JSON guide; mind fillerKind + per-request-cost notes above) → #89 Channel
|
||||
Builder (#104 artwork upload; also needs #62: #63/#64/#65) → #93 Settings → #90 rebrand
|
||||
→ #91 cutover.
|
||||
Cross-refs: #99 direct-session tracking seam landed via PR #121; final channel-state wiring
|
||||
still open on #99. Done this pass: MERGE PASS → PR #120 (closed #97; #78–#83/#96/#98
|
||||
validated on main), hotfix PR #122 (Dockerfile node tag), PR #121 (direct-stream session
|
||||
tracking). Languages-list endpoint from #105 still unimplemented — open a follow-up when
|
||||
#86/#89 need it;
|
||||
filler/watermark lists return DB order — SPA should client-sort.
|
||||
0. HOUSEKEEPING: #99 stays open for the final /api/channels/state onAir wiring; #126
|
||||
(OpenAPI polymorphism gap) is a good backend slot-filler between screens.
|
||||
1. #85 Guide/EPG screen ← CODEX PROMPT above (Fable reviews via subagents, merges on
|
||||
consent, updates this file).
|
||||
2. UX conversion order: #89 Channel Builder (#104 artwork upload; also needs #62:
|
||||
#63/#64/#65; likely needs the first Dialog/Modal component; languages endpoint follow-up
|
||||
from #105 when needed) → #93 Settings → #90 rebrand → #91 cutover.
|
||||
Cross-refs: #99 seam landed (PR #121), final wiring open. Done recently: PR #125 (#86),
|
||||
PR #127 (#87), PR #128 (#88 Libraries — closed 2026-07-05, main d63719ec). Filed: #126.
|
||||
|
||||
+2068
-7
File diff suppressed because it is too large
Load Diff
+2042
-3
File diff suppressed because it is too large
Load Diff
+169
-1
@@ -1,8 +1,176 @@
|
||||
import { request } from './client';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ApiError, request } from './client';
|
||||
import type { components } from './generated/v1';
|
||||
|
||||
export type ChannelSummary = components['schemas']['ChannelResponseModel'];
|
||||
export type ChannelState = components['schemas']['ChannelStateResponseModel'];
|
||||
export type BulkRenumberChannelsRequest = components['schemas']['BulkRenumberChannelsRequest'];
|
||||
export type BulkMoveChannelsToGroupRequest = components['schemas']['BulkMoveChannelsToGroupRequest'];
|
||||
export type BulkDeleteChannelsRequest = components['schemas']['BulkDeleteChannelsRequest'];
|
||||
|
||||
export interface ChannelsScreenData {
|
||||
channels: ChannelSummary[];
|
||||
channelStates: ChannelState[];
|
||||
}
|
||||
|
||||
export type ChannelsScreenQueryState =
|
||||
| { data: ChannelsScreenData; error: null; refresh: () => void; status: 'success' }
|
||||
| { data: null; error: string; refresh: () => void; status: 'error' }
|
||||
| { data: null; error: null; refresh: () => void; status: 'loading' };
|
||||
|
||||
type ChannelsScreenState =
|
||||
| { data: ChannelsScreenData; error: null; status: 'success' }
|
||||
| { data: null; error: string; status: 'error' }
|
||||
| { data: null; error: null; status: 'loading' };
|
||||
|
||||
export function getChannels(): Promise<ChannelSummary[]> {
|
||||
return request<ChannelSummary[]>('/api/channels');
|
||||
}
|
||||
|
||||
export function getChannelStates(): Promise<ChannelState[]> {
|
||||
return request<ChannelState[]>('/api/channels/state');
|
||||
}
|
||||
|
||||
export async function getChannelsScreenData(): Promise<ChannelsScreenData> {
|
||||
const [channels, channelStates] = await Promise.all([
|
||||
getChannels(),
|
||||
getChannelStates()
|
||||
]);
|
||||
|
||||
return { channels, channelStates };
|
||||
}
|
||||
|
||||
export function bulkRenumberChannels(body: BulkRenumberChannelsRequest): Promise<void> {
|
||||
return request<void>('/api/channels/bulk/renumber', {
|
||||
body,
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
|
||||
export function bulkMoveChannelsToGroup(body: BulkMoveChannelsToGroupRequest): Promise<void> {
|
||||
return request<void>('/api/channels/bulk/group', {
|
||||
body,
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
|
||||
export function bulkDeleteChannels(body: BulkDeleteChannelsRequest): Promise<void> {
|
||||
return request<void>('/api/channels/bulk/delete', {
|
||||
body,
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteChannel(channelId: number): Promise<void> {
|
||||
return request<void>(`/api/channels/${channelId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
export function useChannelsScreenQuery(pollMs = 30000): ChannelsScreenQueryState {
|
||||
const [state, setState] = useState<ChannelsScreenState>({
|
||||
data: null,
|
||||
error: null,
|
||||
status: 'loading'
|
||||
});
|
||||
const activeRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = true;
|
||||
|
||||
return () => {
|
||||
activeRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const load = useCallback((showLoading = true) => {
|
||||
if (showLoading) {
|
||||
setState({ data: null, error: null, status: 'loading' });
|
||||
}
|
||||
|
||||
getChannelsScreenData()
|
||||
.then((data) => {
|
||||
if (activeRef.current) {
|
||||
setState({ data, error: null, status: 'success' });
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (activeRef.current) {
|
||||
setState({ data: null, error: messageFromError(error), status: 'error' });
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const loadChannelStates = useCallback(() => {
|
||||
getChannelStates()
|
||||
.then((channelStates) => {
|
||||
if (activeRef.current) {
|
||||
setState((current) => {
|
||||
if (current.status !== 'success') {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
data: { ...current.data, channelStates },
|
||||
error: null,
|
||||
status: 'success'
|
||||
};
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep the current table visible on background polling failures.
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const loadForEffect = () => {
|
||||
getChannelsScreenData()
|
||||
.then((data) => {
|
||||
if (activeRef.current) {
|
||||
setState({ data, error: null, status: 'success' });
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (activeRef.current) {
|
||||
setState({ data: null, error: messageFromError(error), status: 'error' });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
loadForEffect();
|
||||
const intervalId = window.setInterval(() => {
|
||||
loadChannelStates();
|
||||
}, pollMs);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [loadChannelStates, pollMs]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
if (state.status === 'success') {
|
||||
return { data: state.data, error: null, refresh, status: 'success' };
|
||||
}
|
||||
|
||||
if (state.status === 'error') {
|
||||
return { data: null, error: state.error, refresh, status: 'error' };
|
||||
}
|
||||
|
||||
return { data: null, error: null, refresh, status: 'loading' };
|
||||
}
|
||||
|
||||
export function messageFromError(error: unknown): string {
|
||||
if (error instanceof ApiError) {
|
||||
return error.detail ?? error.message;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return 'Unable to load channels';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { getChannelStates, messageFromError, type ChannelState } from './channels';
|
||||
import { request } from './client';
|
||||
import type { components } from './generated/v1';
|
||||
|
||||
export type ChannelGuide = components['schemas']['ChannelGuideResponseModel'];
|
||||
export type ChannelGuideChannel = components['schemas']['ChannelGuideChannelResponseModel'];
|
||||
export type ChannelGuideProgramme = components['schemas']['ChannelGuideProgrammeResponseModel'];
|
||||
|
||||
export interface GuideScreenData {
|
||||
channelStates: ChannelState[];
|
||||
guide: ChannelGuide;
|
||||
}
|
||||
|
||||
export type GuideScreenQueryState =
|
||||
| {
|
||||
data: GuideScreenData;
|
||||
error: null;
|
||||
refresh: () => void;
|
||||
setWindowStart: (start: Date) => void;
|
||||
status: 'success';
|
||||
windowStart: Date;
|
||||
windowEnd: Date;
|
||||
}
|
||||
| { data: null; error: string; refresh: () => void; status: 'error'; windowStart: Date; windowEnd: Date }
|
||||
| { data: null; error: null; refresh: () => void; status: 'loading'; windowStart: Date; windowEnd: Date };
|
||||
|
||||
type GuideScreenState =
|
||||
| { data: GuideScreenData; error: null; status: 'success' }
|
||||
| { data: null; error: string; status: 'error' }
|
||||
| { data: null; error: null; status: 'loading' };
|
||||
|
||||
export const GUIDE_PAST_MS = 60 * 60 * 1000;
|
||||
export const GUIDE_WINDOW_MS = 13 * 60 * 60 * 1000;
|
||||
|
||||
export function defaultGuideWindowStart(now = new Date()): Date {
|
||||
return new Date(now.getTime() - GUIDE_PAST_MS);
|
||||
}
|
||||
|
||||
export function getGuide(start: Date, end: Date): Promise<ChannelGuide> {
|
||||
const query = new URLSearchParams({
|
||||
start: start.toISOString(),
|
||||
end: end.toISOString()
|
||||
});
|
||||
|
||||
return request<ChannelGuide>(`/api/guide?${query.toString()}`);
|
||||
}
|
||||
|
||||
export async function getGuideScreenData(start: Date, end: Date): Promise<GuideScreenData> {
|
||||
const guide = await getGuide(start, end);
|
||||
const channelStates = await getChannelStates().catch(() => []);
|
||||
|
||||
return { channelStates, guide };
|
||||
}
|
||||
|
||||
export function useGuideScreenQuery(channelStatePollMs = 30000): GuideScreenQueryState {
|
||||
const [windowStart, setWindowStartState] = useState(() => defaultGuideWindowStart());
|
||||
const windowStartRef = useRef(windowStart);
|
||||
const [state, setState] = useState<GuideScreenState>({
|
||||
data: null,
|
||||
error: null,
|
||||
status: 'loading'
|
||||
});
|
||||
const activeRef = useRef(true);
|
||||
const requestIdRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = true;
|
||||
|
||||
return () => {
|
||||
activeRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const windowEnd = new Date(windowStart.getTime() + GUIDE_WINDOW_MS);
|
||||
|
||||
const load = useCallback((start = windowStartRef.current) => {
|
||||
const end = new Date(start.getTime() + GUIDE_WINDOW_MS);
|
||||
const requestId = requestIdRef.current + 1;
|
||||
requestIdRef.current = requestId;
|
||||
|
||||
getGuideScreenData(start, end)
|
||||
.then((data) => {
|
||||
if (
|
||||
activeRef.current &&
|
||||
requestIdRef.current === requestId &&
|
||||
windowStartRef.current.getTime() === start.getTime()
|
||||
) {
|
||||
setState({ data, error: null, status: 'success' });
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (
|
||||
activeRef.current &&
|
||||
requestIdRef.current === requestId &&
|
||||
windowStartRef.current.getTime() === start.getTime()
|
||||
) {
|
||||
setState({ data: null, error: messageFromError(error), status: 'error' });
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const loadChannelStates = useCallback(() => {
|
||||
getChannelStates()
|
||||
.then((channelStates) => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState((current) => {
|
||||
if (current.status !== 'success') {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
data: { ...current.data, channelStates },
|
||||
error: null,
|
||||
status: 'success'
|
||||
};
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep the guide visible when the optional live-state refresh fails.
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load(windowStart);
|
||||
}, [load, windowStart]);
|
||||
|
||||
useEffect(() => {
|
||||
const intervalId = window.setInterval(loadChannelStates, Math.max(channelStatePollMs, 30000));
|
||||
|
||||
return () => {
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [channelStatePollMs, loadChannelStates]);
|
||||
|
||||
const setWindowStart = useCallback((start: Date) => {
|
||||
windowStartRef.current = start;
|
||||
setState({ data: null, error: null, status: 'loading' });
|
||||
setWindowStartState(start);
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setState({ data: null, error: null, status: 'loading' });
|
||||
load(windowStartRef.current);
|
||||
}, [load]);
|
||||
|
||||
if (state.status === 'success') {
|
||||
return { data: state.data, error: null, refresh, setWindowStart, status: 'success', windowEnd, windowStart };
|
||||
}
|
||||
|
||||
if (state.status === 'error') {
|
||||
return { data: null, error: state.error, refresh, status: 'error', windowEnd, windowStart };
|
||||
}
|
||||
|
||||
return { data: null, error: null, refresh, status: 'loading', windowEnd, windowStart };
|
||||
}
|
||||
@@ -2,4 +2,8 @@ export * from './auth';
|
||||
export * from './channels';
|
||||
export * from './client';
|
||||
export * from './dashboard';
|
||||
export * from './guide';
|
||||
export * from './libraries';
|
||||
export * from './playouts';
|
||||
export * from './schedules';
|
||||
export * from './useChannelsQuery';
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ApiError, request } from './client';
|
||||
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 interface LibrariesScreenData {
|
||||
scanStatuses: LibraryScanStatus[];
|
||||
sources: MediaSource[];
|
||||
}
|
||||
|
||||
export type LibrariesScreenQueryState =
|
||||
| {
|
||||
data: LibrariesScreenData;
|
||||
error: string | null;
|
||||
refresh: () => void;
|
||||
scanLibrary: (libraryId: number) => Promise<void>;
|
||||
scanningLibraryIds: Set<number>;
|
||||
status: 'success';
|
||||
}
|
||||
| { data: null; error: string; refresh: () => void; status: 'error' }
|
||||
| { data: null; error: null; refresh: () => void; status: 'loading' };
|
||||
|
||||
type LibrariesScreenState =
|
||||
| { data: LibrariesScreenData; error: string | null; pendingLibraryIds: Set<number>; status: 'success' }
|
||||
| { data: null; error: string; status: 'error' }
|
||||
| { data: null; error: null; status: 'loading' };
|
||||
|
||||
// A trigger'd scan disappears from the button-disabled set once either: it has been
|
||||
// observed at least once in scan-status (promoted to "active"), or this many poll
|
||||
// ticks pass without ever appearing (the scanner never picked it up / it failed silently).
|
||||
const PENDING_GRACE_TICKS = 3;
|
||||
|
||||
export function getMediaSources(): Promise<MediaSource[]> {
|
||||
return request<MediaSource[]>('/api/media-sources');
|
||||
}
|
||||
|
||||
export function getLibraryScanStatus(): Promise<LibraryScanStatus[]> {
|
||||
// Wire contract: `percent` is a 0-1 FRACTION despite the field name - the backend
|
||||
// never multiplies by 100 (known backend wart, tracked in the handoff backlog).
|
||||
// Normalize to a 0-100 percentage once, here at the API boundary, so every
|
||||
// consumer (ProgressBar, percent labels) works in ordinary percentage terms.
|
||||
return request<LibraryScanStatus[]>('/api/libraries/scan-status').then((scanStatuses) =>
|
||||
scanStatuses.map((scanStatus) => ({ ...scanStatus, percent: scanStatus.percent * 100 }))
|
||||
);
|
||||
}
|
||||
|
||||
export function scanLibrary(libraryId: number): Promise<void> {
|
||||
return request<void>(`/api/libraries/${libraryId}/scan`, { method: 'POST' });
|
||||
}
|
||||
|
||||
// Pure: computes the surviving pending-id set for one poll tick (success or failure) and
|
||||
// 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 before that write to keep it a single, ref-free
|
||||
// computation that's safe to run under StrictMode double-invocation.
|
||||
function pruneGraceExpiredPending(
|
||||
pendingIds: Set<number>,
|
||||
graceTicks: Map<number, number>,
|
||||
isSeenActive: (libraryId: number) => boolean
|
||||
): Set<number> {
|
||||
const nextPending = new Set<number>();
|
||||
|
||||
pendingIds.forEach((libraryId) => {
|
||||
if (isSeenActive(libraryId)) {
|
||||
// Seen active at least once - normal active/inactive pruning takes over.
|
||||
graceTicks.delete(libraryId);
|
||||
return;
|
||||
}
|
||||
|
||||
const ticksRemaining = (graceTicks.get(libraryId) ?? PENDING_GRACE_TICKS) - 1;
|
||||
|
||||
if (ticksRemaining <= 0) {
|
||||
// Grace window expired without ever appearing in scan-status - give up on it.
|
||||
graceTicks.delete(libraryId);
|
||||
return;
|
||||
}
|
||||
|
||||
graceTicks.set(libraryId, ticksRemaining);
|
||||
nextPending.add(libraryId);
|
||||
});
|
||||
|
||||
return nextPending;
|
||||
}
|
||||
|
||||
export function useLibrariesScreenQuery(pollMs = 10000): LibrariesScreenQueryState {
|
||||
const [state, setState] = useState<LibrariesScreenState>({
|
||||
data: null,
|
||||
error: null,
|
||||
status: 'loading'
|
||||
});
|
||||
const activeRef = useRef(true);
|
||||
const hadScanInProgressRef = useRef(false);
|
||||
// Mirrors of the corresponding state, kept in sync synchronously so triggerScan can
|
||||
// guard against double-submits without waiting for a render.
|
||||
const pendingIdsRef = useRef<Set<number>>(new Set());
|
||||
const activeIdsRef = useRef<Set<number>>(new Set());
|
||||
const pendingGraceTicksRef = useRef<Map<number, number>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = true;
|
||||
|
||||
return () => {
|
||||
activeRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadSources = useCallback(() => {
|
||||
getMediaSources()
|
||||
.then((sources) => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState((current) => {
|
||||
if (current.status !== 'success') {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
data: { scanStatuses: current.data.scanStatuses, sources },
|
||||
error: null,
|
||||
pendingLibraryIds: current.pendingLibraryIds,
|
||||
status: 'success'
|
||||
};
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// A completion refresh should not hide the screen if the source reload fails.
|
||||
});
|
||||
}, []);
|
||||
|
||||
const loadScanStatuses = useCallback(() => {
|
||||
return getLibraryScanStatus()
|
||||
.then((scanStatuses) => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeScanIds = new Set(scanStatuses.map((scan) => scan.libraryId));
|
||||
activeIdsRef.current = activeScanIds;
|
||||
|
||||
const hadScanInProgress = hadScanInProgressRef.current;
|
||||
hadScanInProgressRef.current = scanStatuses.length > 0;
|
||||
|
||||
// Compute the pruned/promoted pending set - and write the grace-tick and pending
|
||||
// refs - OUTSIDE the setState updater. Updaters must be pure: StrictMode double-
|
||||
// invokes them (which would double-decrement grace ticks) and concurrent rendering
|
||||
// may invoke-and-discard one. Mirrors how activeIdsRef is written above.
|
||||
const nextPending = pruneGraceExpiredPending(pendingIdsRef.current, pendingGraceTicksRef.current, (libraryId) =>
|
||||
activeScanIds.has(libraryId)
|
||||
);
|
||||
|
||||
pendingIdsRef.current = nextPending;
|
||||
|
||||
setState((current) => {
|
||||
if (current.status !== 'success') {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
data: { scanStatuses, sources: current.data.sources },
|
||||
error: null,
|
||||
pendingLibraryIds: nextPending,
|
||||
status: 'success'
|
||||
};
|
||||
});
|
||||
|
||||
if (hadScanInProgress && scanStatuses.length === 0) {
|
||||
loadSources();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A status-fetch failure never promotes a pending id to active, so it never
|
||||
// survives via "seen active" - it just burns down the same grace budget as a
|
||||
// successful poll that never saw it. This keeps a persistently-erroring endpoint
|
||||
// from leaving the scan button disabled and the poll interval running forever.
|
||||
// Active-scan state (activeIdsRef / scanStatuses) is left untouched: a transient
|
||||
// failure must not kill an in-progress scan's UI.
|
||||
const nextPending = pruneGraceExpiredPending(pendingIdsRef.current, pendingGraceTicksRef.current, () => false);
|
||||
|
||||
pendingIdsRef.current = nextPending;
|
||||
|
||||
setState((current) => {
|
||||
if (current.status !== 'success') {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
data: current.data,
|
||||
error: current.error,
|
||||
pendingLibraryIds: nextPending,
|
||||
status: 'success'
|
||||
};
|
||||
});
|
||||
});
|
||||
}, [loadSources]);
|
||||
|
||||
const load = useCallback(() => {
|
||||
Promise.all([getMediaSources(), getLibraryScanStatus()])
|
||||
.then(([sources, scanStatuses]) => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hadScanInProgressRef.current = scanStatuses.length > 0;
|
||||
pendingIdsRef.current = new Set();
|
||||
activeIdsRef.current = new Set(scanStatuses.map((scan) => scan.libraryId));
|
||||
pendingGraceTicksRef.current = new Map();
|
||||
setState({
|
||||
data: { scanStatuses, sources },
|
||||
error: null,
|
||||
pendingLibraryIds: new Set(),
|
||||
status: 'success'
|
||||
});
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (activeRef.current) {
|
||||
setState({ data: null, error: messageFromLibrariesError(error, 'Unable to load libraries'), status: 'error' });
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const hasActiveScans =
|
||||
state.status === 'success' && (state.pendingLibraryIds.size > 0 || state.data.scanStatuses.length > 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasActiveScans) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const intervalId = window.setInterval(loadScanStatuses, Math.max(pollMs, 10000));
|
||||
|
||||
return () => {
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [hasActiveScans, loadScanStatuses, pollMs]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setState({ data: null, error: null, status: 'loading' });
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const triggerScan = useCallback((libraryId: number): Promise<void> => {
|
||||
if (pendingIdsRef.current.has(libraryId) || activeIdsRef.current.has(libraryId)) {
|
||||
// Already pending or active - ignore the duplicate submission.
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
pendingIdsRef.current = new Set(pendingIdsRef.current).add(libraryId);
|
||||
pendingGraceTicksRef.current.set(libraryId, PENDING_GRACE_TICKS);
|
||||
|
||||
setState((current) => {
|
||||
if (current.status !== 'success') {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
data: current.data,
|
||||
error: null,
|
||||
pendingLibraryIds: new Set(current.pendingLibraryIds).add(libraryId),
|
||||
status: 'success'
|
||||
};
|
||||
});
|
||||
|
||||
return scanLibrary(libraryId)
|
||||
.then(() => loadScanStatuses())
|
||||
.catch((error: unknown) => {
|
||||
const pendingIds = new Set(pendingIdsRef.current);
|
||||
pendingIds.delete(libraryId);
|
||||
pendingIdsRef.current = pendingIds;
|
||||
pendingGraceTicksRef.current.delete(libraryId);
|
||||
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState((current) => {
|
||||
if (current.status !== 'success') {
|
||||
return current;
|
||||
}
|
||||
|
||||
const pendingLibraryIds = new Set(current.pendingLibraryIds);
|
||||
pendingLibraryIds.delete(libraryId);
|
||||
|
||||
return {
|
||||
data: current.data,
|
||||
error: messageFromLibrariesError(error, 'Unable to scan library'),
|
||||
pendingLibraryIds,
|
||||
status: 'success'
|
||||
};
|
||||
});
|
||||
});
|
||||
}, [loadScanStatuses]);
|
||||
|
||||
if (state.status === 'success') {
|
||||
const activeLibraryIds = new Set(state.data.scanStatuses.map((scan) => scan.libraryId));
|
||||
const scanningLibraryIds = new Set([...state.pendingLibraryIds, ...activeLibraryIds]);
|
||||
|
||||
return {
|
||||
data: state.data,
|
||||
error: state.error,
|
||||
refresh,
|
||||
scanLibrary: triggerScan,
|
||||
scanningLibraryIds,
|
||||
status: 'success'
|
||||
};
|
||||
}
|
||||
|
||||
if (state.status === 'error') {
|
||||
return { data: null, error: state.error, refresh, status: 'error' };
|
||||
}
|
||||
|
||||
return { data: null, error: null, refresh, status: 'loading' };
|
||||
}
|
||||
|
||||
function messageFromLibrariesError(error: unknown, fallback = 'Unable to load libraries'): string {
|
||||
if (error instanceof ApiError) {
|
||||
return error.detail ?? error.message;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ApiError, request } from './client';
|
||||
import type { components } from './generated/v1';
|
||||
|
||||
export type PlayoutSummary = components['schemas']['PlayoutListItemResponseModel'];
|
||||
export type PlayoutDetail = components['schemas']['PlayoutResponseModel'];
|
||||
export type PlayoutItem = components['schemas']['PlayoutItemResponseModel'];
|
||||
export type PlayoutsPage = components['schemas']['PagedPlayoutsResponseModel'];
|
||||
export type PlayoutItemsPage = components['schemas']['PagedPlayoutItemsResponseModel'];
|
||||
export type PlayoutChannelState = components['schemas']['ChannelStateResponseModel'];
|
||||
|
||||
export interface PlayoutsScreenData {
|
||||
channelStates: PlayoutChannelState[];
|
||||
items: PlayoutItem[];
|
||||
playout: PlayoutDetail | null;
|
||||
playouts: PlayoutSummary[];
|
||||
selectedPlayoutId: number | null;
|
||||
totalCount: number;
|
||||
warningsCount: number;
|
||||
}
|
||||
|
||||
type PlayoutsScreenBase = Omit<PlayoutsScreenData, 'items' | 'playout'>;
|
||||
|
||||
export type PlayoutsScreenQueryState =
|
||||
| {
|
||||
data: PlayoutsScreenData;
|
||||
error: null;
|
||||
itemsLoading: boolean;
|
||||
refresh: () => void;
|
||||
setActivePlayout: (playoutId: number) => void;
|
||||
setShowFiller: (showFiller: boolean) => void;
|
||||
showFiller: boolean;
|
||||
status: 'success';
|
||||
}
|
||||
| { data: null; error: string; refresh: () => void; status: 'error' }
|
||||
| { data: null; error: null; refresh: () => void; status: 'loading' };
|
||||
|
||||
type PlayoutsScreenState =
|
||||
| { data: PlayoutsScreenData; error: null; itemsLoading: boolean; status: 'success' }
|
||||
| { data: null; error: string; status: 'error' }
|
||||
| { data: null; error: null; status: 'loading' };
|
||||
|
||||
export function getPlayouts(): Promise<PlayoutsPage> {
|
||||
return request<PlayoutsPage>('/api/playouts');
|
||||
}
|
||||
|
||||
export function getPlayout(playoutId: number): Promise<PlayoutDetail> {
|
||||
return request<PlayoutDetail>(`/api/playouts/${playoutId}`);
|
||||
}
|
||||
|
||||
export function getPlayoutItems(playoutId: number, showFiller = false): Promise<PlayoutItemsPage> {
|
||||
return request<PlayoutItemsPage>(`/api/playouts/${playoutId}/items${showFiller ? '?showFiller=true' : ''}`);
|
||||
}
|
||||
|
||||
export function getPlayoutWarningsCount(): Promise<number> {
|
||||
return request<number>('/api/playouts/warnings/count');
|
||||
}
|
||||
|
||||
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 function usePlayoutsScreenQuery(pollMs = 30000): PlayoutsScreenQueryState {
|
||||
const [state, setState] = useState<PlayoutsScreenState>({
|
||||
data: null,
|
||||
error: null,
|
||||
status: 'loading'
|
||||
});
|
||||
const [showFiller, setShowFillerState] = useState(false);
|
||||
const activeRef = useRef(true);
|
||||
const selectedPlayoutIdRef = useRef<number | null>(null);
|
||||
const showFillerRef = useRef(false);
|
||||
// Snapshot of the last-known base fields (everything but the selected playout's
|
||||
// detail/items), kept in sync whenever load()/loadChannelStates() succeed. Reading
|
||||
// this lets setActivePlayout/setShowFiller kick off their fetch without doing the
|
||||
// fetch from inside a setState updater (impure; StrictMode double-invokes updaters).
|
||||
const baseRef = useRef<PlayoutsScreenBase>({
|
||||
channelStates: [],
|
||||
playouts: [],
|
||||
selectedPlayoutId: null,
|
||||
totalCount: 0,
|
||||
warningsCount: 0
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = true;
|
||||
|
||||
return () => {
|
||||
activeRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadSelectedPlayout = useCallback((playoutId: number, base: PlayoutsScreenBase) => {
|
||||
baseRef.current = base;
|
||||
|
||||
Promise.all([getPlayout(playoutId), getPlayoutItems(playoutId, showFillerRef.current)])
|
||||
.then(([playout, itemsPage]) => {
|
||||
if (!activeRef.current || selectedPlayoutIdRef.current !== playoutId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState((current) => {
|
||||
// Merge onto whatever channel state the background poll may have landed
|
||||
// while this request was in flight, rather than clobbering it with the
|
||||
// snapshot captured when the request started.
|
||||
const channelStates = current.status === 'success' ? current.data.channelStates : base.channelStates;
|
||||
|
||||
return {
|
||||
data: {
|
||||
...base,
|
||||
channelStates,
|
||||
items: itemsPage.page ?? [],
|
||||
playout,
|
||||
selectedPlayoutId: playoutId
|
||||
},
|
||||
error: null,
|
||||
itemsLoading: false,
|
||||
status: 'success'
|
||||
};
|
||||
});
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (activeRef.current && selectedPlayoutIdRef.current === playoutId) {
|
||||
setState({ data: null, error: messageFromPlayoutError(error), status: 'error' });
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const load = useCallback(() => {
|
||||
Promise.all([getPlayouts(), getPlayoutWarningsCount(), getPlayoutChannelStates()])
|
||||
.then(([playoutsPage, warningsCount, channelStates]) => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const playouts = playoutsPage.page ?? [];
|
||||
const selectedPlayoutId = selectedPlayoutIdRef.current && playouts.some((playout) => playout.id === selectedPlayoutIdRef.current)
|
||||
? selectedPlayoutIdRef.current
|
||||
: playouts[0]?.id ?? null;
|
||||
selectedPlayoutIdRef.current = selectedPlayoutId;
|
||||
|
||||
const base: PlayoutsScreenBase = {
|
||||
channelStates,
|
||||
playouts,
|
||||
selectedPlayoutId,
|
||||
totalCount: playoutsPage.totalCount,
|
||||
warningsCount
|
||||
};
|
||||
baseRef.current = base;
|
||||
|
||||
if (selectedPlayoutId == null) {
|
||||
setState({
|
||||
data: {
|
||||
...base,
|
||||
items: [],
|
||||
playout: null
|
||||
},
|
||||
error: null,
|
||||
itemsLoading: false,
|
||||
status: 'success'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
loadSelectedPlayout(selectedPlayoutId, base);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (activeRef.current) {
|
||||
setState({ data: null, error: messageFromPlayoutError(error), status: 'error' });
|
||||
}
|
||||
});
|
||||
}, [loadSelectedPlayout]);
|
||||
|
||||
const loadChannelStates = useCallback(() => {
|
||||
getPlayoutChannelStates()
|
||||
.then((channelStates) => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
baseRef.current = { ...baseRef.current, channelStates };
|
||||
|
||||
setState((current) => {
|
||||
if (current.status !== 'success') {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
data: { ...current.data, channelStates },
|
||||
error: null,
|
||||
itemsLoading: current.itemsLoading,
|
||||
status: 'success'
|
||||
};
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep the monitor visible on background polling failures.
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
|
||||
const intervalId = window.setInterval(loadChannelStates, pollMs);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [load, loadChannelStates, pollMs]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setState({ data: null, error: null, status: 'loading' });
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const setActivePlayout = useCallback((playoutId: number) => {
|
||||
selectedPlayoutIdRef.current = playoutId;
|
||||
|
||||
setState((current) => {
|
||||
if (current.status !== 'success') {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
...current.data,
|
||||
items: [],
|
||||
playout: null,
|
||||
selectedPlayoutId: playoutId
|
||||
},
|
||||
error: null,
|
||||
itemsLoading: true,
|
||||
status: 'success'
|
||||
};
|
||||
});
|
||||
|
||||
const base: PlayoutsScreenBase = { ...baseRef.current, selectedPlayoutId: playoutId };
|
||||
loadSelectedPlayout(playoutId, base);
|
||||
}, [loadSelectedPlayout]);
|
||||
|
||||
const setShowFiller = useCallback((next: boolean) => {
|
||||
showFillerRef.current = next;
|
||||
setShowFillerState(next);
|
||||
|
||||
const playoutId = selectedPlayoutIdRef.current;
|
||||
|
||||
if (playoutId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState((current) => {
|
||||
if (current.status !== 'success') {
|
||||
return current;
|
||||
}
|
||||
|
||||
return { ...current, itemsLoading: true };
|
||||
});
|
||||
|
||||
getPlayoutItems(playoutId, next)
|
||||
.then((itemsPage) => {
|
||||
if (!activeRef.current || selectedPlayoutIdRef.current !== playoutId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState((current) => {
|
||||
if (current.status !== 'success') {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
data: { ...current.data, items: itemsPage.page ?? [] },
|
||||
error: null,
|
||||
itemsLoading: false,
|
||||
status: 'success'
|
||||
};
|
||||
});
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!activeRef.current || selectedPlayoutIdRef.current !== playoutId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState({ data: null, error: messageFromPlayoutError(error), status: 'error' });
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (state.status === 'success') {
|
||||
return {
|
||||
data: state.data,
|
||||
error: null,
|
||||
itemsLoading: state.itemsLoading,
|
||||
refresh,
|
||||
setActivePlayout,
|
||||
setShowFiller,
|
||||
showFiller,
|
||||
status: 'success'
|
||||
};
|
||||
}
|
||||
|
||||
if (state.status === 'error') {
|
||||
return { data: null, error: state.error, refresh, status: 'error' };
|
||||
}
|
||||
|
||||
return { data: null, error: null, refresh, status: 'loading' };
|
||||
}
|
||||
|
||||
function messageFromPlayoutError(error: unknown, fallback = 'Unable to load playouts'): string {
|
||||
if (error instanceof ApiError) {
|
||||
return error.detail ?? error.message;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ApiError, request } from './client';
|
||||
import type { components } from './generated/v1';
|
||||
|
||||
export type ProgramSchedule = components['schemas']['ProgramScheduleViewModel'];
|
||||
// These fields are real on the wire (the backend serializes ProgramScheduleItemViewModel
|
||||
// subtypes with Newtonsoft using the runtime type), but the OpenAPI schema doesn't declare
|
||||
// them: ProgramScheduleItemViewModel is an abstract base with no polymorphism annotation, so
|
||||
// the generator only sees the base shape. Tracked in Gitea issue #126 — remove this widening
|
||||
// once the schema is fixed to describe the concrete subtypes.
|
||||
export type ProgramScheduleItem = components['schemas']['ProgramScheduleItemViewModel'] & {
|
||||
count?: null | string;
|
||||
discardToFillAttempts?: null | number;
|
||||
multipleCount?: null | string;
|
||||
multipleMode?: components['schemas']['MultipleMode'];
|
||||
playoutDuration?: null | string;
|
||||
tailMode?: components['schemas']['TailMode'];
|
||||
};
|
||||
export type ProgramScheduleItemsWithDuration = components['schemas']['ProgramScheduleItemsWithDurationViewModel'];
|
||||
export type ScheduleItemRequest = components['schemas']['ScheduleItemRequest'];
|
||||
export type ReplaceScheduleItemsRequest = components['schemas']['ReplaceScheduleItemsRequest'];
|
||||
export type MediaCollection = components['schemas']['MediaCollectionViewModel'];
|
||||
export type SmartCollection = components['schemas']['SmartCollectionViewModel'];
|
||||
export type FillerPreset = components['schemas']['FillerPresetResponseModel'];
|
||||
export type Watermark = components['schemas']['WatermarkResponseModel'];
|
||||
|
||||
export interface SchedulePickerData {
|
||||
collections: MediaCollection[];
|
||||
fillerPresets: FillerPreset[];
|
||||
smartCollections: SmartCollection[];
|
||||
watermarks: Watermark[];
|
||||
}
|
||||
|
||||
export interface ScheduleScreenData {
|
||||
activeSchedule: ProgramSchedule | null;
|
||||
items: ProgramScheduleItem[];
|
||||
pickers: SchedulePickerData;
|
||||
schedules: ProgramSchedule[];
|
||||
totalDurationEstimate: string | null;
|
||||
}
|
||||
|
||||
export type ScheduleScreenQueryState =
|
||||
| {
|
||||
data: ScheduleScreenData;
|
||||
error: null;
|
||||
itemsLoading: boolean;
|
||||
refresh: () => void;
|
||||
setActiveSchedule: (scheduleId: number) => void;
|
||||
setItems: (items: ProgramScheduleItem[]) => void;
|
||||
status: 'success';
|
||||
}
|
||||
| { data: null; error: string; refresh: () => void; status: 'error' }
|
||||
| { data: null; error: null; refresh: () => void; status: 'loading' };
|
||||
|
||||
type ScheduleScreenState =
|
||||
| { data: ScheduleScreenData; error: null; itemsLoading: boolean; status: 'success' }
|
||||
| { data: null; error: string; status: 'error' }
|
||||
| { data: null; error: null; status: 'loading' };
|
||||
|
||||
export function getSchedules(): Promise<ProgramSchedule[]> {
|
||||
return request<ProgramSchedule[]>('/api/schedules');
|
||||
}
|
||||
|
||||
export function getScheduleItems(scheduleId: number): Promise<ProgramScheduleItemsWithDuration> {
|
||||
return request<ProgramScheduleItemsWithDuration>(`/api/schedules/${scheduleId}/items`);
|
||||
}
|
||||
|
||||
export function getCollections(): Promise<MediaCollection[]> {
|
||||
return request<MediaCollection[]>('/api/collections');
|
||||
}
|
||||
|
||||
export function getSmartCollections(): Promise<SmartCollection[]> {
|
||||
return request<SmartCollection[]>('/api/smart-collections');
|
||||
}
|
||||
|
||||
export function getFillerPresets(): Promise<FillerPreset[]> {
|
||||
return request<FillerPreset[]>('/api/filler-presets');
|
||||
}
|
||||
|
||||
export function getWatermarks(): Promise<Watermark[]> {
|
||||
return request<Watermark[]>('/api/watermarks');
|
||||
}
|
||||
|
||||
export function addScheduleItem(scheduleId: number, body: ScheduleItemRequest): Promise<ProgramScheduleItem> {
|
||||
return request<ProgramScheduleItem>(`/api/schedules/${scheduleId}/items`, {
|
||||
body,
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
|
||||
export function replaceScheduleItems(
|
||||
scheduleId: number,
|
||||
body: ReplaceScheduleItemsRequest
|
||||
): Promise<ProgramScheduleItem[]> {
|
||||
return request<ProgramScheduleItem[]>(`/api/schedules/${scheduleId}/items`, {
|
||||
body,
|
||||
method: 'PUT'
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteScheduleItem(scheduleId: number, itemId: number): Promise<void> {
|
||||
return request<void>(`/api/schedules/${scheduleId}/items/${itemId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
export async function getScheduleScreenData(): Promise<ScheduleScreenData> {
|
||||
const schedules = await getSchedules();
|
||||
const activeSchedule = schedules[0] ?? null;
|
||||
|
||||
if (!activeSchedule) {
|
||||
return {
|
||||
activeSchedule: null,
|
||||
items: [],
|
||||
pickers: emptyPickerData(),
|
||||
schedules,
|
||||
totalDurationEstimate: null
|
||||
};
|
||||
}
|
||||
|
||||
const [itemsEnvelope, pickers] = await Promise.all([
|
||||
getScheduleItems(activeSchedule.id),
|
||||
getSchedulePickerData()
|
||||
]);
|
||||
|
||||
return {
|
||||
activeSchedule,
|
||||
items: (itemsEnvelope.items ?? []) as ProgramScheduleItem[],
|
||||
pickers,
|
||||
schedules,
|
||||
totalDurationEstimate: itemsEnvelope.totalDurationEstimate
|
||||
};
|
||||
}
|
||||
|
||||
export function useScheduleScreenQuery(): ScheduleScreenQueryState {
|
||||
const [state, setState] = useState<ScheduleScreenState>({
|
||||
data: null,
|
||||
error: null,
|
||||
status: 'loading'
|
||||
});
|
||||
const activeRef = useRef(true);
|
||||
const activeScheduleIdRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = true;
|
||||
|
||||
return () => {
|
||||
activeRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const load = useCallback(() => {
|
||||
getScheduleScreenData()
|
||||
.then((data) => {
|
||||
if (activeRef.current) {
|
||||
activeScheduleIdRef.current = data.activeSchedule?.id ?? null;
|
||||
setState({ data, error: null, itemsLoading: false, status: 'success' });
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (activeRef.current) {
|
||||
setState({ data: null, error: messageFromScheduleError(error, 'Unable to load schedules'), status: 'error' });
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setState({ data: null, error: null, status: 'loading' });
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const setActiveSchedule = useCallback((scheduleId: number) => {
|
||||
activeScheduleIdRef.current = scheduleId;
|
||||
|
||||
setState((current) => {
|
||||
if (current.status !== 'success') {
|
||||
return current;
|
||||
}
|
||||
|
||||
const nextActiveSchedule = current.data.schedules.find((schedule) => schedule.id === scheduleId)
|
||||
?? current.data.activeSchedule;
|
||||
|
||||
return {
|
||||
data: { ...current.data, activeSchedule: nextActiveSchedule },
|
||||
error: null,
|
||||
itemsLoading: true,
|
||||
status: 'success'
|
||||
};
|
||||
});
|
||||
|
||||
getScheduleItems(scheduleId)
|
||||
.then((itemsEnvelope) => {
|
||||
if (!activeRef.current || activeScheduleIdRef.current !== scheduleId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState((current) => {
|
||||
if (current.status !== 'success') {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
...current.data,
|
||||
items: (itemsEnvelope.items ?? []) as ProgramScheduleItem[],
|
||||
totalDurationEstimate: itemsEnvelope.totalDurationEstimate
|
||||
},
|
||||
error: null,
|
||||
itemsLoading: false,
|
||||
status: 'success'
|
||||
};
|
||||
});
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!activeRef.current || activeScheduleIdRef.current !== scheduleId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState({
|
||||
data: null,
|
||||
error: messageFromScheduleError(error, 'Unable to load schedule items'),
|
||||
status: 'error'
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setItems = useCallback((items: ProgramScheduleItem[]) => {
|
||||
setState((current) => {
|
||||
if (current.status !== 'success') {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
data: { ...current.data, items },
|
||||
error: null,
|
||||
itemsLoading: false,
|
||||
status: 'success'
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (state.status === 'success') {
|
||||
return {
|
||||
data: state.data,
|
||||
error: null,
|
||||
itemsLoading: state.itemsLoading,
|
||||
refresh,
|
||||
setActiveSchedule,
|
||||
setItems,
|
||||
status: 'success'
|
||||
};
|
||||
}
|
||||
|
||||
if (state.status === 'error') {
|
||||
return { data: null, error: state.error, refresh, status: 'error' };
|
||||
}
|
||||
|
||||
return { data: null, error: null, refresh, status: 'loading' };
|
||||
}
|
||||
|
||||
async function getSchedulePickerData(): Promise<SchedulePickerData> {
|
||||
const [collections, smartCollections, fillerPresets, watermarks] = await Promise.all([
|
||||
getCollections(),
|
||||
getSmartCollections(),
|
||||
getFillerPresets(),
|
||||
getWatermarks()
|
||||
]);
|
||||
|
||||
return {
|
||||
collections,
|
||||
fillerPresets: sortByName(fillerPresets),
|
||||
smartCollections,
|
||||
watermarks: sortByName(watermarks)
|
||||
};
|
||||
}
|
||||
|
||||
function emptyPickerData(): SchedulePickerData {
|
||||
return {
|
||||
collections: [],
|
||||
fillerPresets: [],
|
||||
smartCollections: [],
|
||||
watermarks: []
|
||||
};
|
||||
}
|
||||
|
||||
function sortByName<T extends { name: null | string }>(items: T[]): T[] {
|
||||
return [...items].sort((left, right) => (left.name ?? '').localeCompare(right.name ?? ''));
|
||||
}
|
||||
|
||||
function messageFromScheduleError(error: unknown, fallback = 'Unable to load schedules'): string {
|
||||
if (error instanceof ApiError) {
|
||||
return error.detail ?? error.message;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
+1499
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user