Compare commits

...
Author SHA1 Message Date
timothyandClaude Opus 4.8 f8390bb008 refactor(spa): extract ChannelsScreen from App.tsx (#244, epic #243 phase 1)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 7m57s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m3s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Move the Channels domain verbatim out of web/src/App.tsx into
web/src/screens/ChannelsScreen.tsx (zero-prop, self-sufficient, mirroring the
SchedulesScreen extraction). Pure structural move: no API, route, CSS, or
visual change. App.tsx retains only the import + the <ChannelsScreen /> dispatch.

- 14 symbols moved (ChannelViewFilter → ChannelTableRow); the Dashboard-owned
  progressFromNowPlaying is inlined into the moved progressFromChannelState so
  the screen has no import back into App.tsx (behavior-identical).
- 12 Channels behavior tests moved to a colocated ChannelsScreen.test.tsx with
  its own scoped fetch mock (renders <ChannelsScreen /> directly, no
  mockDashboardApi); App.test.tsx keeps one nav-smoke test for the route.
- Pruned 12 now-dead App.tsx imports; shared symbols (ChannelState,
  messageFromError, ApiError, useChannelsQuery) verified still used and kept.
- Docs: spa-conventions §6 (extracted-screen own-fetch-mock convention),
  decisions.md (single-file rationale; no web/src/channels/ sibling dir, unlike
  Schedules; inlined helper; #238 deferral).

Verified: web vitest 587 passed, eslint clean, tsc/vite build clean,
check:api no drift. #212 empty-lineup bare-create success+failure coverage
preserved. #238 TopBar dead-button left as-is (its owned bug; shell redesign
is epic phase 4 / #247).

refs #244 #243

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 13:27:50 +02:00
timothyandClaude Fable 5 ea0eb0ab76 docs(handoff): lessons from #242 — re-review the fix commit before closing; disabled={saving} SchedulesScreen convention
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 3m48s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 6m32s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m32s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 13:16:39 +02:00
timothy eba22ce679 Merge pull request 'fix: reviewer post-merge blockers — empty-lineup bare create (#212) + dirty-guard popstate/shuffle-toggle (#230)' (#242) from fix/212-230-review-blockers into main
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
2026-07-11 11:13:55 +00:00
timothyandClaude Opus 4.8 490b6d2ef0 docs(handoff): lore — concurrency tests need negative control + stress loop; audit workaround purpose before deleting
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
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 13:07:51 +02:00
timothyandClaude Fable 5 b26045676b fix(spa): gate Delete on saving to close the last switch-during-save clobber
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m53s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 7m27s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Codex re-review of the prior fix commit found the Edit gate closed the exact
repro but two paths remained. One is reachable: Delete was the only
schedule-switch path that guardedSwitch's `saving` guard didn't cover — deleting
mid-save runs applySwitch to the next schedule while the in-flight items PUT is
still outstanding, and that PUT's completion handler then overwrites the next
schedule's draft with the deleted schedule's response. Delete is now
`disabled={saving}`, consistent with the Select, Edit, and guardedSwitch.

Regression: the deferred-PUT test now also asserts Delete is disabled in-flight
and re-enables after the save settles.

Also widens the test mock's onRequest return type to `Response | Promise<Response>
| null` (removes the `as unknown as Response` cast — a test-only type hole the
re-review flagged).

Deferred to #248: the other residual path (properties dialog not focus-trapped, so
keyboard focus can escape to underlying Add/Save mid-save) is a pre-existing,
cross-cutting overlay.tsx a11y gap affecting all dialogs — out of scope for this
targeted blocker fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 13:06:03 +02:00
timothy c42928e891 Merge pull request 'fix(locking): EntityLocker atomicity + scan-lifecycle honesty (#231, #232)' (#241) from batch/231-232-lock-soundness into main
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
2026-07-11 11:05:16 +00:00
timothyandClaude Fable 5 6f6fcf6c63 fix(spa): review follow-ups — surface empty-state create error, gate Edit while saving
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 8m15s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m15s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Two findings from the PR #242 adversarial review (Codex + Claude fork):

1. ChannelsEmptyState swallowed mutationError: on the fresh-install path #212
   targets, a bare-create can 4xx (e.g. no default ffmpeg profile), but the empty
   branch never rendered the error alert the non-empty screen shows — the user saw
   only a spinner re-enable. The empty state now renders the same ctv-channels-error
   alert. Regression: App.test.tsx asserts the error surfaces + no navigation on an
   empty lineup.

2. SchedulesScreen Edit button was not gated on `saving` (Codex): during an items
   save PUT the draft is still dirty, so a discard-to-open → shuffle-flip could let
   the in-flight PUT resolve AFTER the shuffle reload and clobber the normalized
   draft with the pre-shuffle body. Edit is now disabled while saving (consistent
   with the schedule Select). Regression: a deferred PUT proves Edit is disabled
   in-flight and re-enables once the save settles.

Deferred as nits (both reviews rate low): vetoed navigateToPath leaves two stray
history entries (cosmetic, rare — fixing means refactoring central nav); rapid
double-Back is best-effort (inherent popstate non-cancellability).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 12:55:07 +02:00
timothyandClaude Opus 4.8 702b0121cd fix(review): bounded scan-pending net + scheduler compensating unlock (#232)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m4s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m55s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Adversarial review (fork + Codex, both flagged) of PR #241:

- SPA (both reviewers): removing PENDING_GRACE_TICKS wholesale reintroduced a
  stuck scan button. A 202'd scan that finishes between 10s polls (short/empty
  library) is never observed active, so its optimistic pending flag wedged the
  button disabled until reload. Restore a BOUNDED grace net (pruneGraceExpiredPending)
  — re-scoped honestly: it absorbs the inherent queue->observed-active lag and the
  fast-completion race, NOT the removed lying-200 compensation (the POST now returns
  409/404/422 honestly). Bounds pending to PENDING_GRACE_TICKS * pollMs (~30s).

- SPA 409 (Codex): on "already scanning" the button was cleared+reconciled, but a
  scan-status still lagging the in-progress scan re-enabled the button and let the
  user fire repeated 409s. Keep the pending flag on 409 (no toast) so the button
  stays disabled; polling promotes or expires it.

- Scheduler (Codex): the Plex-Shows tail-token batch and the local/Jellyfin/Emby
  scan enqueues had no compensating unlock — a WriteAsync failure after LockLibrary
  (cancellation on shutdown) stranded the library lock. Wrap each acquired-lock
  enqueue in try/catch → UnlockLibrary → rethrow (the Plex catch covers both writes,
  since the library message carries Unlock: false and the un-enqueued networks
  message was the sole releaser).

Tests: two new App.test.tsx cases — grace-window expiry re-enables the button, and
409 keeps it disabled through the queue->active lag.

Ref #232.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:54:48 +02:00
timothy 78e4a3c738 Merge branch 'slice/232-scan' into batch/231-232-lock-soundness
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m45s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m49s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-11 12:39:04 +02:00
timothyandClaude Fable 5 11b43d5e06 fix(spa): reload item draft when a save flips shuffleScheduleItems (#230 finding 2)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m41s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 7m52s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Editing a schedule's properties to change shuffleScheduleItems for the active
schedule left the open items draft stale: hidden Fixed/Flood start values could
be saved back and the inspector kept offering start-type controls the schedule
no longer supports. onScheduleSaved now detects a shuffle flip on the active
schedule and reloads the items via GET so the server's EnforceProperties
re-normalizes the draft. The schedule-level flags already refresh from the save
response (setBoot maps `saved` into the list).

Decision: chose "block opening the edit dialog while the item draft is dirty"
(confirm-to-discard, guardedSwitch semantics) over confirm-at-reload — the
smaller fully-consistent change. It guarantees the properties editor only ever
opens over a clean baseline draft, so the post-save reload is lossless and
avoids the awkward state where a cancelled discard leaves a now-shuffled
schedule holding Fixed values.

Regression: SchedulesScreen.test.tsx — Fixed item on a non-shuffled schedule →
edit properties to shuffle=true → a fresh items GET fires, the Fixed option is
gone, and a subsequent Save's PUT carries no Fixed startType.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 12:38:30 +02:00
timothyandClaude Fable 5 f09045e135 fix(spa): dirty guard covers browser Back/Forward (popstate) (#230 finding 1)
canLeaveCurrentScreen() was only consulted in App's navigate() (sidebar/nav
clicks); browser Back/Forward switched screens unguarded. A popstate can't be
cancelled, so App's popstate handler now, on a vetoed guard, re-pushes the
pre-pop path (tracked in currentPathRef, updated on every approved navigation)
and leaves activeRoute untouched — undoing the browser's URL change. The same
handler covers the synthetic pop navigateToPath() dispatches. Re-pushing is
safe: only one screen is mounted at a time and the guard-registering screen
(schedules) owns no internal popstate listener, so no sub-path screen's
pathname state can desync. Effect cleanup keeps StrictMode double-mount from
double-registering.

Docs: spa-conventions §8 rewritten from navigate-only to describe popstate
coverage. Regression: App.test.tsx dirties the schedules draft, simulates
popstate → confirm called; cancel keeps route + re-pushes path; accept
switches route and unmounts the draft.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 12:38:19 +02:00
timothyandClaude Fable 5 d3d50f60c4 fix(spa): bare-channel create reachable on an empty lineup (#212 reopened)
ChannelsScreen returned ChannelsEmptyState before the action bar that owns
"New blank channel", so a fresh install could never create its first channel.
The empty state now offers both create paths (bare-create + ChannelBuilder),
reusing the exact createBlankChannel handler (number = max+1 → 1 on empty,
group "ErsatzTV", default ffmpeg profile).

Regression: App.test.tsx bare-creates from a [] lineup, asserts the POST
payload (number "1") + navigation to /app/edit-channel/{id}.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 12:38:10 +02:00
timothyandClaude Opus 4.8 088644e8c0 feat(libraries): honest scan lifecycle + one-lock-one-release discipline (#232)
Scan queue handler now returns a QueueLibraryScanResult enum
(Queued|NotFound|SyncDisabled|AlreadyScanning) instead of a lying bool;
LibrariesController.ScanLibrary maps them to 202/404/422/409 with ProblemDetails.
Guard the lock->enqueue with the EnqueueWithTraktLock compensating-unlock pattern.

ScannerService now releases every library/collection lock in a finally so a handler
exception can't leak the lock. Plex "Shows" scheduler batch (one lock, two messages)
now has only the trailing SynchronizePlexNetworks carry the single release
(Unlock flag), mirroring the scheduler Trakt tail-token precedent.

Guard the other lock->enqueue producers (Create/UpdateLocalLibrary, UpdateTraktList)
with compensating unlock. SPA drops the PENDING_GRACE_TICKS heuristic now that the
POST reports 202/409/404/422 directly: 202 -> pending+poll, 409 -> reconcile (no
error toast), 404/422 -> surface error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:38:08 +02:00
timothyandClaude Opus 4.8 275908ec11 fix(locking): atomic EntityLocker flags + single-owner release contract (#231)
The six plain-bool lock flags (Plex, Trakt, Emby/Jellyfin/Plex collections,
troubleshooting playback) used a non-atomic check-then-set, so two concurrent
Lock* callers could both win. Convert them to int flags mutated only via
Interlocked.CompareExchange, so the caller that wins the 0->1 transition is the
sole owner and the only one that fires the change event. The three
ConcurrentDictionary-backed kinds (Library/Playout/RemoteMediaSource) were
already atomic; drop their redundant ContainsKey pre-checks.

Define the ownership contract (tokenless single-owner discipline, no interface
change) on IEntityLocker and in docs/decisions.md: a true from Lock* confers
ownership of exactly one release; Unlock* on an unlocked slot returns false,
fires no event, and logs a warning (the double-release / non-owner tripwire).

Adds EntityLockerTests (real locker, parallel-caller races) proving exactly one
winner per kind, one-releaser-per-slot, and event-fires-once-per-transition.

Ref #231. Scan-lifecycle call-site fixes that consume this contract land in the
same PR (#232); the BuildPlayout/subtitle finally-gating is #234.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:30:36 +02:00
timothyandClaude Fable 5 554f658989 docs(handoff): orchestrator is Opus-by-default with explicit Fable escalation triggers
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 6m22s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 7m25s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m38s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 12:11:12 +02:00
timothyandClaude Fable 5 dd9230ac7d docs(handoff): lessons from #207/#212 session (lazy-Map masking, merge-regen rule, CI timeouts)
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 12:09:19 +02:00
timothy 6bbf669403 Merge pull request 'feat: schedules editor full mutation depth + channel-editor gaps (#207, #212, #126)' (#229) from feat/207-212-schedules-channel-depth into main
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
2026-07-11 10:05:53 +00:00
timothy c4b7babbe8 Merge pull request 'test(api): locked-path 409 guard tests for ReplaceAlternateSchedules + ReplaceTemplates (#215)' (#240) from fix/215-guard-tests into main
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 6m4s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
2026-07-11 09:59:43 +00:00
timothyandClaude Fable 5 3c7ed5c2f8 test(spa): raise timeout on trash see-all paging test (CI VM flake, run 686)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 11s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 10m17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 11:51:11 +02:00
timothyandClaude Fable 5 67547d998a test(api): locked-path 409 guard tests for ReplaceAlternateSchedules + ReplaceTemplates (fixes #215)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 11s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m18s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 8m37s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Closes the reviewer-verification gap from adversarial-reviewer#18: the
locked-path 409 guard tests already existed for Delete, EraseItems,
EraseItemsAndHistory, Update, and UpdateDefaultDeco, but were missing for
PlayoutController.ReplaceAlternateSchedules and .ReplaceTemplates even
though their production guards (entityLocker.IsPlayoutLocked) were in
place. Added two tests mirroring the existing pattern exactly (409
ConflictObjectResult + ProblemDetails + DidNotReceive() on the mediator
command). ApiErrorResponseMetadataTests and OpenApiErrorResponseContractTests
already carried 409 rows for both PUT endpoints, so no changes were needed
there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 11:49:40 +02:00
timothyandClaude Fable 5 9467a373b8 docs(handoff): lore — Codex back for independent review passes when quota allows
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m19s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m34s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 11:46:10 +02:00
timothyandClaude Fable 5 b82ae0c906 merge: origin/main (review-gates batch #222/#239) into feat/207-212
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Failing after 3m12s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m46s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 11:44:58 +02:00
timothyandClaude Fable 5 5e2b428876 docs(handoff): kickoff prompt also surfaces pending adversarial-reviewer audits as pickup candidates
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 11:40:48 +02:00
timothyandClaude Fable 5 1bef9c9076 ci: small-runner lane + per-ref concurrency (queue starvation fix)
- build & docs-reminder jobs -> runs-on: small (dedicated small-jobs runner,
  server-management#574). Gitea dispatches a job as a runner task even when
  its 'if' skips it; the PR-run skip of 'Build & push image' waited up to
  31 min for an ubuntu-latest slot, stalling every PR run.
- concurrency scoped per event+ref with cancel-in-progress for PRs: runs
  parallelize across PRs, superseded syncs auto-cancel. Previously one global
  group serialized ALL runs (single-runner relic). Main/tag builds still
  serialize within their ref; don't push main + v* tag simultaneously
  (shared :buildcache / smoke container) — tag after main is green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 11:40:30 +02:00
timothy c77ed43585 Merge pull request 'Integration: review-gates batch — #220/#215/#217/#219/#218 + #213 remainder (PRs #223–#228)' (#239) from integrate/review-gates into main
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m59s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 8m54s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m38s
2026-07-11 00:03:46 +00:00
timothyandClaude Fable 5 f0ada7e993 docs: sweep stale #213 gap-issue cells after integration (all #213 items closed)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 13s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m26s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m41s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 01:53:06 +02:00
timothy 6dc9c12d8c Merge remote-tracking branch 'origin/fix/213-logs-trash' into integrate/review-gates
# Conflicts:
#	docs/blazor-route-parity.md
2026-07-11 01:49:50 +02:00
timothy cf81f28118 Merge remote-tracking branch 'origin/fix/215-entitylocker' into integrate/review-gates
# Conflicts:
#	docs/blazor-route-parity.md
#	docs/decisions.md
2026-07-11 01:49:16 +02:00
timothy aee96a1788 Merge remote-tracking branch 'origin/fix/220-episode-nav' into integrate/review-gates
# Conflicts:
#	docs/blazor-route-parity.md
2026-07-11 01:48:48 +02:00
timothy ef4b64d57c Merge remote-tracking branch 'origin/fix/218-playouts-badge' into integrate/review-gates 2026-07-11 01:48:20 +02:00
timothy c846593aa3 Merge remote-tracking branch 'origin/fix/217-219-api-fixes' into integrate/review-gates 2026-07-11 01:48:20 +02:00
timothy a039d5230c Merge remote-tracking branch 'origin/fix/213-spa-nits' into integrate/review-gates 2026-07-11 01:48:19 +02:00
timothy 245b1a5889 Merge pull request 'fix(spa): gate mutation controls on stale result sets during refetch (#221)' (#222) from fix/221-stale-refetch into main
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 7m6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 7m46s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
2026-07-10 23:47:36 +00:00
timothyandClaude Fable 5 e5189f2921 docs(handoff): RAM-based fan-out gating (user calibration) + 2026-07-11 lessons (CI port fix, dual runners, cross-session plumbing merge)
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / Docs update reminder (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 01:42:10 +02:00
timothy 7f248fd553 Merge branch 'feat/207-212-schedules-channel-depth' of ssh://192.168.1.95:22/timothy/ersatztv into feat/207-212-schedules-channel-depth
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 12s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m5s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-11 01:27:39 +02:00
timothyandClaude Fable 5 3d35aa5348 fix(spa): defensively sort schedule items by index on ingest (#229)
Bug 2 (client side): array position becomes the persisted index on the next
PUT-replace, so the schedules editor must ingest strictly by the server-provided
`index` rather than trusting response row order — otherwise a reload + re-save could
silently reshuffle the lineup. Applied at both ingest points (GET load and the
replace response). Pinned by a shuffled-response-order test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 01:26:25 +02:00
timothyandClaude Fable 5 794bb0d4bb fix(schedules): reload write-path response with read includes + order GET by index (#229)
Bug 1 (500 on watermark/graphics save): Replace/Add handlers projected the
freshly-built entity graph, whose ProgramScheduleItemWatermark / -GraphicsElement
join rows carry only foreign-key ids — the Watermark/GraphicsElement navs are null,
and Mapper.ProjectToViewModel dereferences them unguarded, throwing an NRE that the
controller surfaced as a 500 on PUT/POST. Both handlers now reload the persisted
item(s) through the read-side include chain before projecting. Extracted that chain
into ProgramScheduleItemQueryExtensions.IncludeScheduleItemDetails() so GET, Replace
and Add share one source of truth.

Masking: PersistItems returned a lazy LanguageExt Map, and the existing round-trip
test only checked .IsRight — never enumerating it, so the deferred NRE never fired.
The new ScheduleItemWriteProjectionTests force enumeration (as the controller's
.ToList()/serialization does) and seed watermark/graphics via a separate context so
the handler's fresh factory context has nothing pre-tracked.

Bug 2 (server side): GetProgramScheduleItemsHandler now .OrderBy(i => i.Index) —
it previously returned id order, which is not index order.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 01:26:25 +02:00
timothyandClaude Fable 5 dfd2134cb4 docs: queue protocol v2 — state moves to pinned Gitea tracker #237 (parallel-session safe)
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / Docs update reminder (push) Has been cancelled
Handoff file reduced to static kickoff prompt + append-only lessons lore;
queue/arc/session log live in pinned tracker ersatztv#237 with in-progress
claim labels and end-of-session triage. Decision recorded in decisions.md;
docs index updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 01:17:26 +02:00
timothy ef615f8c69 Merge main: CI MySQL host-port unpin (collision fix, #236)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 6m56s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 11s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 7m50s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-11 01:10:45 +02:00
timothyandClaude Fable 5 5918e947d9 test(schedules): pin FixedStartTimeBehavior + FixDiscardToFillAttempts in round-trip (F7)
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Failing after 2s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 12s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m1s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Seed a non-null FixedStartTimeBehavior (Flexible) on the Fixed-start item and
assert it survives envelope A; assert the Marathon-order Duration item's
DiscardToFillAttempts is zeroed by the deliberate FixDiscardToFillAttempts
server normalization (Random/Shuffle keep the value, all else -> 0).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 01:00:34 +02:00
timothyandClaude Fable 5 4bc43893c1 fix(spa): guard schedule editor against destructive cross-save + lost updates (F1/F3/F4/F5)
F1: switching schedules synchronously clears items/baseline/selection/dirty
before setActiveId (applySwitch) and gates every mutation surface on a
successful items load for the CURRENT activeId (itemsLoaded) — a failed items
GET for schedule B can no longer leave B's header over A's dirty draft and PUT
A's lineup into B.
F3: key={selectedItem._key} on ScheduleItemInspector so per-item child state
(PlaylistPicker groupId, SearchPicker query) resets on selection change.
F4: mutate() no-ops and all edit surfaces disable while saving, so edits during
an in-flight Save can't be silently discarded by the Save .then.
F5: create-schedule auto-switch routes through guardedSwitch so a dirty draft
gets the same discard confirm.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 01:00:34 +02:00
timothyandClaude Fable 5 8feb9a1367 fix(spa): reconcile playbackOrder + multipleMode on schedule collection-type change (F2)
After a collection-type change, snap playbackOrder to the new type's first
offered order when the current one is no longer valid (e.g. Collection+Marathon
-> TelevisionShow left a stale 'Marathon' while the native <select> displayed
'Chronological' and Marathon fields stayed visible), and reconcile multipleMode
into the valid set for the new (type, order) state (e.g. CollectionSize
surviving a switch into Playlist). Replaces the narrow MultiCollection/Playlist
special-cases with a general invariant. Adds repro + a from/to-pair invariant
test; updates the Playlist multipleMode test to the corrected behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 01:00:34 +02:00
timothy 0120d3ce5b merge: feat/212-spa-channel into feat/207-212-schedules-channel-depth
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 11m0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
# Conflicts:
#	docs/decisions.md
2026-07-11 00:40:50 +02:00
timothy ceb45e4926 merge: feat/207-spa-schedules into feat/207-212-schedules-channel-depth 2026-07-11 00:37:07 +02:00
timothyandClaude Fable 5 5054cd8228 style+docs(spa): schedules editor CSS + parity/decisions/conventions docs (#207)
- shell.css: picker/multiselect/fieldset/toolbar styles for the new editor
- spa-conventions.md §8: navigation-guard convention
- decisions.md: draft/explicit-Save, Copy-includes-refs, shuffled-GET lossiness
- blazor-route-parity.md: schedules cluster -> PARITY-OK (#207 DONE)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 00:35:05 +02:00
timothyandClaude Fable 5 2687d33637 feat(spa): rebuild schedules editor to full mutation parity (#207)
Extract ScheduleScreen from App.tsx into screens/SchedulesScreen.tsx +
schedules/ domain folder (itemRules, pickers, inspector, ScheduleForm).
Draft model with explicit Save (single destructive PUT), Discard, dirty
guard (navigationGuard + beforeunload), schedule CRUD, and all Blazor
item fields/gates/resets. Rewrite api/schedules.ts to the flat DTO +
CRUD + languages/filler-by-kind pickers. Live TopBar Add Schedule via a
window CustomEvent. Screen + nav-guard tests; App.test updated for the
extracted screen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 00:32:25 +02:00
timothy d0e431beff Merge main: CI MySQL host-port unpin (collision fix)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m8s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m17s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-11 00:31:12 +02:00
timothy 086319f200 Merge main: CI MySQL host-port unpin (collision fix)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 18s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m38s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m15s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-11 00:31:11 +02:00
timothy b70713fde8 Merge main: CI MySQL host-port unpin (collision fix)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 19s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Failing after 6m8s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 15m30s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-11 00:31:10 +02:00
timothy 83455fa42e Merge main: CI MySQL host-port unpin (collision fix)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 12s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m51s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 8m44s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-11 00:31:09 +02:00
timothy 9da582924a Merge main: CI MySQL host-port unpin (collision fix)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Failing after 5m46s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m17s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-11 00:31:06 +02:00
timothy 6b9d76f948 Merge main: CI MySQL host-port unpin (collision fix)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 12s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 10m2s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m8s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-11 00:31:04 +02:00
timothy 7a28e6ae9c Merge main: CI MySQL host-port unpin (collision fix)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 4s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m8s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m37s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-11 00:31:03 +02:00
timothy 846452e28c docs: close channel-editor gaps in blazor-route-parity + record #212 decisions
Channel editor cluster moves from GAPS to PARITY-OK in the verdict table now
that external logo URL, bare-create, and enumerated pickers have landed.
Records the bare-create-on-list-screen and external-logo-wins decisions in
decisions.md.
2026-07-11 00:16:15 +02:00
timothy f459e74645 feat(spa): bare-channel create from the channels list (#212)
Adds a "New blank channel" action next to "Add Channel" on ChannelsScreen
(web/src/App.tsx) that POSTs CreateChannelRequest with Blazor's add-mode
defaults (ChannelEditor.razor's else branch) via the new createChannel client,
then navigates to the channel's editor. Distinct from "Add Channel", which
remains the library-to-lineup ChannelBuilder flow and is untouched.
2026-07-11 00:16:09 +02:00
timothy 5d0ad8b0d7 feat(api): add languages, music-video-credits-templates, stream-selectors, createChannel clients (#212)
New web/src/api/languages.ts module (getLanguages) plus channels.ts additions
(getMusicVideoCreditsTemplates, getChannelStreamSelectors, createChannel) for the
channel-editor gaps in #212. Each has URL-building tests.
2026-07-11 00:15:41 +02:00
timothyandClaude Fable 5 0a8c7b691b feat(spa): logs sort + page-size persistence, trash see-all paging (#213)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m23s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 28s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m14s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Blazor parity for the remaining #213 conveniences:

- GET /api/logs gains sortField (timestamp|level) and sortDirection
  (asc|desc) query params, allow-listed and normalized (unrecognized
  values fall back to the pre-existing timestamp-desc default) rather
  than rejected with a 422. LogsScreen.tsx renders clickable, sortable
  column headers with a chevron direction indicator.
- LogsScreen.tsx now persists the chosen page size to localStorage
  (ctv-logs-page-size) and restores it on mount, following the
  existing designSystem.ts localStorage-preference pattern. This is a
  client-local UI preference, not the Blazor ConfigElement-backed
  server setting — see docs/decisions.md.
- TrashScreen.tsx adds a per-kind "See all N ..." affordance that
  pages past the 100/kind /api/search cap using the already-paginated
  GET /api/library/browse (mediaType + pageNum), appending results
  client-side. No new API surface was needed since that endpoint
  already supports the paging the trash screen needed.

docs/decisions.md, docs/blazor-route-parity.md, docs/spa-conventions.md
and docs/api-conventions.md updated in this same commit. OpenAPI spec
regenerated (v1.d.ts unchanged: query params aren't part of the
generated components/schemas surface).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 00:10:20 +02:00
timothyandClaude Fable 5 5816c31136 feat(spa): pure schedule-item rules module (#207)
Blazor-parity gating, option lists and forced-reset transforms for the
schedules editor, exhaustively unit-tested (44 cases). No React/fetch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 00:09:49 +02:00
timothyandClaude Fable 5 ef8915f1ca ci: unpin MySQL service host port 3306 (concurrent-run collision)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 6m48s
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 10m40s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
The migrations job connects via Server=mysql on the shared runner network;
the host-port publish was unused and made overlapping runs fail with
"Bind for 0.0.0.0:3306: port is already allocated" (bit PR #222 tonight,
backlogged since #216).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 00:09:26 +02:00
timothyandClaude Fable 5 caeabf2a2b fix(spa): block-history page-size persistence + History gating, blocks/templates list filter (#213)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m7s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 11s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m47s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Blazor parity conveniences: BlockPlayoutTroubleshootingScreen now persists the block-history
page-size selector to localStorage (ctv-block-history-page-size, same ctv- namespace as
ctv-theme) and restores it on mount, and gates the per-block History action on block.id >= 0
(mirrors BlockPlayoutTroubleshooting.razor, which hides it for synthesized/virtual blocks).
BlocksScreen and TemplatesScreen list screens gain a client-side name/group search filter box,
matching the filter already present on the troubleshooting blocks list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 00:04:15 +02:00
timothyandClaude Fable 5 35206b2199 test(api): drop stale WatermarkViewModel serializer-contract case (#126)
The flat ScheduleItemResponseModel removed the polymorphic ProgramScheduleItemViewModel
from the API surface, which was the only path exposing the Application VMs
(WatermarkViewModel, PlaylistViewModel, FillerPresetViewModel, collection VMs, etc.).
WatermarkViewModel is no longer in v1.json, so its serializer-contract guard case is moot.
ChannelViewModel still covers the schema transformer's VM path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 23:57:13 +02:00
timothyandClaude Fable 5 6823b42dbc fix(spa): remove hard-coded Playouts nav badge (fixes #218)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m49s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 12s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m43s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
The sidebar "Playouts 3" badge on a fresh empty DB was design-mock
scaffolding (badge: 3 hard-coded in the routes array) never wired to
live data; Blazor had no equivalent. Removed the value but kept the
nav-badge mechanism (ScreenRoute.badge, NavItem badge/badgeTone props)
in place since it's a plausible future home for a live warnings count.

The footer "1 failing" chip reported in the same issue is NOT a bug:
summarizeHealth renders live GET /api/health data, and on a fresh
local dev instance the genuinely failing check is FFmpeg Capabilities
(local Homebrew ffmpeg lacks the subtitles/zscale filters that prod's
ffmpeg image has). No code change for that half.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 23:55:07 +02:00
timothyandClaude Fable 5 223797a6cb docs+openapi: regenerate v1.json/endpoint-index/types + record flat schedule DTO decision (#126 #207 #212)
- Regenerated ErsatzTV/wwwroot/openapi/v1.json, docs/endpoint-index.md, and
  web/src/api/generated/v1.d.ts for the flat schedule-item DTO + discovery endpoints
- docs/decisions.md: flat ScheduleItemResponseModel + NamedIdResponseModel rationale
- docs/api-conventions.md: shared {id,name} embed, polymorphic-VM flattening, and
  optional enum query-filter conventions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 23:54:12 +02:00
timothyandClaude Fable 5 0156077e18 docs(e2e): add local TV library seeding recipe (#220)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Failing after 3s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 11s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Document the on-disk media + direct-SQLite LibraryPath + scan recipe for
E2E, since a local library is not API-seedable. Capture two gotchas hit
while verifying the episode-nav PR: deleting search-index/ leaves search
permanently empty (migration doesn't reindex from DB; rescan skips
unchanged files), and /api/search needs field/wildcard queries
(title:Alpha), not bare title words.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 23:52:29 +02:00
timothyandClaude Fable 5 b13fb0ffc7 test(api): lossless GET->PUT round-trip + EnforceProperties normalization (#126 #207 #212)
Fixed-point round-trip over all schedule-item subtypes (One/Flood/Multiple/
Duration) and field families (fixed-start, preferred audio/subtitle, custom
title, marathon, fillers x5, 2 watermarks, 2 graphics, rerun, search) proving
the flat ScheduleItemResponseModel reconstructs an identical PUT. Second test
documents the deliberate ShuffleScheduleItems normalization (Flood->One,
Fixed->Dynamic, Playlist->PlaybackOrder None).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 23:50:10 +02:00
timothyandClaude Fable 5 c5ebe29ce6 test(api): controller tests for flat schedule DTO + discovery endpoints (#126 #207 #212)
- ScheduleController GET/POST/PUT items now assert flat ScheduleItemResponseModel shapes
- LanguagesControllerTests (route + VM->DTO mapping)
- ChannelController tests for music-video-credits-templates + stream-selectors literal routes
- FillerPreset controller/handler tests for FillerKind field + ?fillerKind= filter

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 23:42:53 +02:00
timothyandClaude Fable 5 d7782dccac feat(api): flat schedule-item DTO + discovery endpoints (#126 #207 #212)
Task A (#126): new non-polymorphic ScheduleItemResponseModel /
ScheduleItemsResponseModel in Core/Api/Scheduling, plus shared
NamedIdResponseModel. ScheduleItemResponseMapper flattens the
One/Flood/Multiple/Duration VM hierarchy. ScheduleController GET/POST/PUT
items now return the flat DTOs.

Task B: GET /api/languages (LanguagesController + LanguageCodeResponseModel);
GET /api/channels/music-video-credits-templates and
GET /api/channels/stream-selectors; FillerKind added to
FillerPresetResponseModel with optional ?fillerKind= filter on
GET /api/filler-presets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 23:40:47 +02:00
timothyandClaude Fable 5 dbc47f4cae docs(review): state check-then-act (advisory) semantics of the 409 build-lock guard honestly (#215)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 6m56s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m56s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Adversarial review finding 1 on PR #225: decisions.md and api-conventions §3a
read as if the race were eliminated; the guard only narrows it (a queued build
can acquire the lock after the check passes). Also records why true lock
acquisition per mutation was not taken.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 23:38:47 +02:00
timothyandClaude Fable 5 5b945c308d fix(spa): episode anchor nav must handle in-app popstate + not re-scroll on refetch (#220 review)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m30s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 11s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m53s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Adversarial review of #220 found in-grid episode card clicks never
scrolled/highlighted: navigateToPath() (routing.ts) does pushState +
a synthetic popstate, not a real hash change, so the anchor effect's
hashchange-only listener never fired for same-pathname navigation.
Now listens to both hashchange and popstate.

Also: track the last anchor value actually scrolled to so a
refetch/pagination that recreates the items array (anchor unchanged)
doesn't hijack scroll position; document the known CHILD_PAGE_SIZE
deep-link limitation (parity with the Blazor fragment link); and fix
the MediaPosterCard/shell.css comments that described the highlight
ring as "temporary" when only its glow pulse fades, not the ring
itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 23:36:15 +02:00
timothyandClaude Fable 5 c038e13a99 fix(spa): review fixes for #221 refetch gating — stuck cue, inert select cards, exit-while-refreshing
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m27s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Failing after 2s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 12s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Three PR #222 adversarial-review findings fixed:

1. SearchScreen's `refreshing` derivation compared the last success `state.query`
   against the current query even when the query was cleared to empty — `load()`
   early-returns on a blank query, so `state` never updates and the "Refreshing…"
   cue got stuck forever over the empty-query card. Gate on `hasQuery`.
2. `MediaPosterCard` falls back to `onOpen` whenever `onToggleSelect` is
   undefined, so `selectMode && refreshing` (onToggleSelect withheld but onOpen
   still derived from `!canSelect`) made a mid-select click navigate away
   instead of no-op'ing. Both screens now withhold `onOpen` for the whole of
   select mode, not just the "live" part of it.
3. The Select/Done toggle was `disabled={refreshing}`, which also blocked
   *exiting* select mode — but exiting only clears selection, it isn't a
   mutation against the stale result set. Disable only when entering
   (`refreshing && !selectMode`).

Also corrected the "can never get stuck" over-claim in docs/spa-conventions.md
§3a: the param-keyed refreshing derivation is only self-correcting when every
param value actually triggers a fetch; params that suppress fetching (like an
empty search query) must be excluded from the comparison or the whole flag
gated on the same condition.

Tests added: query-cleared-to-empty shows no refreshing cue (both screens'
existing 3 race tests still green); select-mode+refreshing card click neither
selects nor navigates; select toggle disabled only while entering, not exiting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 23:35:12 +02:00
timothyandClaude Fable 5 28910ff557 fix(api): gate playout mutations on EntityLocker build lock (409) + mirror lock state in SPA (fixes #215)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m9s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 25s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m46s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Blazor disabled per-playout Reset/Erase/Delete/Edit while a BuildPlayout was
in flight (EntityLocker.IsPlayoutLocked); the REST API had no equivalent, so a
client could race an in-flight build with a destructive ExecuteDelete and leave
a half-built playout. After Blazor removal this safety invariant would vanish
entirely (adversarial-reviewer#18 removal gate).

Server:
- Add public ApiResults.ConflictProblem(title, detail) (409, mirrors NotFoundProblem).
- Inject IEntityLocker into PlayoutController; guard every id-keyed mutation
  (PUT {id}, PUT .../deco, PUT .../alternate-schedules, PUT .../templates,
  POST .../erase-items, POST .../erase-items-and-history, DELETE {id}) → 409
  when IsPlayoutLocked(id); add [ProducesResponseType(...409)] to each.
- Guard ChannelController.ResetPlayout the same way after resolving the id.
- reset-all stays 202 (ResetAllPlayoutsHandler already skips locked playouts).
- Stamp IsLocked onto PlayoutListItemResponseModel from IsPlayoutLocked.

SPA:
- Disable Reset/Erase/Erase-and-history/Delete for a locked row + show a
  "Building…" Badge; on a 409 surface the error and refresh the list.

Tests: controller-level 409 guard tests (delete/erase/PUT/deco/channel-reset)
+ IsLocked projection test; new OpenAPI contract + metadata 409 rows.
Docs: api-conventions §3a, blazor-route-parity playouts verdict, decisions.md.
Regenerated v1.json + web types.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 23:34:23 +02:00
timothyandClaude Fable 5 8ddf0ae169 fix(api): resolve per-show scan by exact show id, not substring title (fixes #219)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m11s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 12s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m49s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
POST /api/libraries/{id}/scan-show resolved the target show via
GetShowIdByTitle, an EF.Functions.Like "%title%" substring match with
no OrderBy - non-deterministic under duplicate/overlapping titles and
capable of scanning the wrong show. The Blazor UI never had this bug
(it always passed the exact show id); this endpoint shipped days ago
in PR #216 with no external consumers, so the contract break is safe.

BREAKING CHANGE: ScanShowRequest now takes `showId: int` instead of
`showTitle: string`. Replaced ITelevisionRepository.GetShowIdByTitle
with GetShowTitle(libraryId, showId), which also enforces the show
belongs to the given library. LibrariesController.ScanShow now returns
a genuine 404 ProblemDetails (via ApiResults.NotFoundProblem, the
established pre-check pattern from TemplateController.DeleteGroup)
when the show id doesn't exist in that library, then queues
QueueShowScanByLibraryId with the DB-resolved title.

SPA: libraries.ts ScanShowParams.showId replaces showTitle;
MediaDetailScreen.tsx passes show.id. Extended
ApiErrorResponseMetadataTests and OpenApiErrorResponseContractTests
with the new 404 contract for ScanShow. Regenerated v1.json / v1.d.ts
via scripts/update-openapi.sh + npm run generate:api.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 23:28:09 +02:00
timothyandClaude Fable 5 fc2c054b44 fix(api): validate + include RemoteStream in playlist add-items (fixes #217)
AddItemsToPlaylistHandler only validated existence for movies/shows/
seasons/episodes, leaving artist/music-video/other-video/song/image/
remote-stream ids unchecked (silently accepted, or in RemoteStream's
case silently dropped entirely - the apply dictionary never included
CollectionType.RemoteStream). Mirror AddItemsToCollectionHandler's
established pattern: add RemoteStream to the apply dictionary, and add
an aggregate existence check (ValidateMediaItems/GetRequestedMediaItemIds)
across all ten kinds against dbContext.MediaItems.

Add ErsatzTV.Tests/Application/MediaCollections/PlaylistHandlerTests.cs
covering: a bogus id of each of the ten kinds fails validation; a valid
RemoteStream id is actually persisted to the playlist (regression test
for the drop bug).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 23:27:57 +02:00
timothyandClaude Fable 5 81ddd4e5ca fix(spa): gate mutation controls + add-all on stale result sets during refetch (fixes #221)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m11s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m56s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Search and Media browse keep the previous successful result set rendered
during a refetch (query on Search; kind/query/page on Media browse) with no
gating, so per-card Add-to, Select/select-mode, the selection action bar, Add
all, and Save-as-smart-collection stayed live over stale, about-to-be-replaced
items. Worst path: SearchScreen.addAll only checked activeRef, so a late
GET /api/search/all-items could open a bulk-add dialog scoped to the previous
query's entire result set.

Key the success state to the request params that produced it and derive a
`refreshing` flag; while refreshing, keep cards visible but disable every
mutation surface, show a "Refreshing…" cue, and dim the grid. Card navigation
stays live. Bind addAll's completion to its query via lastQueryRef so a stale
all-items result is discarded. Same pattern applied to both screens.

Docs: spa-conventions §3a (refreshing/gating pattern) + §8 (temporal-semantics
review checklist); blazor-route-parity search/media-browse verdicts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 23:27:20 +02:00
timothyandClaude Fable 5 7129d9c5b5 fix: episode cards navigate to season detail + anchor (API SeasonId on browse items) (fixes #220)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m23s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m31s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Episode cards in the SPA search and media-browse screens were inert (mediaDetailPath had no
Episode case, and LibraryBrowseItemResponseModel carried no parent-season id to route with).

- API: add nullable SeasonId to LibraryBrowseItemResponseModel; populate it in
  LibraryBrowseItemMapper.GetEpisodes (the single shared hydration site used by both the
  library-browse search/browse handler and the season episode drill-in), leave it null for
  every other kind. Regenerated v1.json + v1.d.ts per docs/api-conventions.md §5.
- SPA: mediaDetailPath now routes Episode items with a seasonId to
  /app/media/seasons/{seasonId}#episode-{id} (matching Blazor's Search.razor:241 link), null
  otherwise. MediaPosterCard accepts an id/highlighted pair; SeasonDetailScreen's episode grid
  gives each card a stable `episode-{id}` anchor and scrolls/highlights it on mount and on
  hashchange (deep-link support).
- Tests: GetLibraryBrowseItemsHandlerTests asserts SeasonId is populated for episode drill-in
  results and null for other kinds; web tests cover mediaDetailPath's episode cases and the
  anchor/scroll/highlight behavior (jsdom scrollIntoView stub).
- Docs: blazor-route-parity.md's episode-browse row and the Search cluster verdict updated —
  the standalone SPA episode browse exists and episode cards now navigate, closing the
  adversarial-reviewer#18 finding.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 23:26:08 +02:00
timothy 8b77d5e739 docs: advance ChicoryTV queue past #208/#209 (PR #216); next = #207+#212 schedules/channel-editor depth
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 7m49s
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 9m46s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 11m19s
2026-07-10 08:21:58 +02:00
timothy c63cadb834 Merge pull request 'feat: shared Add-to layer — search + media mutation surface (#208, #209)' (#216) from feat/208-209-add-to into main
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / Docs update reminder (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
2026-07-10 06:19:04 +00:00
timothy 9e6ea3567c fix(spa): debounce commit must not wipe selection/page when query is unchanged (CI flake + real UX race) (#208 #209)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m55s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 12s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m11s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-10 08:07:24 +02:00
timothy 279052ccd4 fix(e2e): stale-asset gotcha — rm build wwwroot before copy in e2e-local.sh (+doc)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Failing after 2m45s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Failing after 2s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 11s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-10 07:56:34 +02:00
timothyandClaude Fable 5 50911eb456 fix(spa): review + E2E fixes — schedule-kind gating, portal body font, system-playlist filter, seasons browse, feedback/UX nits (#208 #209)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Failing after 3m9s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 11s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m47s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 07:51:49 +02:00
timothy a2ed834ef6 docs: parity verdicts + shared add-to decision entry + spa-conventions §5c (#208 #209)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Failing after 3m15s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 11s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m29s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-10 00:19:02 +02:00
timothy 9ab851bc6d Merge slice D: media browse/detail mutation surface (#209) 2026-07-10 00:14:37 +02:00
timothyandClaude Fable 5 acc3188a93 feat(spa): media browse/detail mutation surface — add-to, per-show scan, episode info/troubleshoot (#209)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 00:14:06 +02:00
timothy 04cb214ec1 Merge slice C: search-screen mutation surface (#208) 2026-07-10 00:11:54 +02:00
timothyandClaude Fable 5 6426c01efa feat(spa): search-screen mutation surface — add-to, multi-select, add-all, smart collection (#208)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 00:11:24 +02:00
timothy 4c6b10a751 refactor(web): switch add-items/all-items clients to generated OpenAPI types (#208) 2026-07-10 00:03:18 +02:00
timothy 584ad095d9 Merge slice B: shared Add-to dialogs + MediaPosterCard actions slot 2026-07-10 00:00:57 +02:00
timothyandClaude Fable 5 1e72faa60f feat(spa): shared Add-to dialogs + MediaPosterCard actions slot (#208 #209)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 00:00:05 +02:00
timothy 5493065867 Merge slice A: playlist add-items + search all-items endpoints 2026-07-09 23:57:19 +02:00
timothy 819e751cab feat(api): playlist add-items + search all-items endpoints (#208 #209)
- POST /api/playlists/{id:int}/items wraps the existing AddItemsToPlaylist
  command (mirrors CollectionController.AddItems); controller pre-checks
  playlist existence for a real 404, and the handler now rejects adds to
  system (generated) playlists, matching the guard already applied to
  rename/delete/replace-items so the Blazor path gets the same protection.
- GET /api/search/all-items wraps the existing QuerySearchIndexAllItems
  query, returning a new SearchResultAllItemsResponseModel (never expose
  the VM directly) so the SPA's shared "add all to collection/playlist"
  component can materialize ids before calling the add endpoints, same
  two-step flow Blazor's Search.razor already uses.
- Show-detail DTO check: ShowDetailResponseModel already exposes
  libraryId, title, and mediaSourceKind (serialized as a string enum via
  the global StringEnumConverter) - no changes needed.

Adds controller tests (route table + per-action) for both endpoints and
regenerates the OpenAPI document, endpoint index, and SPA client types.
2026-07-09 23:56:50 +02:00
timothyandClaude Fable 5 cfabd6f33f docs: advance ChicoryTV queue past #210/#211 (PR #214); next = #208+#209 shared add-to component
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m14s
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 10m9s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 7m58s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 23:40:43 +02:00
timothy 4f8079986f Merge pull request 'feat: playouts delete/reset/erase + collections custom-order/all-kind adds + parity nits (#210, #211, #213)' (#214) from feat/210-211-playouts-collections into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m10s
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 10m21s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 11m4s
2026-07-09 21:17:29 +00:00
timothyandClaude Fable 5 3528f689d6 test(web): deflake builder collections fan-out test; fix(spa): watermark mode derives from pathname state (review)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m6s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 12s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m8s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
- App.test.tsx: await the initial library fan-out being issued before
  mockClear — passive effects flush asynchronously, so on a slow machine the
  mount fan-out leaked past the clear and polluted the post-click assertion
  (CI-only failure on run 383).
- WatermarksScreen: modeFromPath parses the pathname state string (incl.
  search) instead of reading mutable window.location during render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 23:06:31 +02:00
timothyandClaude Fable 5 f3d8ac559f docs: parity verdict rows for #210/#211/#213 + decision entries (schedule-reset drop, reorder buttons)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Failing after 3m10s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 11s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m15s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 22:42:09 +02:00
timothy 8ba261ded4 Merge branch 'feat/213-nits' into feat/210-211-playouts-collections 2026-07-09 22:40:42 +02:00
timothy 3bccb568ad feat(spa): small parity nits — block copy, watermark copy, trash select-all, trakt copy fix (#213)
- BlocksScreen: add copy-to-group dialog (mirrors TemplatesScreen), wiring the
  already-existing copyBlock() API client
- WatermarksScreen: add copy via /add?from={id} prefill (mirrors
  FFmpegProfilesScreen), no new endpoint needed
- TrashScreen: add Select all / Clear selection to the action bar; bump
  PAGE_SIZE to the search API's max (100) — true paging needs a page param
  the API doesn't have yet
- TraktListsScreen: fix stale callout text — "View matched items" now opens
  the SPA's own search screen, not the Classic UI

docs/decisions.md: record the YAML-validator paste-textarea deviation, the
channel-number prompt-renumber deviation, extend the "table not calendar"
convention to the deco-templates editor, and document the trash 100-item cap.
2026-07-09 22:40:08 +02:00
timothy 606d50bb8d feat(spa): collection custom-order reorder UI + all-kind add picker (#211)
Adds PUT /api/collections/{id}/custom-order support (updateCollectionCustomOrder)
and a reorder mode in ManualItemsView: loads every page of a manual collection
(so the wholesale-replace PUT never drops items), lets the user move items with
up/down icon buttons, and saves/cancels. Reorder is offered for any manual
collection with useCustomPlaybackOrder on, not just movies-only (server/enumerator
already support any kind).

Widens the add-items picker (ADDABLE_TYPE_LIST, MEDIA_KIND_FILTERS,
toAddItemsRequest) from 4 to all 10 addable media kinds; the default "All" search
fan-out stays Movie/Show/Artist (seasons excluded per #180), with the new kinds
reachable via their specific filter, mirroring Blazor's per-kind list pages.
2026-07-09 22:35:43 +02:00
timothyandClaude Fable 5 91e23bcd57 feat(spa): wire playout delete/reset/erase + scheduling context + templates preview calendar (#210)
Adds the SPA slice for the newly-merged playout endpoints:
- api/playouts: deletePlayout, resetChannelPlayout, erasePlayoutItems,
  erasePlayoutItemsAndHistory, getPlayoutItemSchedulingContext (+ types).
- PlayoutsScreen: enable per-playout Reset (channel playout reset), Delete
  (danger, confirm), and erase-by-kind buttons (Block = items + items&history;
  Classic/Sequential/Scripted = items&history; none for ExternalJson); replaces
  the stale "no per-playout reset endpoint" block. Disable Alternate schedules
  for OnDemand Classic playouts (Blazor parity).
- Per-item scheduling-context dialog (info button when hasSchedulingContext &&
  id != null), rendering the decoded JSON in a scrollable <pre>.
- Playout-templates editor: "Preview calendar" toggle with a Monday-first month
  grid; winning template per day computed client-side via a colocated
  appliesToDate/firstMatchingIndex helper that ports
  AlternateScheduleSelector.GetScheduleForDate / PlayoutTemplateEditViewModel
  .AppliesToDate exactly (inclusive range, year-wrap reverse, start-day rollover,
  end-day clamp, explicit-year override).
- Button gains a title prop (parity with IconButton) for the disabled-button hint.

Tests: api client URL/method assertions, PlayoutsScreen action flows
(delete/reset/erase per kind, OnDemand gating, scheduling-context dialog +
error), calendar-helper unit tests, and a preview-calendar render test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 22:30:28 +02:00
timothyandClaude Fable 5 e66b926206 feat(api): expose PlayoutItem row id on the playout-items list DTO (#210)
The SPA needs the item's row id to call
GET /api/playouts/items/{id}/scheduling-context. Plumbed through
PlayoutItemViewModel -> PlayoutItemResponseModel as a nullable Id
(null for synthesized UNSCHEDULED gap rows, which are PlayoutGaps,
not PlayoutItems). Additive for existing consumers (Playouts.razor
reads the VM by property). Regenerated v1.json + v1.d.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 22:10:45 +02:00
timothyandClaude Fable 5 f1b3879d51 feat(api): playout erase/scheduling-context + collection custom-order (#210, #211)
Backend slice for the ChicoryTV playouts and collections screens.

PlayoutController:
- POST /api/playouts/{id}/erase-items (204; 404 pre-check; 422 unless
  Block/Sequential/Scripted) -> ErasePlayoutItems
- POST /api/playouts/{id}/erase-items-and-history (204; 404; 422 unless
  Classic/Block/Sequential/Scripted) -> ErasePlayoutHistory
- GET /api/playouts/items/{id}/scheduling-context (200/404) decodes a
  playout item's stored context by row id via a new
  GetPlayoutItemSchedulingContext query that reuses ProcessSchedulingContext
- PlayoutItemResponseModel gains HasSchedulingContext (no raw JSON in list)
- PlayoutListItemResponseModel gains PlayoutMode (ChannelNumber already present)

CollectionController:
- PUT /api/collections/{id}/custom-order (204; 404 pre-check; 422) with
  UpdateCollectionCustomOrderRequest deriving CustomIndex from array order
- GetCollectionItemsHandler orders by CustomIndex (nulls last) then title/id
  when the collection's UseCustomPlaybackOrder is set

Tests: controller route + behavior tests, OpenAPI ProblemDetails TestCases,
GetCollectionItems custom-order handler test. Regenerated v1.json, v1.d.ts,
endpoint-index.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 22:05:51 +02:00
timothyandClaude Fable 5 eb6e712c38 docs: #91 phase (b) cold-review gate BLOCKED — mutation-depth sweep results, gates #202/#207-#213 (#203 resolved)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m5s
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 9m51s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 7m47s
Cold adversarial review (external, zero shared context) returned GATE: BLOCKED.
blazor-route-parity.md now embeds per-cluster mutation-depth verdicts; schedules +
media-sources rows moved to Section 3 (disproven SPA-READY). Handoff queue reordered:
#210/#211 -> #208/#209 -> #207/#212 -> #202 -> removal PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 21:43:45 +02:00
timothyandClaude Fable 5 688cf10fee docs: advance ChicoryTV queue past #198/#193 (PRs #200/#201); next = #91 phase b cold-review gate
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 7m35s
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 9m58s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 11m20s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 20:22:28 +02:00
timothy 2b91837174 Merge pull request 'fix(openapi): spec property naming now mirrors the runtime Newtonsoft serializer (#198)' (#201) from fix/198-openapi-casing into main
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / Docs update reminder (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
2026-07-09 18:19:20 +00:00
timothy 8a263e7c93 Merge pull request 'fix(api): existence-check rerun-collection selectedId per selection type (#193)' (#200) from fix/193-rerun-existence into main
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / Docs update reminder (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
2026-07-09 18:19:08 +00:00
timothyandClaude Fable 5 e0d30c9b7b test(api): cover [JsonProperty] path + fail loudly on rename collision (#198, review)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m59s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m31s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Adversarial-review follow-ups:

- OpenApiSerializerContractTests: add a fifth case, a fully-populated
  ChannelResponseModel — the only DTO with a [JsonProperty("ffmpegProfile")]
  override, i.e. the attribute path of NewtonsoftSchemaNamingTransformer that
  the existing four cases never exercised.
- NewtonsoftSchemaNamingTransformer: a rename colliding with an existing schema
  key now throws InvalidOperationException (naming the type and keys) instead of
  silently overwriting/dropping a property — the generator must never emit a
  lossy spec.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 20:06:26 +02:00
timothyandClaude Fable 5 d9faa8be7b docs: spec-follows-runtime naming decision + casing wart (#198)
- api-conventions.md §5a: runtime Newtonsoft casing vs generated spec, the
  schema transformer that mirrors it, and the contract test guarding it.
- decisions.md: append the "wire format is source of truth; spec follows via the
  real contract resolver" decision.
- spa-conventions.md §4: trust the generated key casing; note the removed
  troubleshooting escape hatch and runtime-cased test mocks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 20:00:48 +02:00
timothyandClaude Fable 5 7ffde6225f fix(web): read runtime-cased ffmpegProfileId/ffmpegProfile keys (#198)
Regenerate v1.d.ts from the aligned spec (fFmpegProfileId->ffmpegProfileId,
fFmpegProfile->ffmpegProfile) and update every SPA reader/writer and test mock
to the runtime casing:

- ChannelEditScreen: read channel.ffmpegProfileId; draft/set/select use
  ffmpegProfileId.
- ChannelBuilder: template reads/writes + ADVANCED_KEYS use ffmpegProfileId.
- App.tsx channel list: read channel.ffmpegProfile (was fFmpegProfile, a latent
  bug that always rendered "Unassigned" since the runtime never sent that key).
- PlaybackTroubleshootingScreen: drop the #198 escape hatch (rawChannel cast +
  dual-read) and read data.channel.ffmpegProfileId directly.
- Test mocks now use runtime casing; pinning comments updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 20:00:38 +02:00
timothyandClaude Fable 5 f26790aa35 fix(api): mirror runtime Newtonsoft JSON casing in OpenAPI spec (#198)
The OpenAPI document is generated from System.Text.Json metadata, whose
camelCase drifted from the runtime MVC serializer (Newtonsoft via
CustomContractResolver/CustomNamingStrategy): the spec said "fFmpegProfileId"
and "fFmpegProfile" while the wire emits "ffmpegProfileId" (naming-strategy
special case) and "ffmpegProfile" (ChannelResponseModel's [JsonProperty]
override). That fed the SPA the wrong keys.

Add NewtonsoftSchemaNamingTransformer, an OpenAPI schema transformer registered
on all three documents that renames each object schema's Properties (and
Required) keys through the SAME Newtonsoft contract resolver the runtime uses,
so the spec matches the wire format by construction. Regenerate v1.json.

Guard with OpenApiSerializerContractTests: serializes fully-populated DTOs
through the runtime Newtonsoft settings and pins the v1.json schema property
sets to the emitted keys, failing if generation drifts again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 20:00:23 +02:00
timothyandClaude Fable 5 6dac2d724c fix(#193): review follow-ups — drop Playlist option, non-tracking existence check
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m43s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 12s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m52s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Adversarial review follow-ups on the #193 rerun selection validation:
- RerunCollectionEditor.razor: remove the CollectionType.Playlist
  dropdown option — RerunCollection has no PlaylistId column, so the
  option previously persisted a broken row and now fails validation.
- RerunCollectionSelectionValidation: replace FindAsync with a
  non-tracking AnyAsync(EF.Property<int>("Id") == id) existence check
  so nothing is materialized/tracked into the save-path context.
- Tests: pin the deliberate 422-not-404 choice
  (ShouldNotBeOfType<NotFoundError>) and cover the unsupported-type
  default arm (Playlist -> "Unsupported collection type"), which was
  reachable via the Blazor editor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 19:53:29 +02:00
timothyandClaude Fable 5 ad7e151565 fix(#193): validate rerun collection selection existence
RerunCollectionRequestMapping.ResolveSelection builds an id-only VM
with no existence check, so CreateRerunCollectionHandler and
UpdateRerunCollectionHandler blindly copied CollectionId /
MultiCollectionId / SmartCollectionId / MediaItemId from the request
onto the RerunCollection FK columns. A bad id persisted a dangling FK
or 500'd at SaveChanges.

Add RerunCollectionSelectionValidation, a shared per-CollectionType
existence check (Collection/MultiCollection/SmartCollection sets, and
subtype-accurate media-item sets: Shows/Seasons/Artists/Movies/
Episodes/MusicVideos/OtherVideos/Songs/Images/RemoteStreams), composed
into both handlers' Validate via the existing .Apply(...) pattern so a
missing/mismatched id now fails validation (422) with a clear message
instead of persisting or 500ing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 19:44:31 +02:00
timothyandClaude Fable 5 415da214ec docs: advance ChicoryTV queue past #145 (PR #199); next = #198 casing drift + #193; #91 phase b gated on cold review
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 7m36s
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 10m7s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 11m28s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 08:24:05 +02:00
timothy af17dafcab Merge pull request 'feat: SPA playback troubleshooting screen + status/subtitles/stream-selectors endpoints (#145)' (#199) from feat/145-troubleshooting-spa into main
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / Docs update reminder (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
2026-07-09 06:21:03 +00:00
timothyandClaude Fable 5 63983244ab fix(web): channel-mode Play sent ffmpegProfile=0 (runtime/spec key drift, #198)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m16s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m16s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Live E2E caught what unit tests could not: the runtime JSON from GET
/api/channels/{id} is Newtonsoft-cased ('ffmpegProfileId'), while the
generated OpenAPI types say 'fFmpegProfileId' (STJ naming) — so the
channel-mode default-profile read was undefined, the form held null, and
buildPlaybackUrl fell back to ffmpegProfile=0. The unit-test channel mock was
typed from the same wrong generated key, so mocks conformed to the spec, not
the server.

- Read the runtime key first via a typed escape hatch, fall back to the spec
  key, then to the first profile — never 0 (see #198 for the root spec-
  generation fix; ChannelEditScreen's same latent bug is #198's scope).
- buildPlaybackUrl now refuses to build a URL with a null profile, and Play
  is additionally gated on ffmpegProfileId != null.
- streamSelectorMode/streamSelector verified against live runtime JSON: no
  leading acronym, keys match the generated types — noted in a comment.
- Channel mock now pins the RUNTIME shape (ffmpegProfileId), and a new
  channel-mode Play test asserts ffmpegProfile=2 + channel=7 + ISO start in
  the URL (web suite 384 -> 385).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 08:04:24 +02:00
timothyandClaude Fable 5 1ae8facaab docs(spa-conventions): document HlsPlayer playToken prop
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 07:53:03 +02:00
timothyandClaude Fable 5 284b65a936 docs: #145 review nits — media-sample gating decision, entry-point note
- decisions.md: Download Media Sample is gated while a session is active in
  the SPA (Blazor only gated Download Results) — intentional deviation.
- blazor-route-parity.md: playback row notes entry points are the channels
  table + movie detail page only for now (other media kinds need a hand-built
  ?mediaItem= URL).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 07:52:35 +02:00
timothyandClaude Fable 5 58a3bb1a2b fix(web): repeat Play + startup race in playback troubleshooting
Review MUST-FIX + SHOULD-FIX (#145):

- Repeat Play with unchanged settings was fully broken: the manifest GET is
  what starts a session server-side, but an identical URL made setPlayerSrc a
  state no-op, so HlsPlayer never re-attached and no request was issued —
  while the poll saw the previous session's stored result and toasted a
  spurious success over a dead player. HlsPlayer now takes a playToken prop
  (incremented per Play) that forces teardown/re-attach even for identical src.

- First-poll race: PrepareTroubleshootingPlayback runs DB queries BEFORE
  taking the playback lock, so polls right after Play can still see the stale
  settled result. Replaced the manually-seeded 'running' assumption with a
  play-phase machine (idle -> starting -> running -> idle): settled statuses
  are ignored during 'starting' until the server has reported running at
  least once, with a 30s timeout that surfaces an error and stops polling.
  Play + downloads are gated and a Starting/Transcoding indicator shows for
  the whole busy window.

Nits: mediaItem takes precedence over channel when both query params are
present (matching Blazor); on-mount poll comment notes the intentional
post-restart log recovery. Tests: repeat-play re-issues loadSource, stale
settled ignored until running observed, starting timeout, download gating
while busy, both-params precedence (web suite 378 -> 384).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 07:52:27 +02:00
timothyandClaude Fable 5 6e2e0e1858 feat(api): optional refresh param on GET /api/graphics-elements
Review SHOULD-FIX (#145): the SPA graphics picker could go stale because
Blazor ran RefreshGraphicsElements (disk->DB sync) before listing, while the
API endpoint never refreshed — a newly added .yml would not appear. GET
/api/graphics-elements?refresh=true now sends RefreshGraphicsElements before
the list query; default false leaves existing callers untouched. The playback
troubleshooting screen passes refresh=true. Controller tests cover
refresh-iff-true ordering; regenerated OpenAPI v1.json (endpoint index and
generated TS schemas unchanged - query params are not part of either).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 07:52:14 +02:00
timothyandClaude Fable 5 add5d4c11d docs: record #145 SPA playback troubleshooting parity, decisions, HLS convention
- blazor-route-parity.md: move /system/troubleshooting/playback from Section 3
  (BLAZOR-ONLY) to Section 2 (SPA-ready); Section 3 now has no blocking rows.
- decisions.md: poll-based completion feedback (no SPA push channel) and the
  datetime-local vs Chronic deviation.
- spa-conventions.md: new HlsPlayer reusable-component + test-mocking note.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 00:03:46 +02:00
timothyandClaude Fable 5 6202886b8f feat(web): playback troubleshooting screen (#145)
Ports Blazor Pages/Troubleshooting/PlaybackTroubleshooting.razor to the SPA at
/app/troubleshooting/playback. Driven by ?mediaItem={id} (media mode) or
?channel={id} (channel mode); neither shows a friendly empty state (no redirect).

- Channel mode: FFmpeg profile (channel's preselected), stream selector, and a
  datetime-local Date/Time (deviation from Blazor's Chronic natural-language
  parse) sent as the m3u8 'start' ISO param.
- Media mode: subtitle (mutually exclusive with stream selector), watermark +
  graphics-element multi-select (repeated watermark/graphicsElement id params),
  Start From Beginning (disabled for RemoteStream) + Seek Seconds.
- Play builds playback.m3u8, plays via HlsPlayer, and polls
  /api/troubleshoot/playback/status every 2s; on running->completed/failed it
  stops polling, shows the log tail + a colored speed badge, and a completion
  notice. Polls once on mount to reflect a session running elsewhere.
- Entry points: enable the Channels table Troubleshoot action and add a
  Troubleshoot Playback button to the movie detail page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 00:03:39 +02:00
timothyandClaude Fable 5 40299f5ee6 feat(api): SPA wrappers for troubleshoot playback stream-selectors/subtitles/status
Adds thin client wrappers (+ URL-building tests) for GET
/api/troubleshoot/playback/{stream-selectors,subtitles/{id},status}, used by
the playback troubleshooting screen (#145).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 00:03:26 +02:00
timothyandClaude Fable 5 6d9619a3bc feat(web): add hls.js and reusable HlsPlayer component
Adds hls.js as a web dependency and a small HlsPlayer wrapper that attaches
hls.js when MSE is available (config mirrors the legacy _Host.cshtml
previewChannel: liveDurationInfinity + unbounded manifest time-to-first-byte,
since the troubleshooting playback.m3u8 endpoint blocks until segments exist)
and falls back to native HLS on Safari. Tears down the instance on src change
and unmount.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 00:03:21 +02:00
timothyandClaude Fable 5 8ada776324 test(troubleshoot): cover new troubleshooting playback endpoints
Add controller tests for stream-selectors, subtitles (404 + mapping), and
the status endpoint's idle/running/completed/failed transitions (using the
real status store), plus a unit test for the notification handler. Add the
subtitles 404 case to the OpenAPI ProblemDetails contract test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 23:46:41 +02:00
timothyandClaude Fable 5 5815e4b437 feat(api): add troubleshooting stream-selectors, subtitles, and status endpoints
Add three GET endpoints to TroubleshootController for the SPA port of the
playback troubleshooting page:
- /api/troubleshoot/playback/stream-selectors (List<string>)
- /api/troubleshoot/playback/subtitles/{mediaItemId} (404 pre-check via
  GetMediaItemInfo; maps SubtitleViewModel -> TroubleshootingSubtitleResponseModel)
- /api/troubleshoot/playback/status (TroubleshootingPlaybackStatusResponseModel:
  idle/running/completed/failed + exitCode/speed + logs.txt tail)

Regenerate v1.json, endpoint-index.md, and the web API types.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 23:46:35 +02:00
timothyandClaude Fable 5 cf33af4572 feat(troubleshoot): add troubleshooting playback status store
Add a singleton ITroubleshootingPlaybackStatusStore (Core, alongside
TroubleshootingNotifier) that records the exit code + speed of the most
recent troubleshooting playback session. A new MediatR notification
handler writes to it on PlaybackTroubleshootingCompletedNotification, and
PrepareTroubleshootingPlaybackHandler resets it when a new session starts
(both the channel and media-item lock paths).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 23:46:25 +02:00
timothyandClaude Opus 4.8 2998d1c2b9 docs: mark CI runner fixed (server-management#570, verified run #371) + log NU1903 triage
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m9s
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Failing after 18m35s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Cache-save hang resolved: run #371 test log shows actions/cache restore (732MB NuGet) +
save (~1s) + hit-skip working; warm Build&test ~9min. Record the Microsoft.OpenApi 2.0.0
(NU1903/GHSA-v5pm-xwqc-g5wc) assessment: DoS-only, unreachable (we generate not parse specs),
correctly non-blocking; low-priority bump to 2.7.5 available.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 23:28:36 +02:00
timothy 4c58e19b04 Merge pull request 'docs+ci: harden docs-first rule + non-blocking parity-doc reminder' (#196) from docs/guardrails-docs-first into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m53s
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 9m24s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
2026-07-08 21:06:31 +00:00
timothyandClaude Opus 4.8 6bdb7dae1c docs+ci: harden the docs-first rule + add a non-blocking parity-doc reminder
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m4s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 12s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 11m56s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
CLAUDE.md Conventions: turn "read docs before exploring" into a hard rule with red-flag
framing (do NOT reverse-engineer conventions from source; applies to subagents too) and a
"which doc to update in the same PR" table. Add a lightweight, PR-only, NON-BLOCKING
`docs-reminder` CI job that warns when a screen/route change skips
docs/blazor-route-parity.md. No dotnet/node in that job (no actions/cache), so it can't hit
the VM-127 cache-save issues (server-management#570). Documented in docs/ci-cd.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 22:17:25 +02:00
timothyandClaude Opus 4.8 3531211984 docs: advance ChicoryTV handoff past the multi/rerun/playlist SPA editors + CI/runner session
Build ErsatzTV Image / Build & test (.NET) (push) Waiting to run
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Waiting to run
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
#151/#152 (PR #194) + #153 (PR #195) + #190 (PR #192) merged & closed; next = #145
playback troubleshooting (last gate for #91 phase b). Records the VM-127 runner caveat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 22:12:31 +02:00
timothy 81bcfec8ad Merge pull request 'feat: playlist CRUD API + SPA editor (#153)' (#195) from feat/153-playlists into main
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
2026-07-08 20:09:06 +00:00
timothy d15102e14e Merge remote-tracking branch 'origin/main' into feat/153-playlists
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 18m53s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 11m21s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-08 21:35:53 +02:00
timothyandClaude Opus 4.8 a6064eccb2 fix(spa): playlist tree tolerates a single group's fetch failure (#153 review)
Use Promise.allSettled when loading each group's playlists so one group's
transient error shows that group empty rather than blanking the whole tree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 21:24:50 +02:00
timothyandClaude Opus 4.8 59edec1e85 fix(api): guard system playlists/groups + validate preview draft (#153 review)
Hardening from adversarial review of the #153 playlist API:

- PUT /api/playlists/{id}: guard IsSystem in the controller after the
  existence pre-check -> 422, so a system (generated) playlist can no
  longer be renamed/wiped. ReplacePlaylistItems is never sent for it.
- PUT /api/playlists/groups/{id}: add controller existence pre-check
  (404 for missing, mirroring DeleteGroup) plus an IsSystem 422 guard;
  RenamePlaylistGroupHandler also gains a system guard (defense-in-depth
  for the Blazor path). Missing/system are now distinct outcomes despite
  LanguageExtensions.Apply collapsing NotFoundError to a plain BaseError.
- POST /api/playlists/preview: validate each draft item at the controller
  boundary (the id required for its collection type must be present) ->
  422 before the shared PreviewPlaylistPlayoutHandler runs, preventing a
  NRE/500 in the playout builder. Logic lives in ReplacePlaylistRequest so
  it stays parallel with ReplacePlaylistItemsHandler's PUT-path check.

Tests: controller cases for system-playlist PUT, system-group PUT,
missing-group 404, and invalid-preview 422 (each asserting the handler is
not invoked); handler tests for RenamePlaylistGroup system/missing/success.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 21:22:19 +02:00
timothyandClaude Opus 4.8 79bdd86ef1 feat(spa): playlist editor screen (#153)
Add a ChicoryTV SPA Playlist editor at /app/playlists over the new
playlist-CRUD REST API: a group tree (add/rename/delete groups,
add/delete playlists) plus a playlist item editor (per-item Collection
Type over the 12 playlist item types, type-conditional playback order,
count, Play All, Show In EPG, reorder/copy/remove) and a draft playout
preview. System groups and playlists are read-only, mirroring the Blazor
Playlists.razor / PlaylistEditor.razor pages.

- web/src/api/playlists.ts: group/playlist/item CRUD + preview client fns
- web/src/screens/PlaylistsScreen.tsx: group tree + editor + preview
- App.tsx: route, ScreenId, nav, dispatch wiring
- docs: blazor-route-parity + domain-model updated to SPA DONE

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 21:09:52 +02:00
timothy 35e5799cd6 docs(ci-cd): runner relocated off bumblebee to ci-runner VM 127 (server-management#570)
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Failing after 16m15s
Build ErsatzTV Image / Build & test (.NET) (push) Failing after 37m23s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Update stale jazz/bumblebee runner references: concurrency note, amd64
host note, EF-migration mysql-service network (now per-job, not
downloadswarm), and registry insecure-registry host.
2026-07-08 19:09:49 +00:00
timothyandClaude Opus 4.8 7d5850214e feat(api): playlist CRUD + item management + preview (#153 backend)
Add write endpoints to PlaylistController for the playlist editor: group
create/rename/delete, playlist create/read/update/delete, item list read,
and draft playout preview. Introduces a RenamePlaylistGroup command/handler
(the one missing Application-layer operation) plus request/response DTOs.

Endpoints (verb / route / route-Name):
- POST   /api/playlists/groups        CreatePlaylistGroup
- PUT    /api/playlists/groups/{id}    UpdatePlaylistGroup (rename)
- DELETE /api/playlists/groups/{id}    DeletePlaylistGroup
- GET    /api/playlists/{id}           GetPlaylistById
- GET    /api/playlists/{id}/items     GetPlaylistItems
- POST   /api/playlists                CreatePlaylistInGroup
- PUT    /api/playlists/{id}           UpdatePlaylist (rename + replace items)
- DELETE /api/playlists/{id}           DeletePlaylist
- POST   /api/playlists/preview        PreviewPlaylist

404-vs-422: unknown-id on GET items / PUT / DELETE returns 404 via a
controller-side existence pre-check (mirrors TemplateController.DeleteGroup),
leaving existing shared handlers untouched; validation failures return 422.
RenamePlaylistGroup returns NotFoundError -> 404 for a missing group.

Regenerated wwwroot/openapi/v1.json, docs/endpoint-index.md, and the SPA
client types (web/src/api/generated/v1.d.ts). No SPA screen in this change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 20:52:07 +02:00
timothy 636b680a29 Merge pull request 'feat(spa): multi-collection & rerun-collection editor screens (#151, #152)' (#194) from feat/151-152-spa-editors into main
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Failing after 15m14s
Build ErsatzTV Image / Build & test (.NET) (push) Failing after 15m14s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
2026-07-08 18:22:00 +00:00
timothyandClaude Opus 4.8 3a260e67f8 fix(spa): preserve out-of-set rerun playback order on edit-load (#152 review)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m54s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m1s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 20:15:45 +02:00
timothy 72c57be6a2 Merge remote-tracking branch 'origin/main' into feat/151-152-spa-editors
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m34s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m18s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-08 20:02:41 +02:00
timothyandClaude Opus 4.8 c791b78825 feat(spa): multi-collection & rerun-collection editor screens (#151, #152)
Add two React SPA editor screens over the existing REST APIs, retiring the
last two "Classic UI only" media-collection editors.

MultiCollectionsScreen (/app/multi-collections, #151): list + in-screen
editor. Name + a list of manual/smart collection items, each with a
Schedule-As-Group toggle; two add-pickers dedupe already-present entries.
Items save with playbackOrder "Chronological" (matches the Blazor editor).

RerunCollectionsScreen (/app/rerun-collections, #152): list + in-screen
editor. Name, a Collection Type select restricted to the REST-supported
selection set (IsSupportedSelectionType) — Collection/MultiCollection/
SmartCollection plus the media-item types TelevisionShow/TelevisionSeason/
Artist/Movie/Episode/MusicVideo/OtherVideo/Song/Image/RemoteStream (every
one maps to a working LibraryBrowseMediaType picker); Playlist is excluded.
A conditional single-selection picker per type, and First/Rerun playback
order selects with the Blazor type-conditional option sets (reset on type
change).

Both screens toggle list<->editor via local state (mirroring
CollectionsScreen), no sub-path routing. Wired into App.tsx (imports,
ScreenId, routes, mediaNavIds, ScreenContent). Added messageFrom*Error
helpers + type re-export to the api clients.

Tests: multiCollections/rerunCollections api-client param+body tests,
both screen tests (list render, create body, rerun type-switch swaps
picker + order options, delete), and an App.tsx nav-registration test.
Docs: blazor-route-parity.md (rows moved to SPA DONE), domain-model.md.

web verify gate: 353 tests pass, eslint clean, tsc -b + vite build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 19:58:59 +02:00
timothy b37c2298b5 Merge pull request 'ci: speed up build pipeline (NuGet cache, shallow checkout, jar layer reorder) (#190)' (#192) from feat/190-ci-cache into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m46s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m52s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m28s
2026-07-08 17:54:25 +00:00
timothyandClaude Opus 4.8 e68aa5834c ci: speed up build pipeline (NuGet cache, shallow checkout, jar layer reorder)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m3s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m17s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Three CI wins from ersatztv#190, all config-only:

- Cache ~/.nuget/packages via actions/cache@v4 in both the `test` and
  `migrations` jobs, keyed on Directory.Packages.props + global.json, so
  dotnet restore no longer starts from scratch every run.
- Shallow-checkout the `test` job (fetch-depth: 1) since it never runs
  git describe/log; only `build` needs full history for versioning. The
  `migrations` job's checkout was already effectively shallow (default
  fetch-depth 1), documented explicitly.
- Reorder the Dockerfile so the openapi-generator jar wget happens before
  the openapi spec COPY, decoupling the ~30MB download layer from spec
  changes (e.g. v1.json) that previously busted it on every API PR.

Documented all three in docs/ci-cd.md alongside the existing job/Dockerfile
notes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 19:37:31 +02:00
timothyandClaude Opus 4.8 d20b240a1a docs: advance ChicoryTV handoff past the collections/API-gap + v26.6.0 release session
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m9s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m21s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m51s
Merged #186(#185)/#187(#184)/#189(#155)/#188(#151-152 backend); tagged v26.6.0.
Next: #151/#152 SPA editors → #153 playlists → #145. Filed #190 (CI cache).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 19:11:48 +02:00
timothy 1c035cc94f Merge pull request 'feat(api): REST CRUD for multi-collections and rerun-collections (#151, #152)' (#188) from feat/multi-rerun-collections-api into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m36s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m39s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m55s
2026-07-08 16:54:35 +00:00
timothy 282f59ea60 Merge remote-tracking branch 'origin/main' into feat/multi-rerun-collections-api
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m15s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 3m51s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
# Conflicts:
#	docs/blazor-route-parity.md
#	docs/endpoint-index.md
2026-07-08 18:43:08 +02:00
timothy ca1265214e Merge pull request 'feat(api): GET /api/collections/{id}/items (paged) — full collection contents (#155)' (#189) from feat/collections-api into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m45s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m55s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 7m18s
2026-07-08 16:38:24 +00:00
timothyandClaude Opus 4.8 dc459c6cd5 fix(api): reject unsupported CollectionType on rerun-collection create/update (#152 review)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m25s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m42s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
The rerun request mapping's catch-all routed Playlist/RerunFirstRun/RerunRerun/
SearchQuery/Fake* to MediaItemId, so POST/PUT with those persisted a wrong-kind id
and returned 201 instead of 422. Guard both verbs with IsSupportedSelectionType.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 18:30:06 +02:00
timothy 16c8789638 Merge remote-tracking branch 'origin/main' into feat/multi-rerun-collections-api
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m32s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m37s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-08 18:19:29 +02:00
timothyandClaude Opus 4.8 a496dad9c9 feat(api): REST CRUD for multi-collections and rerun-collections (#151, #152)
Adds MultiCollectionController + RerunCollectionController (list/get/create/update/
delete) over the existing MediatR handlers, with response/request DTOs, controller +
contract tests, regenerated OpenAPI + TS types, and thin SPA api-client modules. SPA
editor screens are a follow-up. Rerun request DTO resolves the chosen entity id into
the minimal VM the handler reads (documented inline).

Refs #151, #152

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 18:18:32 +02:00
224 changed files with 27405 additions and 3517 deletions
+69 -8
View File
@@ -22,11 +22,15 @@ on:
tags:
- 'v*'
# Single runner on jazz: serialize all runs so the push-main-then-tag release
# flow can't collide on the shared :buildcache tag or the smoke container.
# Concurrency is scoped per ref (originally one global group for the single
# jazz runner; with 3 runners that serialized the whole queue). PR runs
# parallelize across PRs and a new sync auto-cancels its superseded run.
# Real image builds (main / v* tags) still serialize within their own ref;
# don't push main and a v* tag simultaneously — they share :buildcache and
# the smoke container name.
concurrency:
group: ersatztv-build
cancel-in-progress: false
group: ersatztv-build-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
REGISTRY: 192.168.1.95:3000
@@ -40,13 +44,22 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
# only the test job's steps below need the working tree; git history/tags
# are only needed by the `build` job's `git describe` (ersatztv#190)
fetch-depth: 1
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('Directory.Packages.props', 'global.json') }}
restore-keys: nuget-${{ runner.os }}-
- name: Restore
run: dotnet restore
@@ -102,8 +115,9 @@ jobs:
env:
MYSQL_ROOT_PASSWORD: ersatztv
MYSQL_DATABASE: ersatztv_migrations
ports:
- 3306:3306
# No host-port binding: the job reaches this service as mysql:3306 on the shared
# runner network. Publishing 3306 made concurrent runs collide ("port is already
# allocated") whenever two migrations jobs overlapped.
options: >-
--health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -persatztv --silent"
--health-interval=5s
@@ -112,12 +126,21 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
# default fetch-depth: 1 -- this job never runs git describe/log, only
# actions/checkout@v4's default (shallow) history is needed (ersatztv#190)
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('Directory.Packages.props', 'global.json') }}
restore-keys: nuget-${{ runner.os }}-
- name: Restore
run: dotnet restore
@@ -161,7 +184,12 @@ jobs:
build:
name: Build & push image (amd64)
runs-on: ubuntu-latest
# `small` = the dedicated small-jobs runner lane (server-management#574).
# On PR runs this job only resolves its skip, but Gitea still dispatches it
# as a task — on the ubuntu-latest runners that skip queued behind long
# builds (observed 31 min). Real builds (main/tags) run on bumblebee,
# capped at 4 CPUs / 10g.
runs-on: small
needs: [test, migrations]
if: github.event_name != 'pull_request'
steps:
@@ -280,3 +308,36 @@ jobs:
echo "===== container logs (tail) ====="; docker logs "$NAME" 2>&1 | tail -n 40 || true
exit 1
fi
# Non-blocking nudge: if a PR migrates/adds a route but forgets the parity tracker, warn.
# The rule lives in CLAUDE.md → Conventions; this only surfaces an easy-to-miss omission.
# Deliberately no setup-dotnet/setup-node (and thus no actions/cache) so it can't hit the
# cache-save issues seen on the relocated runner (server-management#570).
docs-reminder:
name: Docs update reminder
runs-on: small # seconds-long git diff; keep it off the build runners
if: github.event_name == 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Warn when a screen/route change skips the parity doc
run: |
base_ref="${{ github.base_ref }}"
git fetch --no-tags --depth=100 origin "$base_ref" || true
changed="$(git diff --name-only "origin/${base_ref}...HEAD" 2>/dev/null || true)"
echo "Changed files in this PR:"; printf '%s\n' "$changed"
screen_or_route=no
if printf '%s\n' "$changed" | grep -Eq '^web/src/screens/.+\.tsx$|^ErsatzTV/LegacyUiRedirects\.cs$'; then
screen_or_route=yes
fi
parity=no
if printf '%s\n' "$changed" | grep -qx 'docs/blazor-route-parity.md'; then
parity=yes
fi
if [ "$screen_or_route" = yes ] && [ "$parity" = no ]; then
echo "::warning::This PR touches a SPA screen or LegacyUiRedirects.cs but does not update docs/blazor-route-parity.md. If you added/migrated/redirected a route, update the parity tracker (and docs/domain-model.md) in THIS PR — see CLAUDE.md → Conventions."
else
echo "Parity-doc reminder: nothing to flag."
fi
+12 -1
View File
@@ -56,7 +56,18 @@ docker build -f docker/Dockerfile -t ersatztv:dev .
## Conventions
- **Read [`docs/contributing.md`](docs/contributing.md)** before non-trivial changes — it documents the established patterns (layering, CQRS handlers, LanguageExt, Blazor/MudBlazor, EF Core + dual-provider migrations, the FFmpeg pipeline, analyzers, testing) and the **deviation policy**: match the established style; diverge only with a concrete, stated reason.
- **Convention docs replace re-recon**: before API/SPA/E2E/parity work, read `docs/README.md` (index) → `docs/api-conventions.md`, `docs/spa-conventions.md`, `docs/e2e-local.md`, `docs/domain-model.md`, `docs/blazor-route-parity.md`, `docs/decisions.md`. Any PR that changes a convention, migrates a route, or reverses a decision MUST update the relevant doc in the same PR.
- **Docs-first is a HARD RULE — read before you explore**: before ANY API / SPA / E2E / parity / scheduling work, read `docs/README.md` (index) → the convention docs (`api-conventions`, `spa-conventions`, `e2e-local`, `domain-model`, `blazor-route-parity`, `decisions`). **Do NOT reverse-engineer conventions from source (Grep/Read) before reading these** — they exist precisely so you don't. Only recon the task-specific delta the docs deliberately don't freeze (a merged endpoint's exact DTO, a Blazor page's field list). **This applies to delegated subagents too**: tell each agent which doc section to read; never let one re-derive conventions from code.
- **Docs-update is part of "done" — same PR, never a follow-up**: any PR that changes a convention, adds/migrates/redirects a route, adds/changes a `/api/*` endpoint, or reverses a decision MUST update the relevant doc in that same PR:
| Change | Update in the same PR |
|---|---|
| Migrate / add / redirect a route (new `web/src/screens/*.tsx`, `LegacyUiRedirects.cs`) | `docs/blazor-route-parity.md` + `docs/domain-model.md` |
| Add / change a `/api/*` endpoint | `docs/api-conventions.md` checklist, then regenerate `v1.json` + `endpoint-index.md` via `./scripts/update-openapi.sh` |
| Change a SPA screen convention | `docs/spa-conventions.md` |
| Establish / reverse a convention or decision | `docs/decisions.md` (append-only) + the affected doc |
| Add / remove / retitle a doc | `docs/README.md` index |
The `docs-reminder` CI job flags a screen/route change that skips `blazor-route-parity.md`, but it's a **non-blocking** nudge — the rule is on you, not the check.
- Follow existing MediatR CQRS pattern for new features
- Domain logic in `ErsatzTV.Core`, infrastructure in `ErsatzTV.Infrastructure`
- Keep UI thin: the SPA talks to `/api/*` only; legacy Blazor pages delegate to MediatR handlers. New screens go in the SPA (`web/`), never in Blazor
+1 -1
View File
@@ -6,7 +6,7 @@ namespace ErsatzTV.Application.Filler;
internal static class Mapper
{
internal static FillerPresetResponseModel ProjectToResponseModel(FillerPreset fillerPreset) =>
new(fillerPreset.Id, fillerPreset.Name);
new(fillerPreset.Id, fillerPreset.Name, fillerPreset.FillerKind);
internal static FillerPresetFullResponseModel ProjectToFullResponseModel(FillerPreset fillerPreset) =>
new(
@@ -1,5 +1,6 @@
using ErsatzTV.Core.Api.Filler;
using ErsatzTV.Core.Domain.Filler;
namespace ErsatzTV.Application.Filler;
public record GetAllFillerPresetsForApi : IRequest<List<FillerPresetResponseModel>>;
public record GetAllFillerPresetsForApi(FillerKind? FillerKind = null) : IRequest<List<FillerPresetResponseModel>>;
@@ -14,9 +14,13 @@ public class GetAllFillerPresetsForApiHandler(IDbContextFactory<TvContext> dbCon
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
List<FillerPreset> fillerPresets = await dbContext.FillerPresets
.AsNoTracking()
.ToListAsync(cancellationToken);
IQueryable<FillerPreset> query = dbContext.FillerPresets.AsNoTracking();
if (request.FillerKind is { } fillerKind)
{
query = query.Where(fp => fp.FillerKind == fillerKind);
}
List<FillerPreset> fillerPresets = await query.ToListAsync(cancellationToken);
return fillerPresets.Map(ProjectToResponseModel).ToList();
}
}
@@ -44,7 +44,17 @@ public class CreateLocalLibraryHandler : LocalLibraryHandlerBase,
if (_entityLocker.LockLibrary(localLibrary.Id))
{
await _scannerWorkerChannel.WriteAsync(new ForceScanLocalLibrary(localLibrary.Id));
try
{
await _scannerWorkerChannel.WriteAsync(new ForceScanLocalLibrary(localLibrary.Id));
}
catch
{
// the scanner only unlocks when it receives the message; if the enqueue fails
// after we acquired the lock, release it here or it is held forever.
_entityLocker.UnlockLibrary(localLibrary.Id);
throw;
}
}
return ProjectToViewModel(localLibrary);
@@ -1,3 +1,11 @@
namespace ErsatzTV.Application.Libraries;
public record QueueLibraryScanByLibraryId(int LibraryId) : IRequest<bool>;
public enum QueueLibraryScanResult
{
Queued,
NotFound,
SyncDisabled,
AlreadyScanning
}
public record QueueLibraryScanByLibraryId(int LibraryId) : IRequest<QueueLibraryScanResult>;
@@ -17,9 +17,11 @@ public class QueueLibraryScanByLibraryIdHandler(
IEntityLocker locker,
ChannelWriter<IScannerBackgroundServiceRequest> scannerWorker,
ILogger<QueueLibraryScanByLibraryIdHandler> logger)
: IRequestHandler<QueueLibraryScanByLibraryId, bool>
: IRequestHandler<QueueLibraryScanByLibraryId, QueueLibraryScanResult>
{
public async Task<bool> Handle(QueueLibraryScanByLibraryId request, CancellationToken cancellationToken)
public async Task<QueueLibraryScanResult> Handle(
QueueLibraryScanByLibraryId request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
@@ -40,10 +42,17 @@ public class QueueLibraryScanByLibraryIdHandler(
if (!shouldSyncItems)
{
logger.LogWarning("Library sync is disabled for library id {Id}", library.Id);
return false;
return QueueLibraryScanResult.SyncDisabled;
}
if (locker.LockLibrary(library.Id))
// A true from LockLibrary confers ownership of exactly one release; a false means a scan
// is already in progress and we own no release.
if (!locker.LockLibrary(library.Id))
{
return QueueLibraryScanResult.AlreadyScanning;
}
try
{
logger.LogDebug("Queued library scan for library id {Id}", library.Id);
@@ -78,10 +87,18 @@ public class QueueLibraryScanByLibraryIdHandler(
break;
}
}
catch
{
// the scanner only unlocks when it receives the message; if enqueueing fails
// (e.g. request aborted / channel completed) after we acquired the lock, release
// it here or it is held forever (EnqueueWithTraktLock pattern).
locker.UnlockLibrary(library.Id);
throw;
}
return true;
return QueueLibraryScanResult.Queued;
}
return false;
return QueueLibraryScanResult.NotFound;
}
}
@@ -96,7 +96,17 @@ public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase,
if (_entityLocker.LockLibrary(existing.Id))
{
await _scannerWorkerChannel.WriteAsync(new ForceScanLocalLibrary(existing.Id));
try
{
await _scannerWorkerChannel.WriteAsync(new ForceScanLocalLibrary(existing.Id));
}
catch
{
// the scanner only unlocks when it receives the message; if the enqueue fails
// after we acquired the lock, release it here or it is held forever.
_entityLocker.UnlockLibrary(existing.Id);
throw;
}
}
}
@@ -274,7 +274,8 @@ internal static class LibraryBrowseItemMapper
null,
em.EpisodeId,
null,
EpisodeSubtitle(em))).ToList());
EpisodeSubtitle(em),
em.Episode.SeasonId)).ToList());
}
public static async Task<List<LibraryBrowseItemResponseModel>> GetMusicVideos(
@@ -46,7 +46,8 @@ public class AddItemsToPlaylistHandler : IRequestHandler<AddItemsToPlaylist, Eit
{ CollectionType.MusicVideo, request.MusicVideoIds },
{ CollectionType.OtherVideo, request.OtherVideoIds },
{ CollectionType.Song, request.SongIds },
{ CollectionType.Image, request.ImageIds }
{ CollectionType.Image, request.ImageIds },
{ CollectionType.RemoteStream, request.RemoteStreamIds }
};
int index = playlist.Items.Count > 0 ? playlist.Items.Max(i => i.Index) + 1 : 0;
@@ -81,17 +82,24 @@ public class AddItemsToPlaylistHandler : IRequestHandler<AddItemsToPlaylist, Eit
await ValidateMovies(request),
await ValidateShows(request),
await ValidateSeasons(request),
await ValidateEpisodes(request))
.Apply((collection, _, _, _, _) => collection);
await ValidateEpisodes(request),
await ValidateMediaItems(dbContext, request, cancellationToken))
.Apply((collection, _, _, _, _, _) => collection);
private static Task<Validation<BaseError, Playlist>> PlaylistMustExist(
private static async Task<Validation<BaseError, Playlist>> PlaylistMustExist(
TvContext dbContext,
AddItemsToPlaylist request,
CancellationToken cancellationToken) =>
dbContext.Playlists
CancellationToken cancellationToken)
{
Option<Playlist> maybePlaylist = await dbContext.Playlists
.Include(c => c.Items)
.SelectOneAsync(c => c.Id, c => c.Id == request.PlaylistId, cancellationToken)
.Map(o => o.ToValidation<BaseError>("Playlist does not exist."));
.SelectOneAsync(c => c.Id, c => c.Id == request.PlaylistId, cancellationToken);
return maybePlaylist.ToValidation<BaseError>("Playlist does not exist.")
.Bind(playlist => playlist.IsSystem
? BaseError.New("Cannot add items to system (generated) playlist")
: Success<BaseError, Playlist>(playlist));
}
private Task<Validation<BaseError, Unit>> ValidateMovies(AddItemsToPlaylist request) =>
_movieRepository.AllMoviesExist(request.MovieIds)
@@ -120,4 +128,30 @@ public class AddItemsToPlaylistHandler : IRequestHandler<AddItemsToPlaylist, Eit
.Filter(v => v == true)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Episode does not exist"));
private static async Task<Validation<BaseError, Unit>> ValidateMediaItems(
TvContext dbContext,
AddItemsToPlaylist request,
CancellationToken cancellationToken)
{
List<int> ids = GetRequestedMediaItemIds(request).Distinct().ToList();
int existingCount = await dbContext.MediaItems
.CountAsync(mi => ids.Contains(mi.Id), cancellationToken);
return existingCount == ids.Count
? Unit.Default
: BaseError.New("Media item does not exist");
}
private static IEnumerable<int> GetRequestedMediaItemIds(AddItemsToPlaylist request) =>
request.MovieIds
.Append(request.ShowIds)
.Append(request.SeasonIds)
.Append(request.EpisodeIds)
.Append(request.ArtistIds)
.Append(request.MusicVideoIds)
.Append(request.OtherVideoIds)
.Append(request.SongIds)
.Append(request.ImageIds)
.Append(request.RemoteStreamIds);
}
@@ -14,7 +14,7 @@ public class CreateRerunCollectionHandler(IDbContextFactory<TvContext> dbContext
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, RerunCollection> validation = await Validate(dbContext, request);
Validation<BaseError, RerunCollection> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(c => PersistCollection(dbContext, c));
}
@@ -27,10 +27,20 @@ public class CreateRerunCollectionHandler(IDbContextFactory<TvContext> dbContext
return ProjectToViewModel(collection);
}
private static Task<Validation<BaseError, RerunCollection>> Validate(
private static async Task<Validation<BaseError, RerunCollection>> Validate(
TvContext dbContext,
CreateRerunCollection request) =>
ValidateName(dbContext, request).MapT(name => new RerunCollection
CreateRerunCollection request,
CancellationToken cancellationToken) =>
(await ValidateName(dbContext, request),
await RerunCollectionSelectionValidation.SelectedEntityMustExist(
dbContext,
request.CollectionType,
request.Collection?.Id,
request.MultiCollection?.Id,
request.SmartCollection?.Id,
request.MediaItem?.MediaItemId,
cancellationToken))
.Apply((name, _) => new RerunCollection
{
Name = name,
CollectionType = request.CollectionType,
@@ -0,0 +1,6 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.MediaCollections;
public record RenamePlaylistGroup(int PlaylistGroupId, string Name)
: IRequest<Either<BaseError, PlaylistGroupViewModel>>;
@@ -0,0 +1,60 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections;
public class RenamePlaylistGroupHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<RenamePlaylistGroup, Either<BaseError, PlaylistGroupViewModel>>
{
public async Task<Either<BaseError, PlaylistGroupViewModel>> Handle(
RenamePlaylistGroup request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, PlaylistGroup> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(playlistGroup => Persist(dbContext, request, playlistGroup));
}
private static async Task<PlaylistGroupViewModel> Persist(
TvContext dbContext,
RenamePlaylistGroup request,
PlaylistGroup playlistGroup)
{
playlistGroup.Name = request.Name;
await dbContext.SaveChangesAsync();
return Mapper.ProjectToViewModel(playlistGroup);
}
private static Task<Validation<BaseError, PlaylistGroup>> Validate(
TvContext dbContext,
RenamePlaylistGroup request,
CancellationToken cancellationToken) =>
PlaylistGroupMustExist(dbContext, request, cancellationToken)
.BindT(PlaylistGroupMustNotBeSystem)
.BindT(playlistGroup => ValidateName(request).Map(_ => playlistGroup));
private static Task<Validation<BaseError, PlaylistGroup>> PlaylistGroupMustExist(
TvContext dbContext,
RenamePlaylistGroup request,
CancellationToken cancellationToken) =>
dbContext.PlaylistGroups
.Include(pg => pg.Playlists)
.SelectOneAsync(pg => pg.Id, pg => pg.Id == request.PlaylistGroupId, cancellationToken)
.Map(o => o.ToValidation<BaseError>(
new NotFoundError($"PlaylistGroup {request.PlaylistGroupId} does not exist.")));
// Plain BaseError (NOT NotFoundError) so it maps to 422, mirroring DeletePlaylistGroupHandler's
// system-group guard. A missing group still surfaces as NotFoundError (404) from PlaylistGroupMustExist.
private static Validation<BaseError, PlaylistGroup> PlaylistGroupMustNotBeSystem(PlaylistGroup playlistGroup) =>
playlistGroup.IsSystem
? BaseError.New("Cannot rename system playlist group")
: playlistGroup;
private static Validation<BaseError, string> ValidateName(RenamePlaylistGroup request) =>
request.NotEmpty(x => x.Name)
.Bind(_ => request.NotLongerThan(50)(x => x.Name));
}
@@ -0,0 +1,112 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections;
/// <summary>
/// Shared existence check for the id a rerun collection selects via <see cref="CollectionType" />
/// (see <c>RerunCollectionRequestMapping.ResolveSelection</c> in the API layer). Used by both
/// <see cref="CreateRerunCollectionHandler" /> and <see cref="UpdateRerunCollectionHandler" /> so
/// a bad/stale id fails validation (422) instead of persisting a dangling FK or 500ing at
/// SaveChanges.
/// </summary>
internal static class RerunCollectionSelectionValidation
{
public static Task<Validation<BaseError, Unit>> SelectedEntityMustExist(
TvContext dbContext,
CollectionType collectionType,
int? collectionId,
int? multiCollectionId,
int? smartCollectionId,
int? mediaItemId,
CancellationToken cancellationToken) =>
collectionType switch
{
CollectionType.Collection => Exists(
dbContext.Collections,
collectionId,
"Collection does not exist.",
cancellationToken),
CollectionType.MultiCollection => Exists(
dbContext.MultiCollections,
multiCollectionId,
"Multi collection does not exist.",
cancellationToken),
CollectionType.SmartCollection => Exists(
dbContext.SmartCollections,
smartCollectionId,
"Smart collection does not exist.",
cancellationToken),
CollectionType.TelevisionShow => Exists(
dbContext.Shows,
mediaItemId,
"Show does not exist.",
cancellationToken),
CollectionType.TelevisionSeason => Exists(
dbContext.Seasons,
mediaItemId,
"Season does not exist.",
cancellationToken),
CollectionType.Artist => Exists(
dbContext.Artists,
mediaItemId,
"Artist does not exist.",
cancellationToken),
CollectionType.Movie => Exists(
dbContext.Movies,
mediaItemId,
"Movie does not exist.",
cancellationToken),
CollectionType.Episode => Exists(
dbContext.Episodes,
mediaItemId,
"Episode does not exist.",
cancellationToken),
CollectionType.MusicVideo => Exists(
dbContext.MusicVideos,
mediaItemId,
"Music video does not exist.",
cancellationToken),
CollectionType.OtherVideo => Exists(
dbContext.OtherVideos,
mediaItemId,
"Other video does not exist.",
cancellationToken),
CollectionType.Song => Exists(
dbContext.Songs,
mediaItemId,
"Song does not exist.",
cancellationToken),
CollectionType.Image => Exists(
dbContext.Images,
mediaItemId,
"Image does not exist.",
cancellationToken),
CollectionType.RemoteStream => Exists(
dbContext.RemoteStreams,
mediaItemId,
"Remote stream does not exist.",
cancellationToken),
_ => Task.FromResult(
Fail<BaseError, Unit>($"Unsupported collection type '{collectionType}' for a rerun collection."))
};
private static async Task<Validation<BaseError, Unit>> Exists<T>(
DbSet<T> set,
int? id,
string errorMessage,
CancellationToken cancellationToken)
where T : class
{
// pure existence check — no tracking, nothing materialized into the save-path context
if (id is null ||
!await set.AnyAsync(e => EF.Property<int>(e, "Id") == id.Value, cancellationToken))
{
return Fail<BaseError, Unit>(errorMessage);
}
return Success<BaseError, Unit>(Unit.Default);
}
}
@@ -58,8 +58,17 @@ public class UpdateRerunCollectionHandler(
TvContext dbContext,
UpdateRerunCollection request,
CancellationToken cancellationToken) =>
(await RerunCollectionMustExist(dbContext, request, cancellationToken), await ValidateName(dbContext, request))
.Apply((collectionToUpdate, _) => collectionToUpdate);
(await RerunCollectionMustExist(dbContext, request, cancellationToken),
await ValidateName(dbContext, request),
await RerunCollectionSelectionValidation.SelectedEntityMustExist(
dbContext,
request.CollectionType,
request.Collection?.Id,
request.MultiCollection?.Id,
request.SmartCollection?.Id,
request.MediaItem?.MediaItemId,
cancellationToken))
.Apply((collectionToUpdate, _, _) => collectionToUpdate);
private static Task<Validation<BaseError, RerunCollection>> RerunCollectionMustExist(
TvContext dbContext,
@@ -55,7 +55,17 @@ public class UpdateTraktListHandler(
if (entityLocker.LockTrakt())
{
await workerChannel.WriteAsync(new MatchTraktListItems(traktList.Id), cancellationToken);
try
{
await workerChannel.WriteAsync(new MatchTraktListItems(traktList.Id), cancellationToken);
}
catch
{
// the background handler only unlocks when it receives the message; if the
// enqueue fails after we acquired the lock, release it here or it is held forever.
entityLocker.UnlockTrakt();
throw;
}
}
}
else if (traktList.PlaylistId is not null)
@@ -16,32 +16,58 @@ public class GetCollectionItemsHandler(IDbContextFactory<TvContext> dbContextFac
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
bool exists = await dbContext.Collections
// A null flag here means the collection row does not exist (the projection yields no row),
// which lets a single query serve both the existence check and the custom-order flag.
bool? useCustomPlaybackOrder = await dbContext.Collections
.AsNoTracking()
.AnyAsync(c => c.Id == request.Id, cancellationToken);
if (!exists)
.Where(c => c.Id == request.Id)
.Select(c => (bool?)c.UseCustomPlaybackOrder)
.SingleOrDefaultAsync(cancellationToken);
if (useCustomPlaybackOrder is null)
{
return new NotFoundError($"Collection {request.Id} does not exist.");
}
// The collection graph is bounded, so load every member id and hydrate them in one shared
// pass (LibraryBrowseItemMapper), then order + page in-memory. Mixed media kinds are supported
// because MediaItem ids are globally unique across kinds.
List<int> mediaItemIds = await dbContext.CollectionItems
// The collection graph is bounded, so load every member (with its CustomIndex) and hydrate
// them in one shared pass (LibraryBrowseItemMapper), then order + page in-memory. Mixed media
// kinds are supported because MediaItem ids are globally unique across kinds.
var collectionItems = await dbContext.CollectionItems
.AsNoTracking()
.Where(ci => ci.CollectionId == request.Id)
.Select(ci => ci.MediaItemId)
.Select(ci => new { ci.MediaItemId, ci.CustomIndex })
.ToListAsync(cancellationToken);
List<int> mediaItemIds = collectionItems.Select(ci => ci.MediaItemId).ToList();
List<LibraryBrowseItemResponseModel> all =
await LibraryBrowseItemMapper.HydrateMediaItemsByIds(dbContext, mediaItemIds, cancellationToken);
// Stable title ordering mirrors the library-browse handler (which orders its rows by name),
// giving the SPA a deterministic, browsable list independent of collection insertion order.
List<LibraryBrowseItemResponseModel> ordered = all
.OrderBy(i => i.Title, StringComparer.OrdinalIgnoreCase)
.ThenBy(i => i.Id)
.ToList();
List<LibraryBrowseItemResponseModel> ordered;
if (useCustomPlaybackOrder.Value)
{
// Custom order: sort by CustomIndex (items without one sort last), then title/id as a
// stable tiebreak.
var customIndexByMediaItemId = collectionItems
.GroupBy(ci => ci.MediaItemId)
.ToDictionary(g => g.Key, g => g.Select(ci => ci.CustomIndex).FirstOrDefault());
ordered = all
.OrderBy(i => customIndexByMediaItemId.TryGetValue(i.Id, out int? customIndex) && customIndex.HasValue
? customIndex.Value
: int.MaxValue)
.ThenBy(i => i.Title, StringComparer.OrdinalIgnoreCase)
.ThenBy(i => i.Id)
.ToList();
}
else
{
// Stable title ordering mirrors the library-browse handler (which orders its rows by name),
// giving the SPA a deterministic, browsable list independent of collection insertion order.
ordered = all
.OrderBy(i => i.Title, StringComparer.OrdinalIgnoreCase)
.ThenBy(i => i.Id)
.ToList();
}
int pageNum = Math.Max(0, request.PageNum);
int pageSize = Math.Clamp(request.PageSize, 1, 100);
+1
View File
@@ -23,6 +23,7 @@ internal static class Mapper
internal static PlayoutItemViewModel ProjectToViewModel(PlayoutItem playoutItem) =>
new(
playoutItem.Id,
GetDisplayTitle(playoutItem.MediaItem, playoutItem.ChapterTitle),
playoutItem.StartOffset,
playoutItem.FinishOffset,
@@ -3,6 +3,7 @@ using ErsatzTV.Core.Domain.Filler;
namespace ErsatzTV.Application.Playouts;
public record PlayoutItemViewModel(
int? Id,
string Title,
DateTimeOffset Start,
DateTimeOffset Finish,
@@ -106,7 +106,9 @@ public class GetFuturePlayoutItemsByIdHandler(IDbContextFactory<TvContext> dbCon
var gap = playoutGaps.Single(g => g.Id == c.Id);
TimeSpan gapDuration = gap.Finish - gap.Start;
// gaps are synthesized rows, not PlayoutItems, so they carry no row id
return new PlayoutItemViewModel(
null,
"UNSCHEDULED",
gap.StartOffset,
gap.FinishOffset,
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Playouts;
public record GetPlayoutItemSchedulingContext(int PlayoutItemId) : IRequest<Option<string>>;
@@ -0,0 +1,33 @@
using ErsatzTV.Application.ProgramSchedules;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Playouts;
public class GetPlayoutItemSchedulingContextHandler(
IDbContextFactory<TvContext> dbContextFactory,
IMediator mediator)
: IRequestHandler<GetPlayoutItemSchedulingContext, Option<string>>
{
public async Task<Option<string>> Handle(
GetPlayoutItemSchedulingContext request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
string serializedContext = await dbContext.PlayoutItems
.AsNoTracking()
.Where(pi => pi.Id == request.PlayoutItemId)
.Select(pi => pi.SchedulingContext)
.SingleOrDefaultAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(serializedContext))
{
return Option<string>.None;
}
// Decode/enrich exactly the way the troubleshooting decode path does, reusing the
// single ProcessSchedulingContext handler so any future format change stays in one place.
return await mediator.Send(new ProcessSchedulingContext(serializedContext), cancellationToken);
}
}
@@ -7,15 +7,20 @@ public interface ISynchronizePlexLibraryById : IRequest<Either<BaseError, string
int PlexLibraryId { get; }
bool ForceScan { get; }
bool DeepScan { get; }
// When false, the ScannerService keeps the library lock held after this message so a later
// message in the same batch (e.g. SynchronizePlexNetworks) carries the single release.
bool Unlock { get; }
}
public record SynchronizePlexLibraryByIdIfNeeded(int PlexLibraryId) : ISynchronizePlexLibraryById
public record SynchronizePlexLibraryByIdIfNeeded(int PlexLibraryId, bool Unlock = true) : ISynchronizePlexLibraryById
{
public bool ForceScan => false;
public bool DeepScan => false;
}
public record ForceSynchronizePlexLibraryById(int PlexLibraryId, bool DeepScan) : ISynchronizePlexLibraryById
public record ForceSynchronizePlexLibraryById(int PlexLibraryId, bool DeepScan, bool Unlock = true)
: ISynchronizePlexLibraryById
{
public bool ForceScan => true;
}
@@ -2,5 +2,9 @@ using ErsatzTV.Core;
namespace ErsatzTV.Application.Plex;
public record SynchronizePlexNetworks(int PlexLibraryId, bool ForceScan) : IRequest<Either<BaseError, Unit>>,
IScannerBackgroundServiceRequest;
// Unlock defaults true so single-message callers release the library lock as before. In the Plex
// "Shows" scheduler batch this message runs LAST and carries the single release (the preceding
// library message runs with Unlock: false).
public record SynchronizePlexNetworks(int PlexLibraryId, bool ForceScan, bool Unlock = true)
: IRequest<Either<BaseError, Unit>>,
IScannerBackgroundServiceRequest;
@@ -60,7 +60,15 @@ public class AddProgramScheduleItemHandler : ProgramScheduleItemCommandBase,
await _channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh), cancellationToken);
}
return ProjectToViewModel(item);
// reload with the full navigation graph before projecting: BuildItem creates the watermark/graphics
// join rows with only their foreign-key ids set, so the tracked entities have null Watermark /
// GraphicsElement navs that ProjectToViewModel dereferences (would 500 on POST — see #229).
ProgramScheduleItem persisted = await dbContext.ProgramScheduleItems
.Filter(psi => psi.Id == item.Id)
.IncludeScheduleItemDetails()
.SingleAsync(cancellationToken);
return ProjectToViewModel(persisted);
}
private static async Task<Validation<BaseError, ProgramSchedule>> Validate(
@@ -56,7 +56,16 @@ public class ReplaceProgramScheduleItemsHandler(
await channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh), cancellationToken);
}
return programSchedule.Items.Map(ProjectToViewModel);
// reload with the full navigation graph before projecting: BuildItem creates the watermark/graphics
// join rows with only their foreign-key ids set, so the tracked entities have null Watermark /
// GraphicsElement navs that ProjectToViewModel dereferences (would 500 on PUT — see #229).
List<ProgramScheduleItem> persisted = await dbContext.ProgramScheduleItems
.Filter(psi => psi.ProgramScheduleId == programSchedule.Id)
.IncludeScheduleItemDetails()
.OrderBy(i => i.Index)
.ToListAsync(cancellationToken);
return persisted.Map(ProjectToViewModel).ToList();
}
private static async Task<Validation<BaseError, ProgramSchedule>> Validate(
@@ -0,0 +1,48 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ProgramSchedules;
internal static class ProgramScheduleItemQueryExtensions
{
/// <summary>
/// The single source of truth for the navigation graph a <see cref="ProgramScheduleItem" /> needs before
/// it can be projected via <see cref="Mapper.ProjectToViewModel" />. The mapper dereferences the
/// <c>Watermark</c> / <c>GraphicsElement</c> navs of each join row without a null guard, so any handler
/// that projects freshly-persisted items MUST reload through this include chain (see #229 — the write
/// path used to project the tracked-but-unloaded graph and threw a NullReferenceException, surfacing as a
/// 500 on PUT/POST whenever an item carried a watermark or graphics element).
/// </summary>
public static IQueryable<ProgramScheduleItem> IncludeScheduleItemDetails(this IQueryable<ProgramScheduleItem> query) =>
query
.Include(i => i.Collection)
.Include(i => i.MultiCollection)
.Include(i => i.SmartCollection)
.Include(i => i.RerunCollection)
.Include(i => i.Playlist)
.Include(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(i => i.MediaItem)
.ThenInclude(i => (i as Season).SeasonMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(i => i.MediaItem)
.ThenInclude(i => (i as Season).Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(i => i.MediaItem)
.ThenInclude(i => (i as Show).ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(i => i.MediaItem)
.ThenInclude(i => (i as Artist).ArtistMetadata)
.ThenInclude(am => am.Artwork)
.Include(i => i.PreRollFiller)
.Include(i => i.MidRollFiller)
.Include(i => i.PostRollFiller)
.Include(i => i.TailFiller)
.Include(i => i.FallbackFiller)
.Include(i => i.ProgramScheduleItemWatermarks)
.ThenInclude(i => i.Watermark)
.Include(i => i.ProgramScheduleItemGraphicsElements)
.ThenInclude(i => i.GraphicsElement);
}
@@ -20,36 +20,8 @@ public class GetProgramScheduleItemsHandler(IDbContextFactory<TvContext> dbConte
return await dbContext.ProgramScheduleItems
.Filter(psi => psi.ProgramScheduleId == request.Id)
.Include(i => i.Collection)
.Include(i => i.MultiCollection)
.Include(i => i.SmartCollection)
.Include(i => i.RerunCollection)
.Include(i => i.Playlist)
.Include(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(i => i.MediaItem)
.ThenInclude(i => (i as Season).SeasonMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(i => i.MediaItem)
.ThenInclude(i => (i as Season).Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(i => i.MediaItem)
.ThenInclude(i => (i as Show).ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(i => i.MediaItem)
.ThenInclude(i => (i as Artist).ArtistMetadata)
.ThenInclude(am => am.Artwork)
.Include(i => i.PreRollFiller)
.Include(i => i.MidRollFiller)
.Include(i => i.PostRollFiller)
.Include(i => i.TailFiller)
.Include(i => i.FallbackFiller)
.Include(i => i.ProgramScheduleItemWatermarks)
.ThenInclude(i => i.Watermark)
.Include(i => i.ProgramScheduleItemGraphicsElements)
.ThenInclude(i => i.GraphicsElement)
.IncludeScheduleItemDetails()
.OrderBy(i => i.Index)
.ToListAsync(cancellationToken)
.Map(programScheduleItems => programScheduleItems.Map(ProjectToViewModel)
.Map(psi => EnforceProperties(maybeProgramSchedule, psi)).ToList());
@@ -0,0 +1,109 @@
using System.Collections.Generic;
using System.Linq;
using ErsatzTV.Core.Api;
using ErsatzTV.Core.Api.Scheduling;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.ProgramSchedules;
/// <summary>
/// Maps the polymorphic <see cref="ProgramScheduleItemViewModel" /> hierarchy to the flat,
/// fully-described <see cref="ScheduleItemResponseModel" /> API DTO (issue #126). Subtype-specific
/// fields (Multiple's mode/count, Duration's playoutDuration/tailMode/discardToFillAttempts) are
/// read by pattern-matching the concrete VM type; they are null for other subtypes.
/// </summary>
public static class ScheduleItemResponseMapper
{
public static ScheduleItemResponseModel ProjectToResponseModel(ProgramScheduleItemViewModel item)
{
MultipleMode? multipleMode = null;
string multipleCount = null;
TimeSpan? playoutDuration = null;
TailMode? tailMode = null;
int? discardToFillAttempts = null;
switch (item)
{
case ProgramScheduleItemMultipleViewModel multiple:
multipleMode = multiple.MultipleMode;
multipleCount = multiple.Count;
break;
case ProgramScheduleItemDurationViewModel duration:
playoutDuration = duration.PlayoutDuration;
tailMode = duration.TailMode;
discardToFillAttempts = duration.DiscardToFillAttempts;
break;
}
List<int> watermarkIds = item.Watermarks?.Map(w => w.Id).ToList() ?? new List<int>();
List<int> graphicsElementIds = item.GraphicsElements?.Map(g => g.Id).ToList() ?? new List<int>();
List<NamedIdResponseModel> watermarks =
item.Watermarks?.Map(w => new NamedIdResponseModel(w.Id, w.Name)).ToList()
?? new List<NamedIdResponseModel>();
List<NamedIdResponseModel> graphicsElements =
item.GraphicsElements?.Map(g => new NamedIdResponseModel(g.Id, g.Name)).ToList()
?? new List<NamedIdResponseModel>();
return new ScheduleItemResponseModel(
item.Id,
item.Index,
item.StartType,
item.StartTime,
item.FixedStartTimeBehavior,
item.PlayoutMode,
item.CollectionType,
item.Collection?.Id,
item.MultiCollection?.Id,
item.SmartCollection?.Id,
item.RerunCollection?.Id,
item.MediaItem?.MediaItemId,
item.Playlist?.Id,
item.SearchTitle,
item.SearchQuery,
item.PlaybackOrder,
item.MarathonGroupBy,
item.MarathonShuffleGroups,
item.MarathonShuffleItems,
item.MarathonBatchSize,
item.FillWithGroupMode,
multipleMode,
multipleCount,
playoutDuration,
tailMode,
discardToFillAttempts,
item.CustomTitle,
item.GuideMode,
item.PreRollFiller?.Id,
item.MidRollFiller?.Id,
item.PostRollFiller?.Id,
item.TailFiller?.Id,
item.FallbackFiller?.Id,
watermarkIds,
graphicsElementIds,
item.PreferredAudioLanguageCode,
item.PreferredAudioTitle,
item.PreferredSubtitleLanguageCode,
item.SubtitleMode,
item.Collection?.Name,
item.MultiCollection?.Name,
item.SmartCollection?.Name,
item.RerunCollection?.Name,
item.Playlist?.Name,
item.Playlist?.PlaylistGroupId,
item.MediaItem?.Name,
item.PreRollFiller?.Name,
item.MidRollFiller?.Name,
item.PostRollFiller?.Name,
item.TailFiller?.Name,
item.FallbackFiller?.Name,
watermarks,
graphicsElements,
item.Name,
item.DurationEstimate);
}
public static ScheduleItemsResponseModel ProjectToResponseModel(ProgramScheduleItemsWithDurationViewModel vm) =>
new(
vm.Items.Map(ProjectToResponseModel).ToList(),
vm.TotalDurationEstimate);
}
@@ -11,6 +11,7 @@ using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Troubleshooting;
using ErsatzTV.Core.Notifications;
using ErsatzTV.FFmpeg;
using ErsatzTV.FFmpeg.State;
@@ -34,6 +35,7 @@ public class PrepareTroubleshootingPlaybackHandler(
ISongVideoGenerator songVideoGenerator,
IWatermarkSelector watermarkSelector,
IEntityLocker entityLocker,
ITroubleshootingPlaybackStatusStore statusStore,
IMediator mediator,
LoggingLevelSwitches loggingLevelSwitches,
ILogger<PrepareTroubleshootingPlaybackHandler> logger)
@@ -68,6 +70,7 @@ public class PrepareTroubleshootingPlaybackHandler(
}
entityLocker.LockTroubleshootingPlayback();
statusStore.Reset();
localFileSystem.EnsureFolderExists(FileSystemLayout.TranscodeTroubleshootingFolder);
localFileSystem.EmptyFolder(FileSystemLayout.TranscodeTroubleshootingFolder);
@@ -166,6 +169,7 @@ public class PrepareTroubleshootingPlaybackHandler(
}
entityLocker.LockTroubleshootingPlayback();
statusStore.Reset();
localFileSystem.EnsureFolderExists(FileSystemLayout.TranscodeTroubleshootingFolder);
localFileSystem.EmptyFolder(FileSystemLayout.TranscodeTroubleshootingFolder);
@@ -0,0 +1,14 @@
using ErsatzTV.Core.Interfaces.Troubleshooting;
using ErsatzTV.Core.Notifications;
namespace ErsatzTV.Application.Troubleshooting;
public class RecordTroubleshootingPlaybackStatusHandler(ITroubleshootingPlaybackStatusStore statusStore)
: INotificationHandler<PlaybackTroubleshootingCompletedNotification>
{
public Task Handle(PlaybackTroubleshootingCompletedNotification notification, CancellationToken cancellationToken)
{
statusStore.RecordCompletion(notification.ExitCode, notification.MaybeSpeed);
return Task.CompletedTask;
}
}
@@ -14,7 +14,7 @@ public class FakeTelevisionRepository : ITelevisionRepository
public Task<List<Show>> GetAllShows() => throw new NotSupportedException();
public Task<Option<Show>> GetShow(int showId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<Option<int>> GetShowIdByTitle(int libraryId, string title) => throw new NotSupportedException();
public Task<Option<string>> GetShowTitle(int libraryId, int showId) => throw new NotSupportedException();
public Task<List<Episode>> GetShowItems(int showId) => throw new NotSupportedException();
public Task<List<int>> GetEpisodeIdsForShow(int showId) => throw new NotSupportedException();
@@ -1,3 +1,5 @@
using ErsatzTV.Core.Domain.Filler;
namespace ErsatzTV.Core.Api.Filler;
public record FillerPresetResponseModel(int Id, string Name);
public record FillerPresetResponseModel(int Id, string Name, FillerKind FillerKind);
@@ -20,4 +20,5 @@ public record LibraryBrowseItemResponseModel(
int? RerunCollectionId,
int? MediaItemId,
int? PlaylistId,
string? Subtitle = null);
string? Subtitle = null,
int? SeasonId = null);
@@ -0,0 +1,20 @@
#nullable enable
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Api.MediaCollections;
public record MultiCollectionResponseModel(
int Id,
string Name,
List<MultiCollectionItemResponseModel> Items);
public record MultiCollectionItemResponseModel(
int? CollectionId,
int? SmartCollectionId,
string Name,
bool ScheduleAsGroup,
PlaybackOrder PlaybackOrder);
public record PagedMultiCollectionsResponseModel(
int TotalCount,
List<MultiCollectionResponseModel> Page);
@@ -0,0 +1,21 @@
#nullable enable
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Api.MediaCollections;
public record PlaylistItemResponseModel(
int Id,
int Index,
CollectionType CollectionType,
int? CollectionId,
string? CollectionName,
int? MultiCollectionId,
string? MultiCollectionName,
int? SmartCollectionId,
string? SmartCollectionName,
int? MediaItemId,
string? MediaItemName,
PlaybackOrder PlaybackOrder,
int? Count,
bool PlayAll,
bool IncludeInProgramGuide);
@@ -0,0 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.MediaCollections;
public record PlaylistPreviewItemResponseModel(string Title, string Start, string Finish, string Duration);
@@ -0,0 +1,17 @@
#nullable enable
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Api.MediaCollections;
public record RerunCollectionResponseModel(
int Id,
string Name,
CollectionType CollectionType,
int? SelectedId,
string? SelectedName,
PlaybackOrder FirstRunPlaybackOrder,
PlaybackOrder RerunPlaybackOrder);
public record PagedRerunCollectionsResponseModel(
int TotalCount,
List<RerunCollectionResponseModel> Page);
@@ -0,0 +1,3 @@
namespace ErsatzTV.Core.Api.MediaItems;
public record LanguageCodeResponseModel(string Code, string EnglishName);
@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Api;
/// <summary>
/// A lightweight {id, name} pair used when an API response needs to embed a reference to a
/// named entity (e.g. a schedule item's watermarks or graphics elements) without projecting
/// the full entity DTO.
/// </summary>
public record NamedIdResponseModel(int Id, string Name);
@@ -1,10 +1,13 @@
#nullable enable
using ErsatzTV.Core.Domain.Filler;
namespace ErsatzTV.Core.Api.Playouts;
public record PlayoutItemResponseModel(
int? Id,
string Title,
DateTimeOffset Start,
DateTimeOffset Finish,
string Duration,
FillerKind? FillerKind);
FillerKind? FillerKind,
bool HasSchedulingContext);
@@ -0,0 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.Playouts;
public record PlayoutItemSchedulingContextResponseModel(string Context);
@@ -10,4 +10,6 @@ public record PlayoutListItemResponseModel(
PlayoutScheduleKind ScheduleKind,
string ScheduleName,
TimeSpan? DailyRebuildTime,
PlayoutBuildStatusResponseModel? BuildStatus);
PlayoutBuildStatusResponseModel? BuildStatus,
ChannelPlayoutMode PlayoutMode,
bool IsLocked);
@@ -0,0 +1,70 @@
#nullable enable
using System.Collections.Generic;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Core.Api.Scheduling;
/// <summary>
/// A flat, non-polymorphic projection of a program schedule item (issue #126). Unlike the
/// Application-layer <c>ProgramScheduleItemViewModel</c> hierarchy (One/Flood/Multiple/Duration
/// subtypes), every subtype-specific field is a nullable member here so the OpenAPI schema fully
/// describes the shape. The mutation fields are named 1:1 with
/// <c>ScheduleItemRequest</c> so a GET can be mapped losslessly back to a PUT/POST request.
/// </summary>
public record ScheduleItemResponseModel(
int Id,
int Index,
StartType StartType,
TimeSpan? StartTime,
FixedStartTimeBehavior? FixedStartTimeBehavior,
PlayoutMode PlayoutMode,
CollectionType CollectionType,
int? CollectionId,
int? MultiCollectionId,
int? SmartCollectionId,
int? RerunCollectionId,
int? MediaItemId,
int? PlaylistId,
string? SearchTitle,
string? SearchQuery,
PlaybackOrder PlaybackOrder,
MarathonGroupBy MarathonGroupBy,
bool MarathonShuffleGroups,
bool MarathonShuffleItems,
int? MarathonBatchSize,
FillWithGroupMode FillWithGroupMode,
MultipleMode? MultipleMode,
string? MultipleCount,
TimeSpan? PlayoutDuration,
TailMode? TailMode,
int? DiscardToFillAttempts,
string? CustomTitle,
GuideMode GuideMode,
int? PreRollFillerId,
int? MidRollFillerId,
int? PostRollFillerId,
int? TailFillerId,
int? FallbackFillerId,
List<int> WatermarkIds,
List<int> GraphicsElementIds,
string? PreferredAudioLanguageCode,
string? PreferredAudioTitle,
string? PreferredSubtitleLanguageCode,
ChannelSubtitleMode? SubtitleMode,
string? CollectionName,
string? MultiCollectionName,
string? SmartCollectionName,
string? RerunCollectionName,
string? PlaylistName,
int? PlaylistGroupId,
string? MediaItemName,
string? PreRollFillerName,
string? MidRollFillerName,
string? PostRollFillerName,
string? TailFillerName,
string? FallbackFillerName,
List<NamedIdResponseModel> Watermarks,
List<NamedIdResponseModel> GraphicsElements,
string? Name,
TimeSpan? DurationEstimate);
@@ -0,0 +1,12 @@
#nullable enable
using System.Collections.Generic;
namespace ErsatzTV.Core.Api.Scheduling;
/// <summary>
/// GET envelope for a schedule's items. Carries the flat <see cref="ScheduleItemResponseModel" />
/// list plus a best-effort total runtime estimate (null when unbounded/unknown).
/// </summary>
public record ScheduleItemsResponseModel(
List<ScheduleItemResponseModel> Items,
TimeSpan? TotalDurationEstimate);
@@ -0,0 +1,13 @@
namespace ErsatzTV.Core.Api.Search;
public record SearchResultAllItemsResponseModel(
List<int> MovieIds,
List<int> ShowIds,
List<int> SeasonIds,
List<int> EpisodeIds,
List<int> ArtistIds,
List<int> MusicVideoIds,
List<int> OtherVideoIds,
List<int> SongIds,
List<int> ImageIds,
List<int> RemoteStreamIds);
@@ -0,0 +1,15 @@
#nullable enable
namespace ErsatzTV.Core.Api.Troubleshooting;
// State machine for the SPA's playback-status poll:
// "idle" - no session has run since startup / reset
// "running" - a session is in flight (IEntityLocker.IsTroubleshootingPlaybackLocked())
// "completed" - the last session finished with exit code 0
// "failed" - the last session finished with a non-zero exit code
// ExitCode/Speed are populated once a result has been recorded; Logs is the tail (last 500 lines)
// of the session's logs.txt when present.
public record TroubleshootingPlaybackStatusResponseModel(
string State,
int? ExitCode,
double? Speed,
string? Logs);
@@ -0,0 +1,11 @@
#nullable enable
namespace ErsatzTV.Core.Api.Troubleshooting;
// Mirrors ErsatzTV.Application.Troubleshooting.SubtitleViewModel. Id is the value the SPA passes
// back as the m3u8 endpoint's subtitleId query parameter; the remaining fields are display-only.
// Language/Title/Codec are nullable because embedded/sidecar subtitles frequently lack them.
public record TroubleshootingSubtitleResponseModel(
int Id,
string? Language,
string? Title,
string? Codec);
@@ -1,5 +1,19 @@
namespace ErsatzTV.Core.Interfaces.Locking;
/// <summary>
/// Advisory "an operation on entity X is in progress" mutex layer, registered as a process-wide singleton.
/// Each lock kind is a single exclusive slot (not reentrant or counted): a second acquire while held returns false.
/// </summary>
/// <remarks>
/// Ownership contract (see docs/decisions.md 2026-07-11, issue #231): every <c>Lock*</c> call returns
/// <c>true</c> iff it transitioned the slot to locked. A <c>true</c> confers ownership of exactly one release —
/// performed either in the acquiring scope (release in a <c>finally</c> gated on the captured return value) or by
/// the single designated releaser it hands off to (the consumer of a message enqueued while holding the lock, with
/// a compensating unlock if the enqueue itself throws). Callers must never release a lock they did not acquire.
/// <c>Unlock*</c> on an already-unlocked slot is a no-op that returns <c>false</c>, raises no event, and logs a
/// warning — the tripwire for double-release / non-owner-release bugs; it deliberately does not throw, so the
/// primitive stays safe to call from <c>finally</c> paths.
/// </remarks>
public interface IEntityLocker
{
event EventHandler OnLibraryChanged;
@@ -10,7 +10,7 @@ public interface ITelevisionRepository
Task<bool> AllEpisodesExist(List<int> episodeIds);
Task<List<Show>> GetAllShows();
Task<Option<Show>> GetShow(int showId, CancellationToken cancellationToken);
Task<Option<int>> GetShowIdByTitle(int libraryId, string title);
Task<Option<string>> GetShowTitle(int libraryId, int showId);
Task<List<Episode>> GetShowItems(int showId);
Task<List<int>> GetEpisodeIdsForShow(int showId);
Task<List<Season>> GetAllSeasons();
@@ -0,0 +1,16 @@
using ErsatzTV.Core.Troubleshooting;
namespace ErsatzTV.Core.Interfaces.Troubleshooting;
// Records the outcome of the most recent troubleshooting playback session so the SPA can poll
// for completion (the legacy Blazor page instead subscribed to an in-process notification). The
// store is reset when a new session starts (see PrepareTroubleshootingPlaybackHandler) and written
// by the PlaybackTroubleshootingCompletedNotification handler.
public interface ITroubleshootingPlaybackStatusStore
{
Option<TroubleshootingPlaybackResult> CurrentResult { get; }
void Reset();
void RecordCompletion(int exitCode, Option<double> speed);
}
@@ -0,0 +1,3 @@
namespace ErsatzTV.Core.Troubleshooting;
public record TroubleshootingPlaybackResult(int ExitCode, Option<double> Speed);
@@ -0,0 +1,36 @@
using ErsatzTV.Core.Interfaces.Troubleshooting;
namespace ErsatzTV.Core.Troubleshooting;
public class TroubleshootingPlaybackStatusStore : ITroubleshootingPlaybackStatusStore
{
private readonly object _sync = new();
private Option<TroubleshootingPlaybackResult> _result = Option<TroubleshootingPlaybackResult>.None;
public Option<TroubleshootingPlaybackResult> CurrentResult
{
get
{
lock (_sync)
{
return _result;
}
}
}
public void Reset()
{
lock (_sync)
{
_result = Option<TroubleshootingPlaybackResult>.None;
}
}
public void RecordCompletion(int exitCode, Option<double> speed)
{
lock (_sync)
{
_result = new TroubleshootingPlaybackResult(exitCode, speed);
}
}
}
@@ -0,0 +1,270 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Notifications;
using ErsatzTV.Infrastructure.Locking;
using MediatR;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Infrastructure.Tests.Locking;
// Verifies the atomicity + single-owner release contract from docs/decisions.md 2026-07-11 (issue #231).
// These run against the real EntityLocker (not a mock) so the Interlocked/ConcurrentDictionary state is exercised.
[TestFixture]
public class EntityLockerTests
{
private const int ParallelCallers = 64;
private static EntityLocker CreateLocker() =>
new(Substitute.For<IMediator>(), NullLogger<EntityLocker>.Instance);
// Every exclusive single-slot kind, adapted to a uniform bool lock/unlock/is-locked shape.
// Library bakes in a fixed id; RemoteMediaSource a concrete media-source type. Playout is async
// (tested separately) and so is not in this source.
private static IEnumerable<LockKind> ExclusiveWinnerKinds()
{
foreach (LockKind kind in EventCountingKinds())
{
yield return kind;
}
yield return new LockKind
{
Name = "RemoteMediaSource",
Lock = l => l.LockRemoteMediaSource<PlexMediaSource>(),
Unlock = l => l.UnlockRemoteMediaSource<PlexMediaSource>(),
IsLocked = l => l.IsRemoteMediaSourceLocked<PlexMediaSource>()
};
}
// The kinds whose change event is a plain EventHandler, so a subscriber can count transitions.
private static IEnumerable<LockKind> EventCountingKinds()
{
yield return new LockKind
{
Name = "Plex",
Lock = l => l.LockPlex(),
Unlock = l => l.UnlockPlex(),
IsLocked = l => l.IsPlexLocked(),
Subscribe = (l, h) => l.OnPlexChanged += h
};
yield return new LockKind
{
Name = "Trakt",
Lock = l => l.LockTrakt(),
Unlock = l => l.UnlockTrakt(),
IsLocked = l => l.IsTraktLocked(),
Subscribe = (l, h) => l.OnTraktChanged += h
};
yield return new LockKind
{
Name = "EmbyCollections",
Lock = l => l.LockEmbyCollections(),
Unlock = l => l.UnlockEmbyCollections(),
IsLocked = l => l.AreEmbyCollectionsLocked(),
Subscribe = (l, h) => l.OnEmbyCollectionsChanged += h
};
yield return new LockKind
{
Name = "JellyfinCollections",
Lock = l => l.LockJellyfinCollections(),
Unlock = l => l.UnlockJellyfinCollections(),
IsLocked = l => l.AreJellyfinCollectionsLocked(),
Subscribe = (l, h) => l.OnJellyfinCollectionsChanged += h
};
yield return new LockKind
{
Name = "PlexCollections",
Lock = l => l.LockPlexCollections(),
Unlock = l => l.UnlockPlexCollections(),
IsLocked = l => l.ArePlexCollectionsLocked(),
Subscribe = (l, h) => l.OnPlexCollectionsChanged += h
};
yield return new LockKind
{
Name = "TroubleshootingPlayback",
Lock = l => l.LockTroubleshootingPlayback(),
Unlock = l => l.UnlockTroubleshootingPlayback(),
IsLocked = l => l.IsTroubleshootingPlaybackLocked(),
Subscribe = (l, h) => l.OnTroubleshootingPlaybackChanged += h
};
yield return new LockKind
{
Name = "Library",
Lock = l => l.LockLibrary(1),
Unlock = l => l.UnlockLibrary(1),
IsLocked = l => l.IsLibraryLocked(1),
Subscribe = (l, h) => l.OnLibraryChanged += h
};
}
// Stress the acquire race over many rounds: a single-round 64-thread barrier does NOT reliably
// collide the non-atomic check-then-set window on fast hardware (verified: it green-lit a
// deliberately broken flag), so this hammers Lock* for thousands of rounds — each round every
// thread races one acquire, exactly one must win, then the winner releases to reset. Any round
// with a winner count != 1 fails the whole test. Against the atomic CAS this is invariant;
// against a non-atomic flag some round double-acquires.
[Test]
[TestCaseSource(nameof(ExclusiveWinnerKinds))]
public void Lock_ParallelCallers_ExactlyOneWinner(LockKind kind)
{
const int threads = 8;
const int rounds = 20_000;
EntityLocker locker = CreateLocker();
var winners = 0;
var badRounds = 0;
using var startRound = new Barrier(threads);
using var endRound = new Barrier(
threads,
_ =>
{
if (Volatile.Read(ref winners) != 1)
{
Interlocked.Increment(ref badRounds);
}
// reset for the next round: release the (single) held lock and clear the counter
kind.Unlock(locker);
Volatile.Write(ref winners, 0);
});
var workers = new Thread[threads];
for (var t = 0; t < threads; t++)
{
workers[t] = new Thread(() =>
{
for (var r = 0; r < rounds; r++)
{
startRound.SignalAndWait();
if (kind.Lock(locker))
{
Interlocked.Increment(ref winners);
}
endRound.SignalAndWait();
}
});
workers[t].Start();
}
foreach (Thread worker in workers)
{
worker.Join();
}
badRounds.ShouldBe(0);
kind.IsLocked(locker).ShouldBeFalse();
}
[Test]
public async Task LockPlayout_ParallelCallers_ExactlyOneWinner_PublishesOnce()
{
var mediator = Substitute.For<IMediator>();
var locker = new EntityLocker(mediator, NullLogger<EntityLocker>.Instance);
const int playoutId = 7;
using var barrier = new Barrier(ParallelCallers);
Task<bool>[] tasks = Enumerable.Range(0, ParallelCallers)
.Select(_ => Task.Run(async () =>
{
barrier.SignalAndWait();
return await locker.LockPlayout(playoutId);
}))
.ToArray();
bool[] results = await Task.WhenAll(tasks);
results.Count(r => r).ShouldBe(1);
locker.IsPlayoutLocked(playoutId).ShouldBeTrue();
await mediator.Received(1).Publish(
Arg.Is<PlayoutUpdatedNotification>(n => n.PlayoutId == playoutId && n.IsLocked),
Arg.Any<CancellationToken>());
}
[Test]
[TestCaseSource(nameof(EventCountingKinds))]
public void Lock_WhenAlreadyLocked_ReturnsFalse_AndFiresNoSecondEvent(LockKind kind)
{
EntityLocker locker = CreateLocker();
var eventCount = 0;
kind.Subscribe!(locker, (_, _) => eventCount++);
kind.Lock(locker).ShouldBeTrue();
kind.Lock(locker).ShouldBeFalse();
kind.IsLocked(locker).ShouldBeTrue();
eventCount.ShouldBe(1);
}
[Test]
[TestCaseSource(nameof(EventCountingKinds))]
public void Unlock_WhenNotLocked_ReturnsFalse_AndFiresNoEvent(LockKind kind)
{
EntityLocker locker = CreateLocker();
var eventCount = 0;
kind.Subscribe!(locker, (_, _) => eventCount++);
kind.Unlock(locker).ShouldBeFalse();
eventCount.ShouldBe(0);
kind.IsLocked(locker).ShouldBeFalse();
}
[Test]
[TestCaseSource(nameof(EventCountingKinds))]
public void Unlock_AfterLock_ReturnsTrue_ThenSecondUnlockReturnsFalse(LockKind kind)
{
EntityLocker locker = CreateLocker();
var eventCount = 0;
kind.Subscribe!(locker, (_, _) => eventCount++);
kind.Lock(locker).ShouldBeTrue();
kind.Unlock(locker).ShouldBeTrue();
kind.Unlock(locker).ShouldBeFalse();
kind.IsLocked(locker).ShouldBeFalse();
eventCount.ShouldBe(2); // one lock transition + one unlock transition, the no-op unlock fires nothing
}
[Test]
[TestCaseSource(nameof(EventCountingKinds))]
public async Task ParallelUnlock_AfterSingleLock_ExactlyOneReleaser(LockKind kind)
{
EntityLocker locker = CreateLocker();
kind.Lock(locker).ShouldBeTrue();
var unlockEvents = 0;
kind.Subscribe!(locker, (_, _) => Interlocked.Increment(ref unlockEvents));
using var barrier = new Barrier(ParallelCallers);
Task<bool>[] tasks = Enumerable.Range(0, ParallelCallers)
.Select(_ => Task.Run(() =>
{
barrier.SignalAndWait();
return kind.Unlock(locker);
}))
.ToArray();
bool[] results = await Task.WhenAll(tasks);
results.Count(r => r).ShouldBe(1);
kind.IsLocked(locker).ShouldBeFalse();
unlockEvents.ShouldBe(1);
}
public sealed class LockKind
{
public required string Name { get; init; }
public required Func<EntityLocker, bool> Lock { get; init; }
public required Func<EntityLocker, bool> Unlock { get; init; }
public required Func<EntityLocker, bool> IsLocked { get; init; }
// null for kinds whose change event is not a plain EventHandler (RemoteMediaSource is EventHandler<Type>).
public Action<EntityLocker, EventHandler>? Subscribe { get; init; }
public override string ToString() => Name;
}
}
@@ -80,16 +80,16 @@ public class TelevisionRepository : ITelevisionRepository
.SelectOneAsync(s => s.Id, s => s.Id == showId, cancellationToken);
}
public async Task<Option<int>> GetShowIdByTitle(int libraryId, string title)
public async Task<Option<string>> GetShowTitle(int libraryId, int showId)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.ShowMetadata
.AsNoTracking()
.Where(sm => sm.ShowId == showId)
.Where(sm => sm.Show.LibraryPath.LibraryId == libraryId)
.Where(sm => EF.Functions.Like(sm.Title, $"%{title}%"))
.Map(sm => sm.ShowId)
.Map(sm => sm.Title)
.FirstOrDefaultAsync()
.Map(showId => showId > 0 ? Option<int>.Some(showId) : Option<int>.None);
.Map(Optional);
}
public async Task<List<int>> GetEpisodeIdsForShow(int showId)
+52 -41
View File
@@ -2,20 +2,25 @@
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Notifications;
using MediatR;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Infrastructure.Locking;
public class EntityLocker(IMediator mediator) : IEntityLocker
public class EntityLocker(IMediator mediator, ILogger<EntityLocker> logger) : IEntityLocker
{
private readonly ConcurrentDictionary<int, byte> _lockedLibraries = new();
private readonly ConcurrentDictionary<int, byte> _lockedPlayouts = new();
private readonly ConcurrentDictionary<Type, byte> _lockedRemoteMediaSourceTypes = new();
private bool _embyCollections;
private bool _jellyfinCollections;
private bool _plex;
private bool _plexCollections;
private bool _trakt;
private bool _troubleshootingPlayback;
// 0 = unlocked, 1 = locked. Mutated only via Interlocked.CompareExchange so the caller that wins
// the 0 -> 1 transition is the sole owner and the only one that fires the change event
// (see docs/decisions.md 2026-07-11, issue #231).
private int _embyCollections;
private int _jellyfinCollections;
private int _plex;
private int _plexCollections;
private int _trakt;
private int _troubleshootingPlayback;
public event EventHandler OnLibraryChanged;
public event EventHandler OnPlexChanged;
@@ -28,7 +33,7 @@ public class EntityLocker(IMediator mediator) : IEntityLocker
public bool LockLibrary(int libraryId)
{
if (!_lockedLibraries.ContainsKey(libraryId) && _lockedLibraries.TryAdd(libraryId, 0))
if (_lockedLibraries.TryAdd(libraryId, 0))
{
OnLibraryChanged?.Invoke(this, EventArgs.Empty);
return true;
@@ -45,6 +50,9 @@ public class EntityLocker(IMediator mediator) : IEntityLocker
return true;
}
logger.LogWarning(
"UnlockLibrary called for library {LibraryId} that was not locked (double-release or non-owner release)",
libraryId);
return false;
}
@@ -53,9 +61,8 @@ public class EntityLocker(IMediator mediator) : IEntityLocker
public bool LockPlex()
{
if (!_plex)
if (Interlocked.CompareExchange(ref _plex, 1, 0) == 0)
{
_plex = true;
OnPlexChanged?.Invoke(this, EventArgs.Empty);
return true;
}
@@ -65,17 +72,17 @@ public class EntityLocker(IMediator mediator) : IEntityLocker
public bool UnlockPlex()
{
if (_plex)
if (Interlocked.CompareExchange(ref _plex, 0, 1) == 1)
{
_plex = false;
OnPlexChanged?.Invoke(this, EventArgs.Empty);
return true;
}
logger.LogWarning("UnlockPlex called but Plex was not locked (double-release or non-owner release)");
return false;
}
public bool IsPlexLocked() => _plex;
public bool IsPlexLocked() => Volatile.Read(ref _plex) == 1;
public bool IsRemoteMediaSourceLocked<TMediaSource>() =>
_lockedRemoteMediaSourceTypes.ContainsKey(typeof(TMediaSource));
@@ -84,8 +91,7 @@ public class EntityLocker(IMediator mediator) : IEntityLocker
{
Type mediaSourceType = typeof(TMediaSource);
if (!_lockedRemoteMediaSourceTypes.ContainsKey(mediaSourceType) &&
_lockedRemoteMediaSourceTypes.TryAdd(mediaSourceType, 0))
if (_lockedRemoteMediaSourceTypes.TryAdd(mediaSourceType, 0))
{
OnRemoteMediaSourceChanged?.Invoke(this, mediaSourceType);
return true;
@@ -104,14 +110,16 @@ public class EntityLocker(IMediator mediator) : IEntityLocker
return true;
}
logger.LogWarning(
"UnlockRemoteMediaSource called for {MediaSourceType} that was not locked (double-release or non-owner release)",
mediaSourceType.Name);
return false;
}
public bool LockTrakt()
{
if (!_trakt)
if (Interlocked.CompareExchange(ref _trakt, 1, 0) == 0)
{
_trakt = true;
OnTraktChanged?.Invoke(this, EventArgs.Empty);
return true;
}
@@ -121,23 +129,22 @@ public class EntityLocker(IMediator mediator) : IEntityLocker
public bool UnlockTrakt()
{
if (_trakt)
if (Interlocked.CompareExchange(ref _trakt, 0, 1) == 1)
{
_trakt = false;
OnTraktChanged?.Invoke(this, EventArgs.Empty);
return true;
}
logger.LogWarning("UnlockTrakt called but Trakt was not locked (double-release or non-owner release)");
return false;
}
public bool IsTraktLocked() => _trakt;
public bool IsTraktLocked() => Volatile.Read(ref _trakt) == 1;
public bool LockEmbyCollections()
{
if (!_embyCollections)
if (Interlocked.CompareExchange(ref _embyCollections, 1, 0) == 0)
{
_embyCollections = true;
OnEmbyCollectionsChanged?.Invoke(this, EventArgs.Empty);
return true;
}
@@ -147,23 +154,23 @@ public class EntityLocker(IMediator mediator) : IEntityLocker
public bool UnlockEmbyCollections()
{
if (_embyCollections)
if (Interlocked.CompareExchange(ref _embyCollections, 0, 1) == 1)
{
_embyCollections = false;
OnEmbyCollectionsChanged?.Invoke(this, EventArgs.Empty);
return true;
}
logger.LogWarning(
"UnlockEmbyCollections called but Emby collections were not locked (double-release or non-owner release)");
return false;
}
public bool AreEmbyCollectionsLocked() => _embyCollections;
public bool AreEmbyCollectionsLocked() => Volatile.Read(ref _embyCollections) == 1;
public bool LockJellyfinCollections()
{
if (!_jellyfinCollections)
if (Interlocked.CompareExchange(ref _jellyfinCollections, 1, 0) == 0)
{
_jellyfinCollections = true;
OnJellyfinCollectionsChanged?.Invoke(this, EventArgs.Empty);
return true;
}
@@ -173,23 +180,23 @@ public class EntityLocker(IMediator mediator) : IEntityLocker
public bool UnlockJellyfinCollections()
{
if (_jellyfinCollections)
if (Interlocked.CompareExchange(ref _jellyfinCollections, 0, 1) == 1)
{
_jellyfinCollections = false;
OnJellyfinCollectionsChanged?.Invoke(this, EventArgs.Empty);
return true;
}
logger.LogWarning(
"UnlockJellyfinCollections called but Jellyfin collections were not locked (double-release or non-owner release)");
return false;
}
public bool AreJellyfinCollectionsLocked() => _jellyfinCollections;
public bool AreJellyfinCollectionsLocked() => Volatile.Read(ref _jellyfinCollections) == 1;
public bool LockPlexCollections()
{
if (!_plexCollections)
if (Interlocked.CompareExchange(ref _plexCollections, 1, 0) == 0)
{
_plexCollections = true;
OnPlexCollectionsChanged?.Invoke(this, EventArgs.Empty);
return true;
}
@@ -199,21 +206,22 @@ public class EntityLocker(IMediator mediator) : IEntityLocker
public bool UnlockPlexCollections()
{
if (_plexCollections)
if (Interlocked.CompareExchange(ref _plexCollections, 0, 1) == 1)
{
_plexCollections = false;
OnPlexCollectionsChanged?.Invoke(this, EventArgs.Empty);
return true;
}
logger.LogWarning(
"UnlockPlexCollections called but Plex collections were not locked (double-release or non-owner release)");
return false;
}
public bool ArePlexCollectionsLocked() => _plexCollections;
public bool ArePlexCollectionsLocked() => Volatile.Read(ref _plexCollections) == 1;
public async Task<bool> LockPlayout(int playoutId)
{
if (!_lockedPlayouts.ContainsKey(playoutId) && _lockedPlayouts.TryAdd(playoutId, 0))
if (_lockedPlayouts.TryAdd(playoutId, 0))
{
await mediator.Publish(new PlayoutUpdatedNotification(playoutId, true));
return true;
@@ -230,6 +238,9 @@ public class EntityLocker(IMediator mediator) : IEntityLocker
return true;
}
logger.LogWarning(
"UnlockPlayout called for playout {PlayoutId} that was not locked (double-release or non-owner release)",
playoutId);
return false;
}
@@ -237,9 +248,8 @@ public class EntityLocker(IMediator mediator) : IEntityLocker
public bool LockTroubleshootingPlayback()
{
if (!_troubleshootingPlayback)
if (Interlocked.CompareExchange(ref _troubleshootingPlayback, 1, 0) == 0)
{
_troubleshootingPlayback = true;
OnTroubleshootingPlaybackChanged?.Invoke(this, EventArgs.Empty);
return true;
}
@@ -249,15 +259,16 @@ public class EntityLocker(IMediator mediator) : IEntityLocker
public bool UnlockTroubleshootingPlayback()
{
if (_troubleshootingPlayback)
if (Interlocked.CompareExchange(ref _troubleshootingPlayback, 0, 1) == 1)
{
_troubleshootingPlayback = false;
OnTroubleshootingPlaybackChanged?.Invoke(this, EventArgs.Empty);
return true;
}
logger.LogWarning(
"UnlockTroubleshootingPlayback called but troubleshooting playback was not locked (double-release or non-owner release)");
return false;
}
public bool IsTroubleshootingPlaybackLocked() => _troubleshootingPlayback;
public bool IsTroubleshootingPlaybackLocked() => Volatile.Read(ref _troubleshootingPlayback) == 1;
}
@@ -32,8 +32,24 @@ public class FillerPresetHandlerTests
await handler.Handle(new GetAllFillerPresetsForApi(), CancellationToken.None);
result.Count.ShouldBe(2);
result.ShouldContain(new FillerPresetResponseModel(1, "Intro"));
result.ShouldContain(new FillerPresetResponseModel(2, "Outro"));
result.ShouldContain(new FillerPresetResponseModel(1, "Intro", FillerKind.PreRoll));
result.ShouldContain(new FillerPresetResponseModel(2, "Outro", FillerKind.PreRoll));
}
[Test]
public async Task GetAllFillerPresetsForApi_Should_Filter_By_FillerKind()
{
await SeedPreset(1, "Intro", FillerKind.PreRoll);
await SeedPreset(2, "Mid", FillerKind.MidRoll);
await SeedPreset(3, "Outro", FillerKind.PostRoll);
var handler = new GetAllFillerPresetsForApiHandler(_db.Factory);
List<FillerPresetResponseModel> result =
await handler.Handle(new GetAllFillerPresetsForApi(FillerKind.MidRoll), CancellationToken.None);
result.Count.ShouldBe(1);
result.ShouldContain(new FillerPresetResponseModel(2, "Mid", FillerKind.MidRoll));
}
[Test]
@@ -47,14 +63,14 @@ public class FillerPresetHandlerTests
result.ShouldBeEmpty();
}
private async Task SeedPreset(int id, string name)
private async Task SeedPreset(int id, string name, FillerKind fillerKind = FillerKind.PreRoll)
{
await using TvContext context = _db.CreateContext();
context.FillerPresets.Add(new FillerPreset
{
Id = id,
Name = name,
FillerKind = FillerKind.PreRoll,
FillerKind = fillerKind,
FillerMode = FillerMode.Duration,
CollectionType = CollectionType.Collection
});
@@ -0,0 +1,224 @@
using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.Libraries;
using ErsatzTV.Application.MediaSources;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using ThreadingChannel = System.Threading.Channels.Channel;
namespace ErsatzTV.Tests.Application.Libraries;
[TestFixture]
public class QueueLibraryScanByLibraryIdHandlerTests
{
private InMemoryTvContext _db = null!;
[SetUp]
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
[Test]
public async Task Handle_Should_Return_NotFound_When_Library_Missing()
{
var locker = Substitute.For<IEntityLocker>();
Channel<IScannerBackgroundServiceRequest> channel = ThreadingChannel.CreateUnbounded<IScannerBackgroundServiceRequest>();
QueueLibraryScanByLibraryIdHandler handler = CreateHandler(locker, channel.Writer);
QueueLibraryScanResult result = await handler.Handle(
new QueueLibraryScanByLibraryId(9999),
CancellationToken.None);
result.ShouldBe(QueueLibraryScanResult.NotFound);
locker.DidNotReceive().LockLibrary(Arg.Any<int>());
}
[Test]
public async Task Handle_Should_Return_SyncDisabled_When_Item_Sync_Off()
{
int libraryId = await SeedSyncDisabledPlexLibrary();
var locker = Substitute.For<IEntityLocker>();
Channel<IScannerBackgroundServiceRequest> channel = ThreadingChannel.CreateUnbounded<IScannerBackgroundServiceRequest>();
QueueLibraryScanByLibraryIdHandler handler = CreateHandler(locker, channel.Writer);
QueueLibraryScanResult result = await handler.Handle(
new QueueLibraryScanByLibraryId(libraryId),
CancellationToken.None);
result.ShouldBe(QueueLibraryScanResult.SyncDisabled);
locker.DidNotReceive().LockLibrary(Arg.Any<int>());
}
[Test]
public async Task Handle_Should_Queue_And_Enqueue_When_Lock_Acquired()
{
int libraryId = await SeedLocalLibrary();
var locker = Substitute.For<IEntityLocker>();
locker.LockLibrary(libraryId).Returns(true);
Channel<IScannerBackgroundServiceRequest> channel = ThreadingChannel.CreateUnbounded<IScannerBackgroundServiceRequest>();
QueueLibraryScanByLibraryIdHandler handler = CreateHandler(locker, channel.Writer);
QueueLibraryScanResult result = await handler.Handle(
new QueueLibraryScanByLibraryId(libraryId),
CancellationToken.None);
result.ShouldBe(QueueLibraryScanResult.Queued);
locker.Received(1).LockLibrary(libraryId);
locker.DidNotReceive().UnlockLibrary(Arg.Any<int>());
channel.Reader.TryRead(out IScannerBackgroundServiceRequest? request).ShouldBeTrue();
request.ShouldBeOfType<ForceScanLocalLibrary>();
}
[Test]
public async Task Handle_Should_Return_AlreadyScanning_When_Lock_Not_Acquired()
{
int libraryId = await SeedLocalLibrary();
var locker = Substitute.For<IEntityLocker>();
locker.LockLibrary(libraryId).Returns(false);
Channel<IScannerBackgroundServiceRequest> channel = ThreadingChannel.CreateUnbounded<IScannerBackgroundServiceRequest>();
QueueLibraryScanByLibraryIdHandler handler = CreateHandler(locker, channel.Writer);
QueueLibraryScanResult result = await handler.Handle(
new QueueLibraryScanByLibraryId(libraryId),
CancellationToken.None);
result.ShouldBe(QueueLibraryScanResult.AlreadyScanning);
channel.Reader.TryRead(out _).ShouldBeFalse();
locker.DidNotReceive().UnlockLibrary(Arg.Any<int>());
}
[Test]
public async Task Two_Concurrent_Scans_Should_Be_Queued_Then_AlreadyScanning()
{
int libraryId = await SeedLocalLibrary();
var locker = Substitute.For<IEntityLocker>();
// first acquire wins, second loses (a scan is already in progress)
locker.LockLibrary(libraryId).Returns(true, false);
Channel<IScannerBackgroundServiceRequest> channel = ThreadingChannel.CreateUnbounded<IScannerBackgroundServiceRequest>();
QueueLibraryScanByLibraryIdHandler handler = CreateHandler(locker, channel.Writer);
QueueLibraryScanResult first = await handler.Handle(
new QueueLibraryScanByLibraryId(libraryId),
CancellationToken.None);
QueueLibraryScanResult second = await handler.Handle(
new QueueLibraryScanByLibraryId(libraryId),
CancellationToken.None);
first.ShouldBe(QueueLibraryScanResult.Queued);
second.ShouldBe(QueueLibraryScanResult.AlreadyScanning);
}
[Test]
public async Task Handle_Should_Unlock_And_Rethrow_When_Enqueue_Throws()
{
int libraryId = await SeedLocalLibrary();
var locker = Substitute.For<IEntityLocker>();
locker.LockLibrary(libraryId).Returns(true);
Channel<IScannerBackgroundServiceRequest> channel = ThreadingChannel.CreateUnbounded<IScannerBackgroundServiceRequest>();
// a completed writer makes WriteAsync throw ChannelClosedException, simulating a failed enqueue
channel.Writer.Complete();
QueueLibraryScanByLibraryIdHandler handler = CreateHandler(locker, channel.Writer);
await Should.ThrowAsync<ChannelClosedException>(() =>
handler.Handle(new QueueLibraryScanByLibraryId(libraryId), CancellationToken.None));
locker.Received(1).UnlockLibrary(libraryId);
}
[Test]
public async Task Handle_Should_Unlock_When_Enqueue_Canceled()
{
int libraryId = await SeedLocalLibrary();
var locker = Substitute.For<IEntityLocker>();
locker.LockLibrary(libraryId).Returns(true);
// a writer whose enqueue is aborted (e.g. request cancelled after the lock was taken)
var writer = Substitute.For<ChannelWriter<IScannerBackgroundServiceRequest>>();
writer.WriteAsync(Arg.Any<IScannerBackgroundServiceRequest>(), Arg.Any<CancellationToken>())
.Returns(ValueTask.FromException(new OperationCanceledException()));
QueueLibraryScanByLibraryIdHandler handler = CreateHandler(locker, writer);
await Should.ThrowAsync<OperationCanceledException>(() =>
handler.Handle(new QueueLibraryScanByLibraryId(libraryId), CancellationToken.None));
locker.Received(1).UnlockLibrary(libraryId);
}
private QueueLibraryScanByLibraryIdHandler CreateHandler(
IEntityLocker locker,
ChannelWriter<IScannerBackgroundServiceRequest> writer) =>
new(
_db.Factory,
locker,
writer,
NullLogger<QueueLibraryScanByLibraryIdHandler>.Instance);
private async Task<int> SeedLocalLibrary()
{
await using TvContext context = _db.CreateContext();
var source = new LocalMediaSource
{
Libraries =
[
new LocalLibrary
{
Name = "Local Movies",
MediaKind = LibraryMediaKind.Movies,
Paths = []
}
]
};
await context.LocalMediaSources.AddAsync(source);
await context.SaveChangesAsync();
return source.Libraries[0].Id;
}
private async Task<int> SeedSyncDisabledPlexLibrary()
{
await using TvContext context = _db.CreateContext();
var source = new PlexMediaSource
{
ServerName = "Plex Server",
ProductVersion = "1",
Platform = "Linux",
PlatformVersion = "1",
ClientIdentifier = "plex",
Connections = [],
PathReplacements = [],
Libraries =
[
new PlexLibrary
{
Name = "Plex Movies",
MediaKind = LibraryMediaKind.Movies,
Key = "1",
ShouldSyncItems = false,
Paths = []
}
]
};
await context.PlexMediaSources.AddAsync(source);
await context.SaveChangesAsync();
return source.Libraries[0].Id;
}
}
@@ -288,6 +288,10 @@ public class GetLibraryBrowseItemsHandlerTests
result.Page[1].MediaItemId.ShouldBe(711);
result.Page[2].MediaItemId.ShouldBe(713);
// #220: every episode carries its parent season id so the SPA can route to the season
// detail page and anchor to the episode (`/app/media/seasons/{seasonId}#episode-{id}`).
result.Page.ShouldAllBe(p => p.SeasonId == 701);
await _searchIndex.DidNotReceive().Search(
Arg.Any<string>(),
Arg.Any<string>(),
@@ -297,6 +301,32 @@ public class GetLibraryBrowseItemsHandlerTests
Arg.Any<CancellationToken>());
}
[Test]
public async Task Handle_Should_Leave_SeasonId_Null_For_Non_Episode_Kinds()
{
await SeedLibraryGraph();
_searchIndex.Search(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<List<string>>(),
Arg.Any<CancellationToken>())
.Returns(new SearchResult(
[
new SearchItem(LuceneSearchIndex.ShowType, 20),
new SearchItem(LuceneSearchIndex.MovieType, 10)
],
2));
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
new GetLibraryBrowseItems("", null, null, 0, 10),
CancellationToken.None);
result.Page.ShouldAllBe(p => p.SeasonId == null);
}
[Test]
public async Task Handle_Should_Browse_Music_Videos_For_A_Specific_Artist_By_ParentId()
{
@@ -0,0 +1,138 @@
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.MediaCollections;
[TestFixture]
public class GetCollectionItemsHandlerTests
{
private InMemoryTvContext _db = null!;
[SetUp]
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
[Test]
public async Task Handle_Should_Return_NotFound_For_Missing_Collection()
{
var handler = new GetCollectionItemsHandler(_db.Factory);
Either<BaseError, PagedLibraryBrowseItemsResponseModel> result =
await handler.Handle(new GetCollectionItems(999, 0, 100), CancellationToken.None);
result.IsLeft.ShouldBeTrue();
}
[Test]
public async Task Handle_Should_Order_By_Title_When_Custom_Order_Disabled()
{
await SeedCollectionGraph(useCustomPlaybackOrder: false);
var handler = new GetCollectionItemsHandler(_db.Factory);
Either<BaseError, PagedLibraryBrowseItemsResponseModel> result =
await handler.Handle(new GetCollectionItems(10, 0, 100), CancellationToken.None);
PagedLibraryBrowseItemsResponseModel page = result.RightToSeq().Single();
page.TotalCount.ShouldBe(3);
page.Page.Select(i => i.Title).ShouldBe(["Alpha", "Beta", "Zeta"]);
}
[Test]
public async Task Handle_Should_Order_By_CustomIndex_With_Nulls_Last_When_Custom_Order_Enabled()
{
await SeedCollectionGraph(useCustomPlaybackOrder: true);
var handler = new GetCollectionItemsHandler(_db.Factory);
Either<BaseError, PagedLibraryBrowseItemsResponseModel> result =
await handler.Handle(new GetCollectionItems(10, 0, 100), CancellationToken.None);
PagedLibraryBrowseItemsResponseModel page = result.RightToSeq().Single();
// Zeta has CustomIndex 0, Alpha has CustomIndex 1, Beta has no CustomIndex (sorts last).
page.Page.Select(i => i.Title).ShouldBe(["Zeta", "Alpha", "Beta"]);
}
private async Task SeedCollectionGraph(bool useCustomPlaybackOrder)
{
await using TvContext context = _db.CreateContext();
var library = new LocalLibrary
{
Id = 1,
Name = "Library",
MediaKind = LibraryMediaKind.Movies,
Paths = []
};
var path = new LibraryPath
{
Id = 1,
Path = "/media",
Library = library,
LibraryFolders = [],
MediaItems = []
};
library.Paths.Add(path);
Movie alpha = MakeMovie(101, path, "Alpha");
Movie beta = MakeMovie(102, path, "Beta");
Movie zeta = MakeMovie(103, path, "Zeta");
var collection = new Collection
{
Id = 10,
Name = "Manual",
UseCustomPlaybackOrder = useCustomPlaybackOrder,
MediaItems = [],
CollectionItems =
[
new CollectionItem { MediaItemId = 103, CustomIndex = 0 },
new CollectionItem { MediaItemId = 101, CustomIndex = 1 },
new CollectionItem { MediaItemId = 102, CustomIndex = null }
],
MultiCollections = [],
MultiCollectionItems = []
};
context.LocalLibraries.Add(library);
context.Movies.AddRange(alpha, beta, zeta);
context.Collections.Add(collection);
await context.SaveChangesAsync();
}
private static Movie MakeMovie(int id, LibraryPath path, string title) =>
new()
{
Id = id,
LibraryPath = path,
Collections = [],
CollectionItems = [],
TraktListItems = [],
MovieMetadata =
[
new MovieMetadata
{
Title = title,
SortTitle = title,
Artwork = [],
Genres = [],
Tags = [],
Studios = [],
Actors = [],
Guids = [],
Subtitles = [],
Directors = [],
Writers = []
}
],
MediaVersions = [new MediaVersion { Duration = TimeSpan.FromMinutes(30) }]
};
}
@@ -0,0 +1,75 @@
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.MediaCollections;
[TestFixture]
public class PlaylistGroupHandlerTests : MediaCollectionHandlerTestBase
{
[Test]
public async Task Rename_Should_Return_Error_When_Group_Missing()
{
var handler = new RenamePlaylistGroupHandler(Db.Factory);
Either<BaseError, PlaylistGroupViewModel> result =
await handler.Handle(new RenamePlaylistGroup(999, "Updated"), CancellationToken.None);
// The handler rejects a missing group; the API surfaces 404 for this case via the
// controller's existence pre-check (LanguageExtensions.Apply collapses the handler's
// NotFoundError to a plain BaseError, so the type is not asserted here).
result.IsLeft.ShouldBeTrue();
}
[Test]
public async Task Rename_Should_Return_BaseError_When_Group_Is_System()
{
await SeedPlaylistGroup(5, "System", isSystem: true);
var handler = new RenamePlaylistGroupHandler(Db.Factory);
Either<BaseError, PlaylistGroupViewModel> result =
await handler.Handle(new RenamePlaylistGroup(5, "Renamed"), CancellationToken.None);
// Plain BaseError (not NotFoundError) so ApiResults maps it to 422, not 404.
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldBe("Cannot rename system playlist group");
}
[Test]
public async Task Rename_Should_Succeed_For_Non_System_Group()
{
await SeedPlaylistGroup(6, "Kids", isSystem: false);
var handler = new RenamePlaylistGroupHandler(Db.Factory);
Either<BaseError, PlaylistGroupViewModel> result =
await handler.Handle(new RenamePlaylistGroup(6, "Family"), CancellationToken.None);
result.IsRight.ShouldBeTrue();
result.Match(Left: _ => "", Right: vm => vm.Name).ShouldBe("Family");
}
private async Task SeedPlaylistGroup(int id, string name, bool isSystem)
{
await using TvContext context = Db.CreateContext();
context.PlaylistGroups.Add(new PlaylistGroup
{
Id = id,
Name = name,
IsSystem = isSystem,
Playlists = []
});
await context.SaveChangesAsync();
}
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
}
@@ -0,0 +1,139 @@
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Tests.Application.MediaCollections;
[TestFixture]
public class PlaylistHandlerTests : MediaCollectionHandlerTestBase
{
[TestCase(CollectionType.Artist)]
[TestCase(CollectionType.MusicVideo)]
[TestCase(CollectionType.OtherVideo)]
[TestCase(CollectionType.Song)]
[TestCase(CollectionType.Image)]
[TestCase(CollectionType.RemoteStream)]
public async Task AddItems_Should_Return_ValidationError_When_Previously_Unvalidated_Kind_Missing(
CollectionType collectionType)
{
await SeedPlaylist(1);
var handler = MakeHandler();
Either<BaseError, Unit> result =
await handler.Handle(MakeAddItems(1, collectionType, [999]), CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("Media item does not exist");
}
[TestCase(CollectionType.Movie)]
[TestCase(CollectionType.TelevisionShow)]
[TestCase(CollectionType.TelevisionSeason)]
[TestCase(CollectionType.Episode)]
public async Task AddItems_Should_Return_ValidationError_When_Existing_Validated_Kind_Missing(
CollectionType collectionType)
{
await SeedPlaylist(1);
var handler = MakeHandler();
Either<BaseError, Unit> result =
await handler.Handle(MakeAddItems(1, collectionType, [999]), CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
}
[Test]
public async Task AddItems_Should_Add_Valid_RemoteStream_Item_To_Playlist()
{
await SeedPlaylist(1);
await SeedRemoteStream(50);
var handler = MakeHandler();
Either<BaseError, Unit> result =
await handler.Handle(MakeAddItems(1, CollectionType.RemoteStream, [50]), CancellationToken.None);
RightOf(result);
await using TvContext context = Db.CreateContext();
Playlist playlist = await context.Playlists.FindAsync(1);
List<PlaylistItem> items = context.Entry(playlist!).Collection(p => p.Items).Query().ToList();
items.ShouldHaveSingleItem();
items[0].CollectionType.ShouldBe(CollectionType.RemoteStream);
items[0].MediaItemId.ShouldBe(50);
}
private AddItemsToPlaylistHandler MakeHandler()
{
IMovieRepository movieRepository = Substitute.For<IMovieRepository>();
movieRepository.AllMoviesExist(Arg.Any<List<int>>()).Returns(true);
ITelevisionRepository televisionRepository = Substitute.For<ITelevisionRepository>();
televisionRepository.AllShowsExist(Arg.Any<List<int>>()).Returns(true);
televisionRepository.AllSeasonsExist(Arg.Any<List<int>>()).Returns(true);
televisionRepository.AllEpisodesExist(Arg.Any<List<int>>()).Returns(true);
return new AddItemsToPlaylistHandler(Db.Factory, movieRepository, televisionRepository);
}
private async Task SeedPlaylist(int id, string name = "Playlist")
{
await using TvContext context = Db.CreateContext();
context.Playlists.Add(new Playlist { Id = id, Name = name, Items = [] });
await context.SaveChangesAsync();
}
private async Task SeedRemoteStream(int id)
{
await using TvContext context = Db.CreateContext();
context.RemoteStreams.Add(new RemoteStream
{
Id = id,
RemoteStreamMetadata = []
});
await context.SaveChangesAsync();
}
private static AddItemsToPlaylist MakeAddItems(int playlistId, CollectionType collectionType, List<int> ids)
{
List<int> movieIds = collectionType == CollectionType.Movie ? ids : [];
List<int> showIds = collectionType == CollectionType.TelevisionShow ? ids : [];
List<int> seasonIds = collectionType == CollectionType.TelevisionSeason ? ids : [];
List<int> episodeIds = collectionType == CollectionType.Episode ? ids : [];
List<int> artistIds = collectionType == CollectionType.Artist ? ids : [];
List<int> musicVideoIds = collectionType == CollectionType.MusicVideo ? ids : [];
List<int> otherVideoIds = collectionType == CollectionType.OtherVideo ? ids : [];
List<int> songIds = collectionType == CollectionType.Song ? ids : [];
List<int> imageIds = collectionType == CollectionType.Image ? ids : [];
List<int> remoteStreamIds = collectionType == CollectionType.RemoteStream ? ids : [];
return new AddItemsToPlaylist(
playlistId,
movieIds,
showIds,
seasonIds,
episodeIds,
artistIds,
musicVideoIds,
otherVideoIds,
songIds,
imageIds,
remoteStreamIds);
}
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
private static TR RightOf<TR>(Either<BaseError, TR> either) =>
either.Match(Left: e => throw new AssertionException($"Expected a Right result, got: {e.Value}"), Right: v => v);
}
@@ -0,0 +1,206 @@
using ErsatzTV.Application;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Tests.Application.MediaCollections;
[TestFixture]
public class RerunCollectionHandlerTests : MediaCollectionHandlerTestBase
{
[Test]
public async Task Create_Should_Return_Error_When_Collection_Missing()
{
var handler = new CreateRerunCollectionHandler(Db.Factory);
Either<BaseError, RerunCollectionViewModel> result = await handler.Handle(
MakeCreate(CollectionType.Collection, collection: new MediaCollectionViewModel(
CollectionType.Collection,
999,
string.Empty,
false,
MediaItemState.Normal)),
CancellationToken.None);
BaseError error = LeftOf(result);
// deliberate 422-not-404: the missing entity is a request-body FK, not the route resource
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("Collection does not exist");
}
[Test]
public async Task Create_Should_Return_Error_When_CollectionType_Unsupported()
{
var handler = new CreateRerunCollectionHandler(Db.Factory);
// reachable via the legacy Blazor editor (no IsSupportedSelectionType pre-check)
Either<BaseError, RerunCollectionViewModel> result = await handler.Handle(
MakeCreate(CollectionType.Playlist),
CancellationToken.None);
BaseError error = LeftOf(result);
error.Value.ShouldContain("Unsupported collection type");
}
[Test]
public async Task Create_Should_Return_Error_When_MediaItem_Missing()
{
var handler = new CreateRerunCollectionHandler(Db.Factory);
Either<BaseError, RerunCollectionViewModel> result = await handler.Handle(
MakeCreate(CollectionType.Movie, mediaItem: new NamedMediaItemViewModel(999, string.Empty)),
CancellationToken.None);
BaseError error = LeftOf(result);
error.Value.ShouldContain("Movie does not exist");
}
[Test]
public async Task Create_Should_Succeed_When_Collection_Exists()
{
await SeedCollection(1);
var handler = new CreateRerunCollectionHandler(Db.Factory);
Either<BaseError, RerunCollectionViewModel> result = await handler.Handle(
MakeCreate(CollectionType.Collection, collection: new MediaCollectionViewModel(
CollectionType.Collection,
1,
string.Empty,
false,
MediaItemState.Normal)),
CancellationToken.None);
RerunCollectionViewModel vm = RightOf(result);
vm.CollectionType.ShouldBe(CollectionType.Collection);
}
[Test]
public async Task Create_Should_Return_Error_When_MediaItem_Type_Mismatch()
{
await SeedShow(1);
var handler = new CreateRerunCollectionHandler(Db.Factory);
// request claims Movie, but the seeded id (1) is actually a Show
Either<BaseError, RerunCollectionViewModel> result = await handler.Handle(
MakeCreate(CollectionType.Movie, mediaItem: new NamedMediaItemViewModel(1, string.Empty)),
CancellationToken.None);
BaseError error = LeftOf(result);
error.Value.ShouldContain("Movie does not exist");
}
[Test]
public async Task Update_Should_Return_Error_When_SmartCollection_Missing()
{
await SeedRerunCollection(1, CollectionType.SmartCollection);
IMediaCollectionRepository mediaCollectionRepository = Substitute.For<IMediaCollectionRepository>();
mediaCollectionRepository.PlayoutIdsUsingRerunCollection(Arg.Any<int>()).Returns([]);
var handler = new UpdateRerunCollectionHandler(
Db.Factory,
mediaCollectionRepository,
Worker);
Either<BaseError, Unit> result = await handler.Handle(
MakeUpdate(
1,
CollectionType.SmartCollection,
smartCollection: new SmartCollectionViewModel(999, string.Empty, string.Empty)),
CancellationToken.None);
BaseError error = LeftOf(result);
error.Value.ShouldContain("Smart collection does not exist");
}
[Test]
public async Task Update_Should_Succeed_When_Selection_Exists()
{
await SeedRerunCollection(1, CollectionType.SmartCollection);
await SeedSmartCollection(2);
IMediaCollectionRepository mediaCollectionRepository = Substitute.For<IMediaCollectionRepository>();
mediaCollectionRepository.PlayoutIdsUsingRerunCollection(Arg.Any<int>()).Returns([]);
var handler = new UpdateRerunCollectionHandler(
Db.Factory,
mediaCollectionRepository,
Worker);
Either<BaseError, Unit> result = await handler.Handle(
MakeUpdate(
1,
CollectionType.SmartCollection,
smartCollection: new SmartCollectionViewModel(2, string.Empty, string.Empty)),
CancellationToken.None);
RightOf(result);
}
private async Task SeedShow(int id)
{
await using TvContext context = Db.CreateContext();
context.Shows.Add(new Show { Id = id });
await context.SaveChangesAsync();
}
private async Task SeedRerunCollection(int id, CollectionType collectionType, string name = "Rerun")
{
await using TvContext context = Db.CreateContext();
context.RerunCollections.Add(new RerunCollection
{
Id = id,
Name = name,
CollectionType = collectionType,
FirstRunPlaybackOrder = PlaybackOrder.Chronological,
RerunPlaybackOrder = PlaybackOrder.Chronological
});
await context.SaveChangesAsync();
}
private static CreateRerunCollection MakeCreate(
CollectionType collectionType,
MediaCollectionViewModel collection = null,
MultiCollectionViewModel multiCollection = null,
SmartCollectionViewModel smartCollection = null,
NamedMediaItemViewModel mediaItem = null) =>
new(
"Rerun " + Guid.NewGuid(),
collectionType,
collection,
multiCollection,
smartCollection,
mediaItem,
PlaybackOrder.Chronological,
PlaybackOrder.Chronological);
private static UpdateRerunCollection MakeUpdate(
int rerunCollectionId,
CollectionType collectionType,
MediaCollectionViewModel collection = null,
MultiCollectionViewModel multiCollection = null,
SmartCollectionViewModel smartCollection = null,
NamedMediaItemViewModel mediaItem = null) =>
new(
rerunCollectionId,
"Rerun " + Guid.NewGuid(),
collectionType,
collection,
multiCollection,
smartCollection,
mediaItem,
PlaybackOrder.Chronological,
PlaybackOrder.Chronological);
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
private static TR RightOf<TR>(Either<BaseError, TR> either) =>
either.Match(Left: e => throw new AssertionException($"Expected a Right result, got: {e.Value}"), Right: v => v);
}
@@ -0,0 +1,450 @@
using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.ProgramSchedules;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Scheduling;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using MediatR;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.ProgramSchedules;
/// <summary>
/// Release gate for issue #126: proves the flat <see cref="ScheduleItemResponseModel" /> produced by
/// the GET endpoint carries enough information to reconstruct an identical PUT (ReplaceProgramScheduleItems)
/// with no loss — a GET → map → PUT → GET fixed point across every subtype and field family. Also documents
/// the deliberate <c>EnforceProperties</c> normalization (shuffle → Dynamic/One/None).
/// </summary>
[TestFixture]
public class ScheduleItemResponseRoundTripTests
{
private InMemoryTvContext _db = null!;
private ChannelWriter<IBackgroundServiceRequest> _worker = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_worker = System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>().Writer;
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
[Test]
public async Task Get_Map_Replace_Get_Should_Be_Lossless_Across_All_Subtypes()
{
int scheduleId = await SeedScheduleAndReferences(shuffleScheduleItems: false);
var replaceHandler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker);
// Arrange: establish the initial items through the real write path.
Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> seeded =
await replaceHandler.Handle(new ReplaceProgramScheduleItems(scheduleId, BuildSeedItems()), CancellationToken.None);
seeded.IsRight.ShouldBeTrue(seeded.LeftToSeq().HeadOrNone().Match(e => e.Value, () => "unknown"));
// Act: GET (with durations) → flat DTO envelope A
ScheduleItemsResponseModel envelopeA = await GetItemsEnvelope(scheduleId);
envelopeA.Items.Count.ShouldBe(6);
// Map every response item back into a Replace command exactly as the request mapping would, then PUT.
// Reconstruct in the item's own (stable) Index order, mirroring how the SPA re-submits the ordered list.
List<ReplaceProgramScheduleItem> reconstructed = envelopeA.Items
.OrderBy(item => item.Index)
.Select((item, index) => ToReplaceCommand(item, index))
.ToList();
Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> replaced =
await replaceHandler.Handle(new ReplaceProgramScheduleItems(scheduleId, reconstructed), CancellationToken.None);
replaced.IsRight.ShouldBeTrue(replaced.LeftToSeq().HeadOrNone().Match(e => e.Value, () => "unknown"));
// GET again → envelope B; A and B must be semantically identical (ignoring regenerated row ids).
ScheduleItemsResponseModel envelopeB = await GetItemsEnvelope(scheduleId);
envelopeB.Items.Count.ShouldBe(envelopeA.Items.Count);
envelopeB.TotalDurationEstimate.ShouldBe(envelopeA.TotalDurationEstimate);
// The GET does not guarantee row order, so pair items up by their (stable) Index before comparing.
List<ScheduleItemResponseModel> orderedA = envelopeA.Items.OrderBy(i => i.Index).ToList();
List<ScheduleItemResponseModel> orderedB = envelopeB.Items.OrderBy(i => i.Index).ToList();
for (var i = 0; i < orderedA.Count; i++)
{
AssertSemanticallyEqual(orderedA[i], orderedB[i]);
}
// Spot-check that the display/hydration fields actually populated (proves the mapper is non-vacuous).
ScheduleItemResponseModel multiple = envelopeA.Items.Single(i => i.PlayoutMode == PlayoutMode.Multiple);
multiple.CollectionName.ShouldBe("Prime Collection");
multiple.MultipleMode.ShouldBe(MultipleMode.Count);
multiple.MultipleCount.ShouldBe("2 + 1");
multiple.FillWithGroupMode.ShouldBe(FillWithGroupMode.FillWithShuffledGroups);
multiple.PreRollFillerId.ShouldBe(1);
multiple.PreRollFillerName.ShouldBe("PreRoll");
multiple.FallbackFillerId.ShouldBe(5);
multiple.WatermarkIds.ShouldBe([1, 2]);
multiple.Watermarks.Select(w => w.Name).ShouldBe(["WM One", "WM Two"]);
multiple.GraphicsElementIds.ShouldBe([1, 2]);
multiple.GraphicsElements.Select(g => g.Id).ShouldBe([1, 2]);
multiple.GraphicsElements.Select(g => g.Name).ShouldAllBe(n => !string.IsNullOrWhiteSpace(n));
ScheduleItemResponseModel one = envelopeA.Items.Single(i =>
i.PlayoutMode == PlayoutMode.One && i.CollectionType == CollectionType.Collection);
one.StartType.ShouldBe(StartType.Fixed);
one.StartTime.ShouldBe(TimeSpan.FromHours(20));
one.FixedStartTimeBehavior.ShouldBe(FixedStartTimeBehavior.Flexible);
one.CustomTitle.ShouldBe("News Hour");
one.PreferredAudioLanguageCode.ShouldBe("eng");
one.PreferredAudioTitle.ShouldBe("Director Commentary");
one.PreferredSubtitleLanguageCode.ShouldBe("fra");
one.SubtitleMode.ShouldBe(ChannelSubtitleMode.Any);
ScheduleItemResponseModel duration = envelopeA.Items.Single(i => i.PlayoutMode == PlayoutMode.Duration);
duration.SmartCollectionName.ShouldBe("Smart Picks");
duration.PlayoutDuration.ShouldBe(TimeSpan.FromMinutes(45));
duration.TailMode.ShouldBe(TailMode.Filler);
duration.TailFillerId.ShouldBe(4);
duration.PlaybackOrder.ShouldBe(PlaybackOrder.Marathon);
duration.MarathonGroupBy.ShouldBe(MarathonGroupBy.Show);
duration.MarathonBatchSize.ShouldBe(4);
// Seeded with DiscardToFillAttempts = 5, but the write path's FixDiscardToFillAttempts zeroes
// it for any order other than Random/Shuffle (Marathon here) — a deliberate server-side
// normalization. Pinning it here keeps the lossless round-trip honest: A already holds 0, so
// the re-submit maps 0 → 0 and B stays equal.
duration.DiscardToFillAttempts.ShouldBe(0);
ScheduleItemResponseModel flood = envelopeA.Items.Single(i => i.PlayoutMode == PlayoutMode.Flood);
flood.PlaylistName.ShouldBe("My Playlist");
flood.PlaylistGroupId.ShouldBe(9);
flood.MarathonShuffleGroups.ShouldBeTrue();
ScheduleItemResponseModel rerun = envelopeA.Items.Single(i => i.CollectionType == CollectionType.RerunFirstRun);
rerun.RerunCollectionName.ShouldBe("Rerun Bin");
ScheduleItemResponseModel search = envelopeA.Items.Single(i => i.CollectionType == CollectionType.SearchQuery);
search.SearchQuery.ShouldBe("genre:comedy");
search.Name.ShouldBe("Comedy Search");
}
[Test]
public async Task Get_Should_Apply_EnforceProperties_Normalization_When_Shuffle_Enabled()
{
int scheduleId = await SeedScheduleAndReferences(shuffleScheduleItems: true);
var replaceHandler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker);
Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> seeded = await replaceHandler.Handle(
new ReplaceProgramScheduleItems(
scheduleId,
[
// Fixed-start Flood item on a plain collection.
MakeReplace(0, PlayoutMode.Flood, CollectionType.Collection) with
{
StartTime = TimeSpan.FromHours(6),
CollectionId = 1,
PlaybackOrder = PlaybackOrder.Chronological
},
// Playlist item carrying a playback order that must be normalized away.
MakeReplace(1, PlayoutMode.One, CollectionType.Playlist) with
{
PlaylistId = 1,
PlaybackOrder = PlaybackOrder.Chronological
}
]),
CancellationToken.None);
seeded.IsRight.ShouldBeTrue();
List<ScheduleItemResponseModel> items = (await GetItemsEnvelope(scheduleId)).Items;
ScheduleItemResponseModel floodItem = items.Single(i => i.CollectionType == CollectionType.Collection);
// Flood → One and Fixed → Dynamic are the documented, deliberate rewrites when ShuffleScheduleItems is on.
floodItem.PlayoutMode.ShouldBe(PlayoutMode.One);
floodItem.StartType.ShouldBe(StartType.Dynamic);
ScheduleItemResponseModel playlistItem = items.Single(i => i.CollectionType == CollectionType.Playlist);
playlistItem.PlaybackOrder.ShouldBe(PlaybackOrder.None);
}
private async Task<ScheduleItemsResponseModel> GetItemsEnvelope(int scheduleId)
{
var itemsHandler = new GetProgramScheduleItemsHandler(_db.Factory);
IMediator mediator = Substitute.For<IMediator>();
mediator.Send(Arg.Any<GetProgramScheduleItems>(), Arg.Any<CancellationToken>())
.Returns(ci => itemsHandler.Handle((GetProgramScheduleItems)ci[0], (CancellationToken)ci[1]));
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
repo.GetItems(Arg.Any<int>()).Returns(new List<MediaItem>());
var withDurations = new GetProgramScheduleItemsWithDurationsHandler(mediator, repo);
ProgramScheduleItemsWithDurationViewModel vm =
await withDurations.Handle(new GetProgramScheduleItemsWithDurations(scheduleId), CancellationToken.None);
return ScheduleItemResponseMapper.ProjectToResponseModel(vm);
}
private static List<ReplaceProgramScheduleItem> BuildSeedItems() =>
[
// 0: Fixed-start One on a plain collection, full preferred-audio/subtitle + custom title.
MakeReplace(0, PlayoutMode.One, CollectionType.Collection) with
{
StartType = StartType.Fixed,
StartTime = TimeSpan.FromHours(20),
FixedStartTimeBehavior = FixedStartTimeBehavior.Flexible,
CollectionId = 1,
PlaybackOrder = PlaybackOrder.Chronological,
CustomTitle = "News Hour",
PreferredAudioLanguageCode = "eng",
PreferredAudioTitle = "Director Commentary",
PreferredSubtitleLanguageCode = "fra",
SubtitleMode = ChannelSubtitleMode.Any
},
// 1: Multiple (Count, expression string) on a different collection, group mode + all 5 fillers + 2 wm + 2 ge.
MakeReplace(1, PlayoutMode.Multiple, CollectionType.Collection) with
{
CollectionId = 2,
PlaybackOrder = PlaybackOrder.Shuffle,
FillWithGroupMode = FillWithGroupMode.FillWithShuffledGroups,
MultipleMode = MultipleMode.Count,
MultipleCount = "2 + 1",
PreRollFillerId = 1,
MidRollFillerId = 2,
PostRollFillerId = 3,
TailFillerId = 4,
FallbackFillerId = 5,
WatermarkIds = [1, 2],
GraphicsElementIds = [1, 2]
},
// 2: Duration on a smart collection, Marathon order + tail filler.
MakeReplace(2, PlayoutMode.Duration, CollectionType.SmartCollection) with
{
SmartCollectionId = 1,
PlayoutDuration = TimeSpan.FromMinutes(45),
TailMode = TailMode.Filler,
TailFillerId = 4,
DiscardToFillAttempts = 5,
PlaybackOrder = PlaybackOrder.Marathon,
MarathonGroupBy = MarathonGroupBy.Show,
MarathonShuffleGroups = true,
MarathonShuffleItems = true,
MarathonBatchSize = 4
},
// 3: Flood on a playlist (playback order None; MarathonShuffleGroups doubles as shuffle-playlist-items).
MakeReplace(3, PlayoutMode.Flood, CollectionType.Playlist) with
{
PlaylistId = 1,
PlaybackOrder = PlaybackOrder.None,
MarathonShuffleGroups = true
},
// 4: Rerun-first-run One item.
MakeReplace(4, PlayoutMode.One, CollectionType.RerunFirstRun) with
{
RerunCollectionId = 1,
PlaybackOrder = PlaybackOrder.None
},
// 5: SearchQuery One item.
MakeReplace(5, PlayoutMode.One, CollectionType.SearchQuery) with
{
SearchTitle = "Comedy Search",
SearchQuery = "genre:comedy",
PlaybackOrder = PlaybackOrder.Shuffle
}
];
private static ReplaceProgramScheduleItem MakeReplace(int index, PlayoutMode playoutMode, CollectionType collectionType) =>
new(
index,
StartType.Dynamic,
StartTime: null,
FixedStartTimeBehavior: null,
playoutMode,
collectionType,
CollectionId: null,
MultiCollectionId: null,
SmartCollectionId: null,
RerunCollectionId: null,
MediaItemId: null,
PlaylistId: null,
SearchTitle: null,
SearchQuery: null,
PlaybackOrder: PlaybackOrder.Shuffle,
MarathonGroupBy: MarathonGroupBy.None,
MarathonShuffleGroups: false,
MarathonShuffleItems: false,
MarathonBatchSize: null,
FillWithGroupMode: FillWithGroupMode.None,
MultipleMode: MultipleMode.Count,
MultipleCount: null,
PlayoutDuration: null,
TailMode: TailMode.None,
DiscardToFillAttempts: null,
CustomTitle: null,
GuideMode: GuideMode.Normal,
PreRollFillerId: null,
MidRollFillerId: null,
PostRollFillerId: null,
TailFillerId: null,
FallbackFillerId: null,
WatermarkIds: [],
GraphicsElementIds: [],
PreferredAudioLanguageCode: null,
PreferredAudioTitle: null,
PreferredSubtitleLanguageCode: null,
SubtitleMode: null);
// Mirrors the ScheduleItemRequest → ReplaceProgramScheduleItem controller mapping, but sourced from a
// response DTO (the SPA does this same field-for-field copy in TypeScript before a PUT).
private static ReplaceProgramScheduleItem ToReplaceCommand(ScheduleItemResponseModel r, int index) =>
new(
index,
r.StartType,
r.StartTime,
r.FixedStartTimeBehavior,
r.PlayoutMode,
r.CollectionType,
r.CollectionId,
r.MultiCollectionId,
r.SmartCollectionId,
r.RerunCollectionId,
r.MediaItemId,
r.PlaylistId,
r.SearchTitle,
r.SearchQuery,
r.PlaybackOrder,
r.MarathonGroupBy,
r.MarathonShuffleGroups,
r.MarathonShuffleItems,
r.MarathonBatchSize,
r.FillWithGroupMode,
r.MultipleMode ?? MultipleMode.Count,
r.MultipleCount,
r.PlayoutDuration,
r.TailMode ?? TailMode.None,
r.DiscardToFillAttempts,
r.CustomTitle,
r.GuideMode,
r.PreRollFillerId,
r.MidRollFillerId,
r.PostRollFillerId,
r.TailFillerId,
r.FallbackFillerId,
r.WatermarkIds,
r.GraphicsElementIds,
r.PreferredAudioLanguageCode,
r.PreferredAudioTitle,
r.PreferredSubtitleLanguageCode,
r.SubtitleMode);
private static void AssertSemanticallyEqual(ScheduleItemResponseModel a, ScheduleItemResponseModel b)
{
b.Index.ShouldBe(a.Index);
b.StartType.ShouldBe(a.StartType);
b.StartTime.ShouldBe(a.StartTime);
b.FixedStartTimeBehavior.ShouldBe(a.FixedStartTimeBehavior);
b.PlayoutMode.ShouldBe(a.PlayoutMode);
b.CollectionType.ShouldBe(a.CollectionType);
b.CollectionId.ShouldBe(a.CollectionId);
b.MultiCollectionId.ShouldBe(a.MultiCollectionId);
b.SmartCollectionId.ShouldBe(a.SmartCollectionId);
b.RerunCollectionId.ShouldBe(a.RerunCollectionId);
b.MediaItemId.ShouldBe(a.MediaItemId);
b.PlaylistId.ShouldBe(a.PlaylistId);
b.SearchTitle.ShouldBe(a.SearchTitle);
b.SearchQuery.ShouldBe(a.SearchQuery);
b.PlaybackOrder.ShouldBe(a.PlaybackOrder);
b.MarathonGroupBy.ShouldBe(a.MarathonGroupBy);
b.MarathonShuffleGroups.ShouldBe(a.MarathonShuffleGroups);
b.MarathonShuffleItems.ShouldBe(a.MarathonShuffleItems);
b.MarathonBatchSize.ShouldBe(a.MarathonBatchSize);
b.FillWithGroupMode.ShouldBe(a.FillWithGroupMode);
b.MultipleMode.ShouldBe(a.MultipleMode);
b.MultipleCount.ShouldBe(a.MultipleCount);
b.PlayoutDuration.ShouldBe(a.PlayoutDuration);
b.TailMode.ShouldBe(a.TailMode);
b.DiscardToFillAttempts.ShouldBe(a.DiscardToFillAttempts);
b.CustomTitle.ShouldBe(a.CustomTitle);
b.GuideMode.ShouldBe(a.GuideMode);
b.PreRollFillerId.ShouldBe(a.PreRollFillerId);
b.MidRollFillerId.ShouldBe(a.MidRollFillerId);
b.PostRollFillerId.ShouldBe(a.PostRollFillerId);
b.TailFillerId.ShouldBe(a.TailFillerId);
b.FallbackFillerId.ShouldBe(a.FallbackFillerId);
b.WatermarkIds.ShouldBe(a.WatermarkIds);
b.GraphicsElementIds.ShouldBe(a.GraphicsElementIds);
b.PreferredAudioLanguageCode.ShouldBe(a.PreferredAudioLanguageCode);
b.PreferredAudioTitle.ShouldBe(a.PreferredAudioTitle);
b.PreferredSubtitleLanguageCode.ShouldBe(a.PreferredSubtitleLanguageCode);
b.SubtitleMode.ShouldBe(a.SubtitleMode);
b.CollectionName.ShouldBe(a.CollectionName);
b.MultiCollectionName.ShouldBe(a.MultiCollectionName);
b.SmartCollectionName.ShouldBe(a.SmartCollectionName);
b.RerunCollectionName.ShouldBe(a.RerunCollectionName);
b.PlaylistName.ShouldBe(a.PlaylistName);
b.PlaylistGroupId.ShouldBe(a.PlaylistGroupId);
b.MediaItemName.ShouldBe(a.MediaItemName);
b.PreRollFillerName.ShouldBe(a.PreRollFillerName);
b.MidRollFillerName.ShouldBe(a.MidRollFillerName);
b.PostRollFillerName.ShouldBe(a.PostRollFillerName);
b.TailFillerName.ShouldBe(a.TailFillerName);
b.FallbackFillerName.ShouldBe(a.FallbackFillerName);
b.Watermarks.Select(w => (w.Id, w.Name)).ShouldBe(a.Watermarks.Select(w => (w.Id, w.Name)));
b.GraphicsElements.Select(g => (g.Id, g.Name)).ShouldBe(a.GraphicsElements.Select(g => (g.Id, g.Name)));
b.Name.ShouldBe(a.Name);
b.DurationEstimate.ShouldBe(a.DurationEstimate);
}
private async Task<int> SeedScheduleAndReferences(bool shuffleScheduleItems)
{
await using TvContext context = _db.CreateContext();
context.Collections.Add(new Collection { Id = 1, Name = "First Collection" });
context.Collections.Add(new Collection { Id = 2, Name = "Prime Collection" });
context.SmartCollections.Add(new SmartCollection { Id = 1, Name = "Smart Picks", Query = "*" });
context.RerunCollections.Add(new RerunCollection
{
Id = 1,
Name = "Rerun Bin",
CollectionType = CollectionType.Collection
});
context.PlaylistGroups.Add(new PlaylistGroup { Id = 9, Name = "Group" });
context.Playlists.Add(new Playlist { Id = 1, PlaylistGroupId = 9, Name = "My Playlist" });
context.ChannelWatermarks.Add(new ChannelWatermark { Id = 1, Name = "WM One" });
context.ChannelWatermarks.Add(new ChannelWatermark { Id = 2, Name = "WM Two" });
context.GraphicsElements.Add(new GraphicsElement { Id = 1, Name = "GE One", Path = "/ge/1", Kind = GraphicsElementKind.Image });
context.GraphicsElements.Add(new GraphicsElement { Id = 2, Name = "GE Two", Path = "/ge/2", Kind = GraphicsElementKind.Image });
AddFiller(context, 1, "PreRoll", FillerKind.PreRoll);
AddFiller(context, 2, "MidRoll", FillerKind.MidRoll);
AddFiller(context, 3, "PostRoll", FillerKind.PostRoll);
AddFiller(context, 4, "Tail", FillerKind.Tail);
AddFiller(context, 5, "Fallback", FillerKind.Fallback);
var schedule = new ProgramSchedule
{
Name = "Round Trip",
ShuffleScheduleItems = shuffleScheduleItems,
Items = [],
Playouts = [],
ProgramScheduleAlternates = []
};
context.ProgramSchedules.Add(schedule);
await context.SaveChangesAsync();
return schedule.Id;
}
private static void AddFiller(TvContext context, int id, string name, FillerKind kind) =>
context.FillerPresets.Add(new FillerPreset
{
Id = id,
Name = name,
FillerKind = kind,
FillerMode = FillerMode.Count,
Count = 1,
CollectionType = CollectionType.Collection
});
}
@@ -0,0 +1,167 @@
using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.ProgramSchedules;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Scheduling;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.ProgramSchedules;
/// <summary>
/// Regression tests for #229. The write path (Replace = PUT, Add = POST) used to project freshly-persisted
/// items whose watermark/graphics join rows only carry foreign-key ids — the Watermark / GraphicsElement navs
/// are null, and <c>Mapper.ProjectToViewModel</c> dereferences them without a guard, throwing an NRE that
/// surfaced as a 500. The pre-existing round-trip test masked this because the Replace handler returned a
/// <b>lazy</b> LanguageExt <c>Map</c> sequence and that test only checked <c>.IsRight</c> — it never enumerated
/// the projected items, so the deferred NRE never fired. These tests force enumeration of the write-path
/// response (exactly as the controller's <c>.ToList()</c> / single-item serialization does) and seed the
/// watermark/graphics entities through a <b>separate</b> context so the handler's fresh factory context has
/// nothing pre-tracked (no accidental change-tracker fix-up).
/// </summary>
[TestFixture]
public class ScheduleItemWriteProjectionTests
{
private InMemoryTvContext _db = null!;
private ChannelWriter<IBackgroundServiceRequest> _worker = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_worker = System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>().Writer;
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
[Test]
public async Task Replace_PutResponse_Should_Carry_Watermark_And_Graphics_Names()
{
int scheduleId = await SeedReferences();
var handler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker);
Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> result = await handler.Handle(
new ReplaceProgramScheduleItems(scheduleId, [ItemWithGraphics(0)]),
CancellationToken.None);
result.IsRight.ShouldBeTrue(result.LeftToSeq().HeadOrNone().Match(e => e.Value, () => "unknown"));
// Force enumeration exactly like the controller (`items.Select(...).ToList()`) — this is what threw the NRE.
List<ProgramScheduleItemViewModel> items = result.RightToSeq().Head().ToList();
ProgramScheduleItemViewModel item = items.Single();
item.Watermarks.Select(w => w.Name).ShouldBe(["WM One", "WM Two"]);
item.GraphicsElements.Count.ShouldBe(2);
item.GraphicsElements.Select(g => g.Name).ShouldAllBe(n => !string.IsNullOrWhiteSpace(n));
}
[Test]
public async Task Add_PostResponse_Should_Carry_Watermark_And_Graphics_Names()
{
int scheduleId = await SeedReferences();
var handler = new AddProgramScheduleItemHandler(_db.Factory, _worker);
Either<BaseError, ProgramScheduleItemViewModel> result = await handler.Handle(
AddItemWithGraphics(scheduleId),
CancellationToken.None);
result.IsRight.ShouldBeTrue(result.LeftToSeq().HeadOrNone().Match(e => e.Value, () => "unknown"));
ProgramScheduleItemViewModel item = result.RightToSeq().Head();
item.Watermarks.Select(w => w.Name).ShouldBe(["WM One", "WM Two"]);
item.GraphicsElements.Count.ShouldBe(2);
}
[Test]
public async Task Get_Should_Return_Items_In_Index_Order_Regardless_Of_Id_Order()
{
int scheduleId;
await using (TvContext ctx = _db.CreateContext())
{
var schedule = new ProgramSchedule
{
Name = "Shuffled", Items = [], Playouts = [], ProgramScheduleAlternates = []
};
ctx.ProgramSchedules.Add(schedule);
await ctx.SaveChangesAsync();
scheduleId = schedule.Id;
// Insert with ids deliberately out of Index order: id 10 -> Index 2, id 11 -> Index 0, id 12 -> Index 1.
// Before the fix the GET returned id order (2, 0, 1); after, it must return Index order (0, 1, 2).
ctx.ProgramScheduleItems.Add(OneItemRow(10, scheduleId, index: 2));
ctx.ProgramScheduleItems.Add(OneItemRow(11, scheduleId, index: 0));
ctx.ProgramScheduleItems.Add(OneItemRow(12, scheduleId, index: 1));
await ctx.SaveChangesAsync();
}
var getHandler = new GetProgramScheduleItemsHandler(_db.Factory);
List<ProgramScheduleItemViewModel> items =
await getHandler.Handle(new GetProgramScheduleItems(scheduleId), CancellationToken.None);
items.Select(i => i.Index).ShouldBe([0, 1, 2]);
}
private static ProgramScheduleItemOne OneItemRow(int id, int scheduleId, int index) =>
new()
{
Id = id,
ProgramScheduleId = scheduleId,
Index = index,
CollectionType = CollectionType.Collection,
CollectionId = 1,
PlaybackOrder = PlaybackOrder.Shuffle,
ProgramScheduleItemWatermarks = [],
ProgramScheduleItemGraphicsElements = []
};
private async Task<int> SeedReferences()
{
await using TvContext ctx = _db.CreateContext();
ctx.Collections.Add(new Collection { Id = 1, Name = "First Collection" });
ctx.ChannelWatermarks.Add(new ChannelWatermark { Id = 1, Name = "WM One" });
ctx.ChannelWatermarks.Add(new ChannelWatermark { Id = 2, Name = "WM Two" });
ctx.GraphicsElements.Add(new GraphicsElement { Id = 1, Name = "GE One", Path = "/ge/1", Kind = GraphicsElementKind.Image });
ctx.GraphicsElements.Add(new GraphicsElement { Id = 2, Name = "GE Two", Path = "/ge/2", Kind = GraphicsElementKind.Image });
var schedule = new ProgramSchedule
{
Name = "WM Schedule", Items = [], Playouts = [], ProgramScheduleAlternates = []
};
ctx.ProgramSchedules.Add(schedule);
await ctx.SaveChangesAsync();
return schedule.Id;
}
private static ReplaceProgramScheduleItem ItemWithGraphics(int index) =>
new(
index, StartType.Dynamic, StartTime: null, FixedStartTimeBehavior: null, PlayoutMode.One,
CollectionType.Collection, CollectionId: 1, MultiCollectionId: null, SmartCollectionId: null,
RerunCollectionId: null, MediaItemId: null, PlaylistId: null, SearchTitle: null, SearchQuery: null,
PlaybackOrder: PlaybackOrder.Shuffle, MarathonGroupBy: MarathonGroupBy.None,
MarathonShuffleGroups: false, MarathonShuffleItems: false, MarathonBatchSize: null,
FillWithGroupMode: FillWithGroupMode.None, MultipleMode: MultipleMode.Count, MultipleCount: null,
PlayoutDuration: null, TailMode: TailMode.None, DiscardToFillAttempts: null, CustomTitle: null,
GuideMode: GuideMode.Normal, PreRollFillerId: null, MidRollFillerId: null, PostRollFillerId: null,
TailFillerId: null, FallbackFillerId: null, WatermarkIds: [1, 2], GraphicsElementIds: [1, 2],
PreferredAudioLanguageCode: null, PreferredAudioTitle: null, PreferredSubtitleLanguageCode: null,
SubtitleMode: null);
private static AddProgramScheduleItem AddItemWithGraphics(int scheduleId) =>
new(
scheduleId, StartType.Dynamic, StartTime: null, FixedStartTimeBehavior: null, PlayoutMode.One,
CollectionType.Collection, CollectionId: 1, MultiCollectionId: null, SmartCollectionId: null,
RerunCollectionId: null, MediaItemId: null, PlaylistId: null, SearchTitle: null, SearchQuery: null,
PlaybackOrder: PlaybackOrder.Shuffle, MarathonGroupBy: MarathonGroupBy.None,
MarathonShuffleGroups: false, MarathonShuffleItems: false, MarathonBatchSize: null,
FillWithGroupMode: FillWithGroupMode.None, MultipleMode: MultipleMode.Count, MultipleCount: null,
PlayoutDuration: null, TailMode: TailMode.None, DiscardToFillAttempts: null, CustomTitle: null,
GuideMode: GuideMode.Normal, PreRollFillerId: null, MidRollFillerId: null, PostRollFillerId: null,
TailFillerId: null, FallbackFillerId: null, WatermarkIds: [1, 2], GraphicsElementIds: [1, 2],
PreferredAudioLanguageCode: null, PreferredAudioTitle: null, PreferredSubtitleLanguageCode: null,
SubtitleMode: null);
}
@@ -26,6 +26,7 @@ public class ApiErrorResponseMetadataTests
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkDelete), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkDelete), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(ChannelController), nameof(ChannelController.ResetPlayout), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelController), nameof(ChannelController.ResetPlayout), StatusCodes.Status409Conflict)]
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.GetDefault), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.SetDefault), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.SetDefault), StatusCodes.Status422UnprocessableEntity)]
@@ -113,22 +114,33 @@ public class ApiErrorResponseMetadataTests
[TestCase(typeof(DecoTemplateController), nameof(DecoTemplateController.Replace), StatusCodes.Status404NotFound)]
[TestCase(typeof(DecoTemplateController), nameof(DecoTemplateController.Replace), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.UpdateDefaultDeco), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.UpdateDefaultDeco), StatusCodes.Status409Conflict)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.UpdateDefaultDeco), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetById), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Create), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Create), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Update), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Update), StatusCodes.Status409Conflict)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Update), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Delete), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Delete), StatusCodes.Status409Conflict)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Delete), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetAlternateSchedules), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetAlternateSchedules), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.ReplaceAlternateSchedules), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.ReplaceAlternateSchedules), StatusCodes.Status409Conflict)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.ReplaceAlternateSchedules), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetTemplates), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetTemplates), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.ReplaceTemplates), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.ReplaceTemplates), StatusCodes.Status409Conflict)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.ReplaceTemplates), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.EraseItems), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.EraseItems), StatusCodes.Status409Conflict)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.EraseItems), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.EraseItemsAndHistory), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.EraseItemsAndHistory), StatusCodes.Status409Conflict)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.EraseItemsAndHistory), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.GetById), StatusCodes.Status404NotFound)]
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.AddOne), StatusCodes.Status404NotFound)]
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.AddOne), StatusCodes.Status401Unauthorized)]
@@ -139,6 +151,7 @@ public class ApiErrorResponseMetadataTests
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.DeleteProfileAsync), StatusCodes.Status404NotFound)]
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.DeleteProfileAsync), StatusCodes.Status401Unauthorized)]
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.DeleteProfileAsync), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(LibrariesController), nameof(LibrariesController.ScanShow), StatusCodes.Status404NotFound)]
public void Api_Error_Response_Metadata_Should_Document_ProblemDetails(
Type controllerType,
string actionName,
@@ -1,7 +1,10 @@
using System.Reflection;
using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.Artworks;
using ErsatzTV.Application.Channels;
using ErsatzTV.Application.Templates;
using Microsoft.AspNetCore.Mvc.Routing;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Controllers.Api.Requests;
@@ -10,6 +13,7 @@ using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Scheduling;
using LanguageExt;
using static LanguageExt.Prelude;
@@ -27,6 +31,7 @@ public class ChannelControllerTests
{
private IMediator _mediator = null!;
private Channel<IBackgroundServiceRequest> _workerChannel = null!;
private IEntityLocker _entityLocker = null!;
private ChannelController _controller = null!;
[SetUp]
@@ -34,7 +39,8 @@ public class ChannelControllerTests
{
_mediator = Substitute.For<IMediator>();
_workerChannel = System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>();
_controller = new ChannelController(_workerChannel.Writer, _mediator);
_entityLocker = Substitute.For<IEntityLocker>();
_controller = new ChannelController(_workerChannel.Writer, _mediator, _entityLocker);
}
[Test]
@@ -56,6 +62,40 @@ public class ChannelControllerTests
result.ShouldBe(new List<ChannelStateResponseModel> { state });
}
[Test]
public void MusicVideoCreditsTemplates_And_StreamSelectors_Routes_Are_Literal_And_Precede_Id()
{
MethodInfo mvct = typeof(ChannelController).GetMethod(nameof(ChannelController.GetMusicVideoCreditsTemplates))!;
mvct.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single().Template
.ShouldBe("/api/channels/music-video-credits-templates");
MethodInfo selectors = typeof(ChannelController).GetMethod(nameof(ChannelController.GetStreamSelectors))!;
selectors.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single().Template
.ShouldBe("/api/channels/stream-selectors");
}
[Test]
public async Task GetMusicVideoCreditsTemplates_Should_Return_Names()
{
_mediator.Send(Arg.Any<GetMusicVideoCreditTemplates>(), Arg.Any<CancellationToken>())
.Returns(["Default", "Scroll"]);
List<string> result = await _controller.GetMusicVideoCreditsTemplates(CancellationToken.None);
result.ShouldBe(["Default", "Scroll"]);
}
[Test]
public async Task GetStreamSelectors_Should_Return_Names()
{
_mediator.Send(Arg.Any<GetChannelStreamSelectors>(), Arg.Any<CancellationToken>())
.Returns(["selector-a.yml"]);
List<string> result = await _controller.GetStreamSelectors(CancellationToken.None);
result.ShouldBe(["selector-a.yml"]);
}
[Test]
public async Task Create_Should_Return_201_With_Location_And_Body()
{
@@ -374,6 +414,20 @@ public class ChannelControllerTests
buildPlayout.Mode.ShouldBe(expectedMode);
}
[Test]
public async Task ResetPlayout_Should_Return_409_When_Playout_Locked()
{
_mediator.Send(Arg.Any<GetPlayoutIdByChannelNumber>(), Arg.Any<CancellationToken>())
.Returns(Option<int>.Some(9));
_entityLocker.IsPlayoutLocked(9).Returns(true);
IActionResult result = await _controller.ResetPlayout("5", mode: null, CancellationToken.None);
var conflict = result.ShouldBeOfType<ConflictObjectResult>();
conflict.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
_workerChannel.Reader.TryRead(out _).ShouldBeFalse();
}
[Test]
public async Task ResetPlayout_Should_Honor_Explicit_Mode()
{
@@ -45,6 +45,66 @@ public class CollectionControllerTests
nameof(CollectionController.RemoveItem),
"DELETE",
"/api/collections/{id:int}/items/{mediaItemId:int}");
ShouldHaveActionRoute(
nameof(CollectionController.UpdateCustomOrder),
"PUT",
"/api/collections/{id:int}/custom-order");
}
[Test]
public async Task UpdateCustomOrder_Should_Return_404_When_Collection_Missing()
{
_mediator.Send(Arg.Any<GetCollectionById>(), Arg.Any<CancellationToken>())
.Returns(Option<MediaCollectionViewModel>.None);
IActionResult result = await _controller.UpdateCustomOrder(
99,
new UpdateCollectionCustomOrderRequest([10, 20]),
CancellationToken.None);
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
notFound.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(404);
await _mediator.DidNotReceive().Send(Arg.Any<UpdateCollectionCustomOrder>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task UpdateCustomOrder_Should_Return_204_And_Assign_Index_From_Order()
{
_mediator.Send(Arg.Any<GetCollectionById>(), Arg.Any<CancellationToken>())
.Returns(Option<MediaCollectionViewModel>.Some(MakeVm(7, "Movies")));
_mediator.Send(Arg.Any<UpdateCollectionCustomOrder>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
IActionResult result = await _controller.UpdateCustomOrder(
7,
new UpdateCollectionCustomOrderRequest([30, 10, 20]),
CancellationToken.None);
result.ShouldBeOfType<NoContentResult>();
await _mediator.Received(1).Send(
Arg.Is<UpdateCollectionCustomOrder>(c =>
c.CollectionId == 7 &&
c.MediaItemCustomOrders.Count == 3 &&
c.MediaItemCustomOrders[0].MediaItemId == 30 && c.MediaItemCustomOrders[0].CustomIndex == 0 &&
c.MediaItemCustomOrders[1].MediaItemId == 10 && c.MediaItemCustomOrders[1].CustomIndex == 1 &&
c.MediaItemCustomOrders[2].MediaItemId == 20 && c.MediaItemCustomOrders[2].CustomIndex == 2),
Arg.Any<CancellationToken>());
}
[Test]
public async Task UpdateCustomOrder_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<GetCollectionById>(), Arg.Any<CancellationToken>())
.Returns(Option<MediaCollectionViewModel>.Some(MakeVm(7, "Movies")));
_mediator.Send(Arg.Any<UpdateCollectionCustomOrder>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(BaseError.New("bad")));
IActionResult result = await _controller.UpdateCustomOrder(
7,
new UpdateCollectionCustomOrderRequest([10]),
CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
@@ -63,17 +63,30 @@ public class FillerPresetControllerTests
{
List<FillerPresetResponseModel> models =
[
new FillerPresetResponseModel(1, "Intro"),
new FillerPresetResponseModel(2, "Outro")
new FillerPresetResponseModel(1, "Intro", FillerKind.PreRoll),
new FillerPresetResponseModel(2, "Outro", FillerKind.PostRoll)
];
_mediator.Send(Arg.Any<GetAllFillerPresetsForApi>(), Arg.Any<CancellationToken>())
.Returns(models);
List<FillerPresetResponseModel> result = await _controller.GetAll(CancellationToken.None);
List<FillerPresetResponseModel> result = await _controller.GetAll(null, CancellationToken.None);
result.ShouldBe(models);
}
[Test]
public async Task GetAll_Should_Forward_FillerKind_Filter()
{
_mediator.Send(Arg.Any<GetAllFillerPresetsForApi>(), Arg.Any<CancellationToken>())
.Returns([]);
await _controller.GetAll(FillerKind.MidRoll, CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<GetAllFillerPresetsForApi>(q => q.FillerKind == FillerKind.MidRoll),
Arg.Any<CancellationToken>());
}
[Test]
public async Task GetById_Should_Return_200_For_Some()
{
@@ -45,7 +45,7 @@ public class GraphicsElementControllerTests
_mediator.Send(Arg.Any<GetAllGraphicsElementsForApi>(), Arg.Any<CancellationToken>())
.Returns(models);
List<GraphicsElementResponseModel> result = await _controller.GetAll(CancellationToken.None);
List<GraphicsElementResponseModel> result = await _controller.GetAll(refresh: false, CancellationToken.None);
result.ShouldBe(models);
}
@@ -56,8 +56,34 @@ public class GraphicsElementControllerTests
_mediator.Send(Arg.Any<GetAllGraphicsElementsForApi>(), Arg.Any<CancellationToken>())
.Returns([]);
List<GraphicsElementResponseModel> result = await _controller.GetAll(CancellationToken.None);
List<GraphicsElementResponseModel> result = await _controller.GetAll(refresh: false, CancellationToken.None);
result.ShouldBeEmpty();
}
[Test]
public async Task GetAll_Should_Not_Refresh_When_Refresh_Is_False()
{
_mediator.Send(Arg.Any<GetAllGraphicsElementsForApi>(), Arg.Any<CancellationToken>())
.Returns([]);
await _controller.GetAll(refresh: false, CancellationToken.None);
await _mediator.DidNotReceive().Send(Arg.Any<RefreshGraphicsElements>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task GetAll_Should_Refresh_Before_Listing_When_Refresh_Is_True()
{
_mediator.Send(Arg.Any<GetAllGraphicsElementsForApi>(), Arg.Any<CancellationToken>())
.Returns([]);
await _controller.GetAll(refresh: true, CancellationToken.None);
Received.InOrder(() =>
{
_mediator.Send(Arg.Any<RefreshGraphicsElements>(), Arg.Any<CancellationToken>());
_mediator.Send(Arg.Any<GetAllGraphicsElementsForApi>(), Arg.Any<CancellationToken>());
});
}
}
@@ -0,0 +1,54 @@
using System.Reflection;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Core.Api.MediaItems;
using MediatR;
using Microsoft.AspNetCore.Mvc.Routing;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class LanguagesControllerTests
{
private LanguagesController _controller = null!;
private IMediator _mediator = null!;
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
_controller = new LanguagesController(_mediator);
}
[Test]
public void Controller_Should_Expose_Idiomatic_Rest_Route()
{
MethodInfo action = typeof(LanguagesController).GetMethod(nameof(LanguagesController.GetLanguages))!;
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
attribute.HttpMethods.ShouldContain("GET");
attribute.Template.ShouldBe("/api/languages");
}
[Test]
public async Task GetLanguages_Should_Map_Vm_To_ResponseModel()
{
List<LanguageCodeViewModel> vms =
[
new LanguageCodeViewModel("eng", "English"),
new LanguageCodeViewModel("fra", "French")
];
_mediator.Send(Arg.Any<GetAllLanguageCodes>(), Arg.Any<CancellationToken>())
.Returns(vms);
List<LanguageCodeResponseModel> result = await _controller.GetLanguages(CancellationToken.None);
result.ShouldBe(
[
new LanguageCodeResponseModel("eng", "English"),
new LanguageCodeResponseModel("fra", "French")
]);
}
}
@@ -3,7 +3,10 @@ using ErsatzTV.Application.Libraries;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Core.Api.Libraries;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using NSubstitute;
using NUnit.Framework;
@@ -16,12 +19,14 @@ public class LibrariesControllerTests
{
private LibrariesController _controller = null!;
private IMediator _mediator = null!;
private ITelevisionRepository _televisionRepository = null!;
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
_controller = new LibrariesController(Substitute.For<ITelevisionRepository>(), _mediator);
_televisionRepository = Substitute.For<ITelevisionRepository>();
_controller = new LibrariesController(_televisionRepository, _mediator);
}
[Test]
@@ -52,4 +57,92 @@ public class LibrariesControllerTests
result.ShouldBe(expected);
}
[Test]
public async Task ScanShow_Should_Return_NotFoundProblem_When_Show_Not_In_Library()
{
_televisionRepository.GetShowTitle(3, 999).Returns(Option<string>.None);
IActionResult result = await _controller.ScanShow(3, new ScanShowRequest(999));
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
notFound.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(StatusCodes.Status404NotFound);
await _mediator.DidNotReceive().Send(Arg.Any<QueueShowScanByLibraryId>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task ScanShow_Should_Queue_Scan_By_Show_Id_When_Show_Belongs_To_Library()
{
_televisionRepository.GetShowTitle(3, 42).Returns(Option<string>.Some("The Office"));
_mediator.Send(Arg.Any<QueueShowScanByLibraryId>(), Arg.Any<CancellationToken>()).Returns(true);
IActionResult result = await _controller.ScanShow(3, new ScanShowRequest(42, DeepScan: true));
result.ShouldBeOfType<OkResult>();
await _mediator.Received(1).Send(
Arg.Is<QueueShowScanByLibraryId>(r =>
r.LibraryId == 3 && r.ShowId == 42 && r.ShowTitle == "The Office" && r.DeepScan),
Arg.Any<CancellationToken>());
}
[Test]
public async Task ScanShow_Should_Return_BadRequest_When_Mediator_Fails_To_Queue()
{
_televisionRepository.GetShowTitle(3, 42).Returns(Option<string>.Some("The Office"));
_mediator.Send(Arg.Any<QueueShowScanByLibraryId>(), Arg.Any<CancellationToken>()).Returns(false);
IActionResult result = await _controller.ScanShow(3, new ScanShowRequest(42));
result.ShouldBeOfType<BadRequestObjectResult>();
}
[Test]
public async Task ScanLibrary_Should_Return_202_When_Queued()
{
_mediator.Send(Arg.Any<QueueLibraryScanByLibraryId>(), Arg.Any<CancellationToken>())
.Returns(QueueLibraryScanResult.Queued);
IActionResult result = await _controller.ScanLibrary(7, CancellationToken.None);
result.ShouldBeOfType<AcceptedResult>();
await _mediator.Received(1).Send(
Arg.Is<QueueLibraryScanByLibraryId>(r => r.LibraryId == 7),
Arg.Any<CancellationToken>());
}
[Test]
public async Task ScanLibrary_Should_Return_404_When_NotFound()
{
_mediator.Send(Arg.Any<QueueLibraryScanByLibraryId>(), Arg.Any<CancellationToken>())
.Returns(QueueLibraryScanResult.NotFound);
IActionResult result = await _controller.ScanLibrary(7, CancellationToken.None);
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
notFound.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(StatusCodes.Status404NotFound);
}
[Test]
public async Task ScanLibrary_Should_Return_409_When_AlreadyScanning()
{
_mediator.Send(Arg.Any<QueueLibraryScanByLibraryId>(), Arg.Any<CancellationToken>())
.Returns(QueueLibraryScanResult.AlreadyScanning);
IActionResult result = await _controller.ScanLibrary(7, CancellationToken.None);
var conflict = result.ShouldBeOfType<ConflictObjectResult>();
conflict.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(StatusCodes.Status409Conflict);
}
[Test]
public async Task ScanLibrary_Should_Return_422_When_SyncDisabled()
{
_mediator.Send(Arg.Any<QueueLibraryScanByLibraryId>(), Arg.Any<CancellationToken>())
.Returns(QueueLibraryScanResult.SyncDisabled);
IActionResult result = await _controller.ScanLibrary(7, CancellationToken.None);
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
unprocessable.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(StatusCodes.Status422UnprocessableEntity);
}
}
@@ -42,7 +42,7 @@ public class LogsControllerTests
_mediator.Send(Arg.Any<GetRecentLogEntries>(), Arg.Any<CancellationToken>())
.Returns(new PagedLogEntriesViewModel(0, []));
await _controller.GetLogs(-1, 500, "boom", CancellationToken.None);
await _controller.GetLogs(-1, 500, "boom", cancellationToken: CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<GetRecentLogEntries>(q =>
@@ -52,6 +52,74 @@ public class LogsControllerTests
Arg.Any<CancellationToken>());
}
[Test]
public async Task GetLogs_Should_Default_To_Timestamp_Descending()
{
_mediator.Send(Arg.Any<GetRecentLogEntries>(), Arg.Any<CancellationToken>())
.Returns(new PagedLogEntriesViewModel(0, []));
await _controller.GetLogs(cancellationToken: CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<GetRecentLogEntries>(q =>
q.SortDescending == true &&
SelectsTimestamp(q.SortExpression)),
Arg.Any<CancellationToken>());
}
[Test]
public async Task GetLogs_Should_Sort_By_Level_Ascending_When_Requested()
{
_mediator.Send(Arg.Any<GetRecentLogEntries>(), Arg.Any<CancellationToken>())
.Returns(new PagedLogEntriesViewModel(0, []));
await _controller.GetLogs(sortField: "level", sortDirection: "asc", cancellationToken: CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<GetRecentLogEntries>(q =>
q.SortDescending == false &&
SelectsLevel(q.SortExpression)),
Arg.Any<CancellationToken>());
}
[Test]
public async Task GetLogs_Should_Reject_Unknown_Sort_Field_And_Fall_Back_To_Timestamp()
{
_mediator.Send(Arg.Any<GetRecentLogEntries>(), Arg.Any<CancellationToken>())
.Returns(new PagedLogEntriesViewModel(0, []));
await _controller.GetLogs(sortField: "message; DROP TABLE", cancellationToken: CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<GetRecentLogEntries>(q => SelectsTimestamp(q.SortExpression)),
Arg.Any<CancellationToken>());
}
[Test]
public async Task GetLogs_Should_Fall_Back_To_Descending_For_Unknown_Direction()
{
_mediator.Send(Arg.Any<GetRecentLogEntries>(), Arg.Any<CancellationToken>())
.Returns(new PagedLogEntriesViewModel(0, []));
await _controller.GetLogs(sortDirection: "sideways", cancellationToken: CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<GetRecentLogEntries>(q => q.SortDescending == true),
Arg.Any<CancellationToken>());
}
private static bool SelectsTimestamp(System.Linq.Expressions.Expression<Func<LogEntryViewModel, object>> expr)
{
var sample = new LogEntryViewModel(DateTimeOffset.UnixEpoch.AddDays(1), LogEventLevel.Error, "m");
return Equals(expr.Compile()(sample), sample.Timestamp);
}
private static bool SelectsLevel(System.Linq.Expressions.Expression<Func<LogEntryViewModel, object>> expr)
{
var sample = new LogEntryViewModel(DateTimeOffset.UnixEpoch.AddDays(1), LogEventLevel.Error, "m");
return Equals(expr.Compile()(sample), sample.Level);
}
[Test]
public async Task GetLogs_Should_Map_Entries_To_Response_Model()
{
@@ -0,0 +1,220 @@
using System.Reflection;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.MediaCollections;
using ErsatzTV.Core.Domain;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using static LanguageExt.Prelude;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class MultiCollectionControllerTests
{
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
_controller = new MultiCollectionController(_mediator);
}
private MultiCollectionController _controller = null!;
private IMediator _mediator = null!;
[Test]
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
{
ShouldHaveActionRoute(nameof(MultiCollectionController.GetAll), "GET", "/api/multi-collections");
ShouldHaveActionRoute(nameof(MultiCollectionController.GetById), "GET", "/api/multi-collections/{id:int}");
ShouldHaveActionRoute(nameof(MultiCollectionController.Create), "POST", "/api/multi-collections");
ShouldHaveActionRoute(nameof(MultiCollectionController.Update), "PUT", "/api/multi-collections/{id:int}");
ShouldHaveActionRoute(nameof(MultiCollectionController.Delete), "DELETE", "/api/multi-collections/{id:int}");
}
[Test]
public async Task GetAll_Should_Clamp_Paging_And_Map_Response()
{
_mediator.Send(Arg.Any<GetPagedMultiCollections>(), Arg.Any<CancellationToken>())
.Returns(new PagedMultiCollectionsViewModel(1, new List<MultiCollectionViewModel> { MakeMulti(3, "Kids") }));
PagedMultiCollectionsResponseModel result =
await _controller.GetAll("k", -5, 9999, CancellationToken.None);
result.TotalCount.ShouldBe(1);
result.Page.Count.ShouldBe(1);
result.Page[0].Id.ShouldBe(3);
result.Page[0].Items.Count.ShouldBe(2);
result.Page[0].Items[0].CollectionId.ShouldBe(10);
result.Page[0].Items[1].SmartCollectionId.ShouldBe(20);
await _mediator.Received(1).Send(
Arg.Is<GetPagedMultiCollections>(q => q.Query == "k" && q.PageNum == 0 && q.PageSize == 100),
Arg.Any<CancellationToken>());
}
[Test]
public async Task GetById_Should_Return_200_For_Some()
{
_mediator.Send(Arg.Any<GetMultiCollectionById>(), Arg.Any<CancellationToken>())
.Returns(Option<MultiCollectionViewModel>.Some(MakeMulti(4, "Kids")));
IActionResult result = await _controller.GetById(4, CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<MultiCollectionResponseModel>().Id.ShouldBe(4);
}
[Test]
public async Task GetById_Should_Return_404_For_None()
{
_mediator.Send(Arg.Any<GetMultiCollectionById>(), Arg.Any<CancellationToken>())
.Returns(Option<MultiCollectionViewModel>.None);
IActionResult result = await _controller.GetById(4, CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task Create_Should_Return_201_And_Map_Request()
{
_mediator.Send(Arg.Any<CreateMultiCollection>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, MultiCollectionViewModel>(MakeMulti(8, "Kids")));
IActionResult result = await _controller.Create(
new CreateMultiCollectionRequest(
"Kids",
new List<MultiCollectionItemRequest>
{
new(10, null, true, PlaybackOrder.Chronological),
new(null, 20, false, PlaybackOrder.Shuffle)
}),
CancellationToken.None);
var created = result.ShouldBeOfType<CreatedResult>();
created.Location.ShouldBe("/api/multi-collections/8");
created.Value.ShouldBeOfType<MultiCollectionResponseModel>().Name.ShouldBe("Kids");
await _mediator.Received(1).Send(
Arg.Is<CreateMultiCollection>(c =>
c.Name == "Kids" &&
c.Items.Count == 2 &&
c.Items[0].CollectionId == 10 &&
c.Items[0].ScheduleAsGroup &&
c.Items[0].PlaybackOrder == PlaybackOrder.Chronological &&
c.Items[1].SmartCollectionId == 20),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Create_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<CreateMultiCollection>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, MultiCollectionViewModel>(BaseError.New("dupe")));
IActionResult result = await _controller.Create(
new CreateMultiCollectionRequest("Kids", new List<MultiCollectionItemRequest>()),
CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task Update_Should_Return_200_With_Refreshed_Body_And_Map_Request()
{
_mediator.Send(Arg.Any<UpdateMultiCollection>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
_mediator.Send(Arg.Any<GetMultiCollectionById>(), Arg.Any<CancellationToken>())
.Returns(Option<MultiCollectionViewModel>.Some(MakeMulti(4, "Kids")));
IActionResult result = await _controller.Update(
4,
new UpdateMultiCollectionRequest(
"Kids",
new List<MultiCollectionItemRequest> { new(10, null, true, PlaybackOrder.Random) }),
CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<MultiCollectionResponseModel>().Id.ShouldBe(4);
await _mediator.Received(1).Send(
Arg.Is<UpdateMultiCollection>(c =>
c.MultiCollectionId == 4 &&
c.Name == "Kids" &&
c.Items.Count == 1 &&
c.Items[0].CollectionId == 10 &&
c.Items[0].PlaybackOrder == PlaybackOrder.Random),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Update_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<UpdateMultiCollection>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(BaseError.New("bad")));
IActionResult result = await _controller.Update(
4,
new UpdateMultiCollectionRequest("Kids", new List<MultiCollectionItemRequest>()),
CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
await _mediator.DidNotReceive().Send(Arg.Any<GetMultiCollectionById>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task Delete_Should_Return_204_On_Success()
{
_mediator.Send(Arg.Any<DeleteMultiCollection>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
IActionResult result = await _controller.Delete(4, CancellationToken.None);
result.ShouldBeOfType<NoContentResult>();
await _mediator.Received(1).Send(
Arg.Is<DeleteMultiCollection>(c => c.MultiCollectionId == 4),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Delete_Should_Return_404_On_NotFoundError()
{
_mediator.Send(Arg.Any<DeleteMultiCollection>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(new ErsatzTV.Core.Errors.NotFoundError("missing")));
IActionResult result = await _controller.Delete(4, CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
private static MultiCollectionViewModel MakeMulti(int id, string name) =>
new(
id,
name,
new List<MultiCollectionItemViewModel>
{
new(
id,
new MediaCollectionViewModel(CollectionType.Collection, 10, "Movies", false, MediaItemState.Normal),
true,
PlaybackOrder.Chronological)
},
new List<MultiCollectionSmartItemViewModel>
{
new(id, new SmartCollectionViewModel(20, "Smart", "query"), false, PlaybackOrder.Shuffle)
});
private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route)
{
MethodInfo action = typeof(MultiCollectionController).GetMethod(actionName)
?? throw new AssertionException($"Missing action {actionName}");
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
attribute.HttpMethods.ShouldContain(httpMethod);
attribute.Template.ShouldBe(route);
}
}
@@ -115,6 +115,7 @@ public class OpenApiErrorResponseContractTests
[TestCase("/api/channels/bulk/delete", "post", "404")]
[TestCase("/api/channels/bulk/delete", "post", "422")]
[TestCase("/api/channels/{channelNumber}/playout/reset", "post", "404")]
[TestCase("/api/channels/{channelNumber}/playout/reset", "post", "409")]
[TestCase("/api/channel-templates/default", "get", "404")]
[TestCase("/api/channel-templates/default/{id}", "put", "404")]
[TestCase("/api/channel-templates/default/{id}", "put", "422")]
@@ -144,6 +145,33 @@ public class OpenApiErrorResponseContractTests
[TestCase("/api/smart-collections/{id}", "put", "422")]
[TestCase("/api/smart-collections/{id}", "delete", "404")]
[TestCase("/api/smart-collections/{id}", "delete", "422")]
[TestCase("/api/multi-collections/{id}", "get", "404")]
[TestCase("/api/multi-collections", "post", "404")]
[TestCase("/api/multi-collections", "post", "422")]
[TestCase("/api/multi-collections/{id}", "put", "404")]
[TestCase("/api/multi-collections/{id}", "put", "422")]
[TestCase("/api/multi-collections/{id}", "delete", "404")]
[TestCase("/api/multi-collections/{id}", "delete", "422")]
[TestCase("/api/playlists/groups", "post", "422")]
[TestCase("/api/playlists/groups/{id}", "put", "404")]
[TestCase("/api/playlists/groups/{id}", "put", "422")]
[TestCase("/api/playlists/groups/{id}", "delete", "404")]
[TestCase("/api/playlists/groups/{id}", "delete", "422")]
[TestCase("/api/playlists/{id}", "get", "404")]
[TestCase("/api/playlists/{id}/items", "get", "404")]
[TestCase("/api/playlists", "post", "422")]
[TestCase("/api/playlists/{id}", "put", "404")]
[TestCase("/api/playlists/{id}", "put", "422")]
[TestCase("/api/playlists/{id}", "delete", "404")]
[TestCase("/api/playlists/{id}", "delete", "422")]
[TestCase("/api/playlists/preview", "post", "422")]
[TestCase("/api/rerun-collections/{id}", "get", "404")]
[TestCase("/api/rerun-collections", "post", "404")]
[TestCase("/api/rerun-collections", "post", "422")]
[TestCase("/api/rerun-collections/{id}", "put", "404")]
[TestCase("/api/rerun-collections/{id}", "put", "422")]
[TestCase("/api/rerun-collections/{id}", "delete", "404")]
[TestCase("/api/rerun-collections/{id}", "delete", "422")]
[TestCase("/api/schedules/{id}", "get", "404")]
[TestCase("/api/schedules", "post", "404")]
[TestCase("/api/schedules", "post", "422")]
@@ -161,9 +189,25 @@ public class OpenApiErrorResponseContractTests
[TestCase("/api/playouts/{id}", "get", "404")]
[TestCase("/api/playouts", "post", "404")]
[TestCase("/api/playouts", "post", "422")]
[TestCase("/api/playouts/{id}", "put", "404")]
[TestCase("/api/playouts/{id}", "put", "409")]
[TestCase("/api/playouts/{id}", "put", "422")]
[TestCase("/api/playouts/{id}", "delete", "404")]
[TestCase("/api/playouts/{id}", "delete", "409")]
[TestCase("/api/playouts/{id}", "delete", "422")]
[TestCase("/api/playouts/{id}/deco", "put", "409")]
[TestCase("/api/playouts/{id}/alternate-schedules", "put", "409")]
[TestCase("/api/playouts/{id}/templates", "put", "409")]
[TestCase("/api/playouts/{id}/items", "get", "404")]
[TestCase("/api/playouts/{id}/erase-items", "post", "404")]
[TestCase("/api/playouts/{id}/erase-items", "post", "409")]
[TestCase("/api/playouts/{id}/erase-items", "post", "422")]
[TestCase("/api/playouts/{id}/erase-items-and-history", "post", "404")]
[TestCase("/api/playouts/{id}/erase-items-and-history", "post", "409")]
[TestCase("/api/playouts/{id}/erase-items-and-history", "post", "422")]
[TestCase("/api/playouts/items/{id}/scheduling-context", "get", "404")]
[TestCase("/api/collections/{id}/custom-order", "put", "404")]
[TestCase("/api/collections/{id}/custom-order", "put", "422")]
[TestCase("/api/artwork/uploads", "post", "422")]
[TestCase("/api/ffmpeg/profiles/{id}", "get", "404")]
[TestCase("/api/ffmpeg/profiles", "post", "404")]
@@ -225,6 +269,11 @@ public class OpenApiErrorResponseContractTests
[TestCase("/api/trakt/lists/{id}", "delete", "409")]
[TestCase("/api/trakt/lists/{id}", "put", "404")]
[TestCase("/api/trakt/lists/{id}", "put", "422")]
[TestCase("/api/troubleshoot/playback/subtitles/{mediaItemId}", "get", "404")]
[TestCase("/api/libraries/{id}/scan-show", "post", "404")]
[TestCase("/api/libraries/{id}/scan", "post", "404")]
[TestCase("/api/libraries/{id}/scan", "post", "409")]
[TestCase("/api/libraries/{id}/scan", "post", "422")]
public void Static_OpenApi_Should_Document_ProblemDetails_For_Api_Error_Responses(
string path,
string method,
@@ -0,0 +1,172 @@
using System.Text.Json;
using ErsatzTV.Application.Artworks;
using ErsatzTV.Application.Channels;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Api.MediaItems;
using ErsatzTV.Core.Api.Settings;
using ErsatzTV.Serialization;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Controllers;
/// <summary>
/// Guards that the generated OpenAPI document's property names for a set of DTOs exactly match the
/// JSON keys the runtime MVC serializer (Newtonsoft, via <see cref="CustomContractResolver" /> +
/// <see cref="StringEnumConverter" />) actually emits. The spec is generated from System.Text.Json
/// metadata, whose camelCase can drift from Newtonsoft's (e.g. "ffmpegProfileId" special case, or a
/// [JsonProperty] override). See issue #198 — a schema transformer now mirrors the runtime resolver,
/// and this test fails if that mirroring is ever removed or broken.
/// </summary>
[TestFixture]
public class OpenApiSerializerContractTests
{
// Mirrors Startup.ConfigureServices -> AddNewtonsoftJson exactly.
private static readonly JsonSerializerSettings RuntimeSettings = new()
{
NullValueHandling = NullValueHandling.Ignore,
ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
ContractResolver = new CustomContractResolver(),
Converters = { new StringEnumConverter() }
};
private static IEnumerable<TestCaseData> Cases()
{
yield return new TestCaseData(FullyPopulatedChannel(), "ChannelViewModel").SetName("ChannelViewModel");
yield return new TestCaseData(FullyPopulatedFFmpegSettings(), "FFmpegSettingsResponseModel")
.SetName("FFmpegSettingsResponseModel");
// WatermarkViewModel was dropped from the API surface by #126: schedule-item endpoints no longer
// expose the polymorphic ProgramScheduleItemViewModel (which transitively pulled in WatermarkViewModel
// and the other collection VMs) — they return the flat ScheduleItemResponseModel, embedding watermarks
// as NamedIdResponseModel. ChannelViewModel below still exercises the VM path of the schema transformer.
yield return new TestCaseData(FullyPopulatedMediaItemInfo(), "MediaItemInfoResponseModel")
.SetName("MediaItemInfoResponseModel");
// The only DTO with a [JsonProperty] override (FFmpegProfile -> "ffmpegProfile") — covers
// the attribute path of NewtonsoftSchemaNamingTransformer, which the cases above don't.
yield return new TestCaseData(FullyPopulatedChannelSummary(), "ChannelResponseModel")
.SetName("ChannelResponseModel");
}
[TestCaseSource(nameof(Cases))]
public void Runtime_Serialized_Keys_Should_Match_OpenApi_Schema_Properties(object dto, string schemaName)
{
// Every member of dto is non-null, so NullValueHandling.Ignore drops nothing: the emitted
// top-level keys are the complete runtime property set for this type.
var serialized = JObject.Parse(JsonConvert.SerializeObject(dto, RuntimeSettings));
List<string> runtimeKeys = serialized.Properties().Select(p => p.Name).OrderBy(n => n).ToList();
using JsonDocument document = JsonDocument.Parse(File.ReadAllText(FindOpenApiDocument()));
JsonElement properties = document.RootElement
.GetProperty("components")
.GetProperty("schemas")
.GetProperty(schemaName)
.GetProperty("properties");
List<string> schemaKeys = properties.EnumerateObject().Select(p => p.Name).OrderBy(n => n).ToList();
runtimeKeys.ShouldBe(
schemaKeys,
$"OpenAPI schema '{schemaName}' property names must match the runtime Newtonsoft JSON keys.");
}
private static ChannelViewModel FullyPopulatedChannel() => new(
1,
"1",
"Name",
"Group",
"Categories",
1,
1.0,
new ArtworkContentTypeModel("path", "image/png"),
default,
"selector",
"en",
"Audio Title",
default,
default,
1,
TimeSpan.Zero,
default,
1,
1,
1,
"en",
default,
default,
"template",
default,
default,
default,
true,
true);
private static FFmpegSettingsResponseModel FullyPopulatedFFmpegSettings() => new(
"/usr/bin/ffmpeg",
"/usr/bin/ffprobe",
1,
"en",
true,
true,
true,
true,
1,
1,
1,
1,
1,
default,
"script");
private static MediaItemInfoResponseModel FullyPopulatedMediaItemInfo() => new(
1,
"Title",
"Movie",
"Local",
"Server",
"Library",
default,
TimeSpan.Zero,
"1:1",
"16:9",
"30/1",
default,
1.0,
1,
1,
[],
[]);
private static ChannelResponseModel FullyPopulatedChannelSummary() => new(
1,
"1",
1.0,
"Name",
"Group",
"Categories",
"1080p H.264",
"en",
"TransportStream",
true,
true);
private static string FindOpenApiDocument()
{
DirectoryInfo? directory = new(TestContext.CurrentContext.TestDirectory);
while (directory is not null)
{
string candidate = Path.Combine(directory.FullName, "ErsatzTV", "wwwroot", "openapi", "v1.json");
if (File.Exists(candidate))
{
return candidate;
}
directory = directory.Parent;
}
throw new FileNotFoundException("Could not find ErsatzTV/wwwroot/openapi/v1.json");
}
}
@@ -0,0 +1,589 @@
using System.Reflection;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Application.Scheduling;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.MediaCollections;
using ErsatzTV.Core.Domain;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using static LanguageExt.Prelude;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class PlaylistControllerTests
{
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
_controller = new PlaylistController(_mediator);
}
private PlaylistController _controller = null!;
private IMediator _mediator = null!;
[Test]
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
{
ShouldHaveActionRoute(nameof(PlaylistController.GetGroups), "GET", "/api/playlists/groups");
ShouldHaveActionRoute(nameof(PlaylistController.CreateGroup), "POST", "/api/playlists/groups");
ShouldHaveActionRoute(nameof(PlaylistController.UpdateGroup), "PUT", "/api/playlists/groups/{id:int}");
ShouldHaveActionRoute(nameof(PlaylistController.DeleteGroup), "DELETE", "/api/playlists/groups/{id:int}");
ShouldHaveActionRoute(nameof(PlaylistController.GetAll), "GET", "/api/playlists");
ShouldHaveActionRoute(nameof(PlaylistController.GetById), "GET", "/api/playlists/{id:int}");
ShouldHaveActionRoute(nameof(PlaylistController.GetItems), "GET", "/api/playlists/{id:int}/items");
ShouldHaveActionRoute(nameof(PlaylistController.Create), "POST", "/api/playlists");
ShouldHaveActionRoute(nameof(PlaylistController.Update), "PUT", "/api/playlists/{id:int}");
ShouldHaveActionRoute(nameof(PlaylistController.Delete), "DELETE", "/api/playlists/{id:int}");
ShouldHaveActionRoute(nameof(PlaylistController.AddItems), "POST", "/api/playlists/{id:int}/items");
ShouldHaveActionRoute(nameof(PlaylistController.Preview), "POST", "/api/playlists/preview");
}
[Test]
public async Task GetGroups_Should_Map_Response()
{
_mediator.Send(Arg.Any<GetAllPlaylistGroups>(), Arg.Any<CancellationToken>())
.Returns(new List<PlaylistGroupViewModel> { new(3, "Kids", 2, false) });
List<PlaylistGroupResponseModel> result = await _controller.GetGroups(CancellationToken.None);
result.Count.ShouldBe(1);
result[0].Id.ShouldBe(3);
result[0].PlaylistCount.ShouldBe(2);
}
[Test]
public async Task CreateGroup_Should_Return_201_And_Map_Request()
{
_mediator.Send(Arg.Any<CreatePlaylistGroup>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, PlaylistGroupViewModel>(new PlaylistGroupViewModel(8, "Kids", 0, false)));
IActionResult result = await _controller.CreateGroup(
new CreatePlaylistGroupRequest("Kids"),
CancellationToken.None);
var created = result.ShouldBeOfType<CreatedResult>();
created.Location.ShouldBe("/api/playlists/groups/8");
created.Value.ShouldBeOfType<PlaylistGroupResponseModel>().Name.ShouldBe("Kids");
await _mediator.Received(1).Send(
Arg.Is<CreatePlaylistGroup>(c => c.Name == "Kids"),
Arg.Any<CancellationToken>());
}
[Test]
public async Task CreateGroup_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<CreatePlaylistGroup>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, PlaylistGroupViewModel>(BaseError.New("bad")));
IActionResult result = await _controller.CreateGroup(
new CreatePlaylistGroupRequest("Kids"),
CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task UpdateGroup_Should_Return_200_And_Map_Request()
{
_mediator.Send(Arg.Any<GetAllPlaylistGroups>(), Arg.Any<CancellationToken>())
.Returns(new List<PlaylistGroupViewModel> { new(4, "Kids", 1, false) });
_mediator.Send(Arg.Any<RenamePlaylistGroup>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, PlaylistGroupViewModel>(new PlaylistGroupViewModel(4, "New Name", 1, false)));
IActionResult result = await _controller.UpdateGroup(
4,
new UpdatePlaylistGroupRequest("New Name"),
CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<PlaylistGroupResponseModel>().Name
.ShouldBe("New Name");
await _mediator.Received(1).Send(
Arg.Is<RenamePlaylistGroup>(c => c.PlaylistGroupId == 4 && c.Name == "New Name"),
Arg.Any<CancellationToken>());
}
[Test]
public async Task UpdateGroup_Should_Return_404_When_Missing()
{
_mediator.Send(Arg.Any<GetAllPlaylistGroups>(), Arg.Any<CancellationToken>())
.Returns(new List<PlaylistGroupViewModel>());
IActionResult result = await _controller.UpdateGroup(
4,
new UpdatePlaylistGroupRequest("New Name"),
CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
await _mediator.DidNotReceive().Send(Arg.Any<RenamePlaylistGroup>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task UpdateGroup_Should_Return_422_On_System_Group_And_Not_Rename()
{
_mediator.Send(Arg.Any<GetAllPlaylistGroups>(), Arg.Any<CancellationToken>())
.Returns(new List<PlaylistGroupViewModel> { new(4, "System", 0, true) });
IActionResult result = await _controller.UpdateGroup(
4,
new UpdatePlaylistGroupRequest("New Name"),
CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
await _mediator.DidNotReceive().Send(Arg.Any<RenamePlaylistGroup>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task UpdateGroup_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<GetAllPlaylistGroups>(), Arg.Any<CancellationToken>())
.Returns(new List<PlaylistGroupViewModel> { new(4, "Kids", 1, false) });
_mediator.Send(Arg.Any<RenamePlaylistGroup>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, PlaylistGroupViewModel>(BaseError.New("too long")));
IActionResult result = await _controller.UpdateGroup(
4,
new UpdatePlaylistGroupRequest("New Name"),
CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task DeleteGroup_Should_Return_204_On_Success()
{
_mediator.Send(Arg.Any<GetAllPlaylistGroups>(), Arg.Any<CancellationToken>())
.Returns(new List<PlaylistGroupViewModel> { new(4, "Kids", 0, false) });
_mediator.Send(Arg.Any<DeletePlaylistGroup>(), Arg.Any<CancellationToken>())
.Returns(Option<BaseError>.None);
IActionResult result = await _controller.DeleteGroup(4, CancellationToken.None);
result.ShouldBeOfType<NoContentResult>();
await _mediator.Received(1).Send(
Arg.Is<DeletePlaylistGroup>(c => c.PlaylistGroupId == 4),
Arg.Any<CancellationToken>());
}
[Test]
public async Task DeleteGroup_Should_Return_404_When_Missing()
{
_mediator.Send(Arg.Any<GetAllPlaylistGroups>(), Arg.Any<CancellationToken>())
.Returns(new List<PlaylistGroupViewModel>());
IActionResult result = await _controller.DeleteGroup(4, CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
await _mediator.DidNotReceive().Send(Arg.Any<DeletePlaylistGroup>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task DeleteGroup_Should_Return_422_On_System_Group()
{
_mediator.Send(Arg.Any<GetAllPlaylistGroups>(), Arg.Any<CancellationToken>())
.Returns(new List<PlaylistGroupViewModel> { new(4, "System", 0, true) });
_mediator.Send(Arg.Any<DeletePlaylistGroup>(), Arg.Any<CancellationToken>())
.Returns(Option<BaseError>.Some(BaseError.New("Cannot delete system playlist group")));
IActionResult result = await _controller.DeleteGroup(4, CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task GetById_Should_Return_200_For_Some()
{
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "Kids", false)));
IActionResult result = await _controller.GetById(4, CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<PlaylistResponseModel>().Id.ShouldBe(4);
}
[Test]
public async Task GetById_Should_Return_404_For_None()
{
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlaylistViewModel>.None);
IActionResult result = await _controller.GetById(4, CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task GetItems_Should_Return_200_And_Flatten_Names()
{
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "Kids", false)));
_mediator.Send(Arg.Any<GetPlaylistItems>(), Arg.Any<CancellationToken>())
.Returns(new List<PlaylistItemViewModel>
{
new(
100,
0,
CollectionType.Collection,
new MediaCollectionViewModel(CollectionType.Collection, 10, "Movies", false, MediaItemState.Normal),
null,
null,
null,
PlaybackOrder.Chronological,
null,
true,
false),
new(
101,
1,
CollectionType.Movie,
null,
null,
null,
new NamedMediaItemViewModel(55, "The Movie"),
PlaybackOrder.Shuffle,
3,
false,
true)
});
IActionResult result = await _controller.GetItems(4, CancellationToken.None);
var items = result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<List<PlaylistItemResponseModel>>();
items.Count.ShouldBe(2);
items[0].CollectionId.ShouldBe(10);
items[0].CollectionName.ShouldBe("Movies");
items[0].MediaItemId.ShouldBeNull();
items[1].MediaItemId.ShouldBe(55);
items[1].MediaItemName.ShouldBe("The Movie");
items[1].CollectionName.ShouldBeNull();
}
[Test]
public async Task GetItems_Should_Return_404_When_Playlist_Missing()
{
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlaylistViewModel>.None);
IActionResult result = await _controller.GetItems(4, CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
await _mediator.DidNotReceive().Send(Arg.Any<GetPlaylistItems>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task Create_Should_Return_201_And_Map_Request()
{
_mediator.Send(Arg.Any<CreatePlaylist>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, PlaylistViewModel>(new PlaylistViewModel(9, 1, "Kids", false)));
IActionResult result = await _controller.Create(
new CreatePlaylistRequest(1, "Kids"),
CancellationToken.None);
var created = result.ShouldBeOfType<CreatedResult>();
created.Location.ShouldBe("/api/playlists/9");
await _mediator.Received(1).Send(
Arg.Is<CreatePlaylist>(c => c.PlaylistGroupId == 1 && c.Name == "Kids"),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Create_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<CreatePlaylist>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, PlaylistViewModel>(BaseError.New("dupe")));
IActionResult result = await _controller.Create(
new CreatePlaylistRequest(1, "Kids"),
CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task Update_Should_Return_200_With_Items_And_Map_Request_By_Array_Order()
{
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "Kids", false)));
_mediator.Send(Arg.Any<ReplacePlaylistItems>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, List<PlaylistItemViewModel>>(new List<PlaylistItemViewModel>
{
new(
100,
0,
CollectionType.Movie,
null,
null,
null,
new NamedMediaItemViewModel(55, "The Movie"),
PlaybackOrder.Shuffle,
null,
false,
true)
}));
IActionResult result = await _controller.Update(
4,
new ReplacePlaylistRequest(
"Kids",
new List<PlaylistItemRequest>
{
new(99, CollectionType.Movie, null, null, null, 55, PlaybackOrder.Shuffle, null, false, true),
new(99, CollectionType.Collection, 10, null, null, null, PlaybackOrder.Chronological, null, true,
false)
}),
CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<List<PlaylistItemResponseModel>>().Count
.ShouldBe(1);
await _mediator.Received(1).Send(
Arg.Is<ReplacePlaylistItems>(c =>
c.PlaylistId == 4 &&
c.Name == "Kids" &&
c.Items.Count == 2 &&
c.Items[0].Index == 0 &&
c.Items[0].MediaItemId == 55 &&
c.Items[1].Index == 1 &&
c.Items[1].CollectionId == 10),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Update_Should_Return_404_When_Playlist_Missing()
{
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlaylistViewModel>.None);
IActionResult result = await _controller.Update(
4,
new ReplacePlaylistRequest("Kids", new List<PlaylistItemRequest>()),
CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
await _mediator.DidNotReceive().Send(Arg.Any<ReplacePlaylistItems>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task Update_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "Kids", false)));
_mediator.Send(Arg.Any<ReplacePlaylistItems>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, List<PlaylistItemViewModel>>(BaseError.New("bad item")));
IActionResult result = await _controller.Update(
4,
new ReplacePlaylistRequest("Kids", new List<PlaylistItemRequest>()),
CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task Update_Should_Return_422_On_System_Playlist_And_Not_Replace_Items()
{
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "System", true)));
IActionResult result = await _controller.Update(
4,
new ReplacePlaylistRequest(
"Renamed",
new List<PlaylistItemRequest>()),
CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
await _mediator.DidNotReceive().Send(Arg.Any<ReplacePlaylistItems>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task Delete_Should_Return_204_On_Success()
{
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "Kids", false)));
_mediator.Send(Arg.Any<DeletePlaylist>(), Arg.Any<CancellationToken>())
.Returns(Option<BaseError>.None);
IActionResult result = await _controller.Delete(4, CancellationToken.None);
result.ShouldBeOfType<NoContentResult>();
await _mediator.Received(1).Send(
Arg.Is<DeletePlaylist>(c => c.PlaylistId == 4),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Delete_Should_Return_404_When_Missing()
{
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlaylistViewModel>.None);
IActionResult result = await _controller.Delete(4, CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
await _mediator.DidNotReceive().Send(Arg.Any<DeletePlaylist>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task Delete_Should_Return_422_On_System_Playlist()
{
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "System", true)));
_mediator.Send(Arg.Any<DeletePlaylist>(), Arg.Any<CancellationToken>())
.Returns(Option<BaseError>.Some(BaseError.New("Cannot delete system (generated) playlist")));
IActionResult result = await _controller.Delete(4, CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task AddItems_Should_Return_204_And_Map_Request()
{
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "Kids", false)));
_mediator.Send(Arg.Any<AddItemsToPlaylist>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
IActionResult result = await _controller.AddItems(
4,
new AddItemsToPlaylistRequest([1, 2], [3], [], [], [], [], [], [], [], []),
CancellationToken.None);
result.ShouldBeOfType<NoContentResult>();
await _mediator.Received(1).Send(
Arg.Is<AddItemsToPlaylist>(c =>
c.PlaylistId == 4 &&
c.MovieIds.SequenceEqual(new List<int> { 1, 2 }) &&
c.ShowIds.SequenceEqual(new List<int> { 3 })),
Arg.Any<CancellationToken>());
}
[Test]
public async Task AddItems_Should_Return_404_When_Playlist_Missing()
{
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlaylistViewModel>.None);
IActionResult result = await _controller.AddItems(
4,
new AddItemsToPlaylistRequest([1], [], [], [], [], [], [], [], [], []),
CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
await _mediator.DidNotReceive().Send(Arg.Any<AddItemsToPlaylist>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task AddItems_Should_Return_422_On_System_Playlist()
{
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "System", true)));
_mediator.Send(Arg.Any<AddItemsToPlaylist>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(BaseError.New("Cannot add items to system (generated) playlist")));
IActionResult result = await _controller.AddItems(
4,
new AddItemsToPlaylistRequest([1], [], [], [], [], [], [], [], [], []),
CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task AddItems_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "Kids", false)));
_mediator.Send(Arg.Any<AddItemsToPlaylist>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(BaseError.New("Movie does not exist")));
IActionResult result = await _controller.AddItems(
4,
new AddItemsToPlaylistRequest([999], [], [], [], [], [], [], [], [], []),
CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task Preview_Should_Return_200_And_Format_Times()
{
_mediator.Send(Arg.Any<PreviewPlaylistPlayout>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, List<PlayoutItemPreviewViewModel>>(new List<PlayoutItemPreviewViewModel>
{
new("The Movie", new TimeSpan(1, 2, 3), new TimeSpan(2, 4, 6), "1:02:03")
}));
IActionResult result = await _controller.Preview(
new ReplacePlaylistRequest(
"Draft",
new List<PlaylistItemRequest>
{
new(0, CollectionType.Movie, null, null, null, 55, PlaybackOrder.Shuffle, null, false, true)
}),
CancellationToken.None);
var items = result.ShouldBeOfType<OkObjectResult>().Value
.ShouldBeOfType<List<PlaylistPreviewItemResponseModel>>();
items.Count.ShouldBe(1);
items[0].Title.ShouldBe("The Movie");
items[0].Start.ShouldBe("01:02:03");
items[0].Finish.ShouldBe("02:04:06");
items[0].Duration.ShouldBe("1:02:03");
await _mediator.Received(1).Send(
Arg.Is<PreviewPlaylistPlayout>(c => c.Data.PlaylistId == 0 && c.Data.Items.Count == 1),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Preview_Should_Return_422_On_Error()
{
_mediator.Send(Arg.Any<PreviewPlaylistPlayout>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, List<PlayoutItemPreviewViewModel>>(BaseError.New("bad")));
IActionResult result = await _controller.Preview(
new ReplacePlaylistRequest("Draft", new List<PlaylistItemRequest>()),
CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task Preview_Should_Return_422_When_Item_Missing_Required_Id()
{
IActionResult result = await _controller.Preview(
new ReplacePlaylistRequest(
"Draft",
new List<PlaylistItemRequest>
{
// Collection-type item with a null collectionId is invalid.
new(0, CollectionType.Collection, null, null, null, null, PlaybackOrder.Chronological, null, true,
false)
}),
CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
await _mediator.DidNotReceive().Send(Arg.Any<PreviewPlaylistPlayout>(), Arg.Any<CancellationToken>());
}
private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route)
{
MethodInfo action = typeof(PlaylistController).GetMethod(actionName)
?? throw new AssertionException($"Missing action {actionName}");
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
attribute.HttpMethods.ShouldContain(httpMethod);
attribute.Template.ShouldBe(route);
}
}
@@ -13,6 +13,7 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Scheduling;
using LanguageExt;
using MediatR;
@@ -31,12 +32,14 @@ public class PlayoutControllerTests
{
private PlayoutController _controller = null!;
private IMediator _mediator = null!;
private IEntityLocker _entityLocker = null!;
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
_controller = new PlayoutController(_mediator);
_entityLocker = Substitute.For<IEntityLocker>();
_controller = new PlayoutController(_mediator, _entityLocker);
}
[Test]
@@ -57,6 +60,206 @@ public class PlayoutControllerTests
ShouldHaveActionRoute(nameof(PlayoutController.GetBlocks), "GET", "/api/playouts/{id:int}/blocks");
ShouldHaveActionRoute(nameof(PlayoutController.GetBlockHistory), "GET", "/api/playouts/{id:int}/blocks/{blockId:int}/history");
ShouldHaveActionRoute(nameof(PlayoutController.GetHistoryDetails), "GET", "/api/playouts/history/{id:int}");
ShouldHaveActionRoute(nameof(PlayoutController.EraseItems), "POST", "/api/playouts/{id:int}/erase-items");
ShouldHaveActionRoute(
nameof(PlayoutController.EraseItemsAndHistory),
"POST",
"/api/playouts/{id:int}/erase-items-and-history");
ShouldHaveActionRoute(
nameof(PlayoutController.GetItemSchedulingContext),
"GET",
"/api/playouts/items/{id:int}/scheduling-context");
}
// ----- Build-lock guard (#215): id-keyed mutations return 409 while the build lock is held -----
[Test]
public async Task Delete_Should_Return_409_When_Playout_Locked()
{
_entityLocker.IsPlayoutLocked(9).Returns(true);
IActionResult result = await _controller.Delete(9, CancellationToken.None);
var conflict = result.ShouldBeOfType<ConflictObjectResult>();
conflict.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
await _mediator.DidNotReceive().Send(Arg.Any<DeletePlayout>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task EraseItems_Should_Return_409_When_Playout_Locked()
{
_entityLocker.IsPlayoutLocked(9).Returns(true);
IActionResult result = await _controller.EraseItems(9, CancellationToken.None);
result.ShouldBeOfType<ConflictObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
await _mediator.DidNotReceive().Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>());
await _mediator.DidNotReceive().Send(Arg.Any<ErasePlayoutItems>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task EraseItemsAndHistory_Should_Return_409_When_Playout_Locked()
{
_entityLocker.IsPlayoutLocked(9).Returns(true);
IActionResult result = await _controller.EraseItemsAndHistory(9, CancellationToken.None);
result.ShouldBeOfType<ConflictObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
await _mediator.DidNotReceive().Send(Arg.Any<ErasePlayoutHistory>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task Update_Should_Return_409_When_Playout_Locked()
{
_entityLocker.IsPlayoutLocked(9).Returns(true);
IActionResult result = await _controller.Update(
9,
new UpdatePlayoutDetailsRequest(TimeSpan.FromHours(4), null),
CancellationToken.None);
result.ShouldBeOfType<ConflictObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
await _mediator.DidNotReceive().Send(Arg.Any<UpdatePlayout>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task UpdateDefaultDeco_Should_Return_409_When_Playout_Locked()
{
_entityLocker.IsPlayoutLocked(9).Returns(true);
IActionResult result = await _controller.UpdateDefaultDeco(
9,
new UpdateDefaultDecoRequest(null),
CancellationToken.None);
result.ShouldBeOfType<ConflictObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
}
[Test]
public async Task GetAll_Should_Stamp_IsLocked_From_Locker()
{
_entityLocker.IsPlayoutLocked(9).Returns(true);
_mediator.Send(Arg.Any<GetPagedPlayouts>(), Arg.Any<CancellationToken>())
.Returns(new PagedPlayoutsViewModel(1, [MakePlayout(9)]));
PagedPlayoutsResponseModel result = await _controller.GetAll("", 0, 100, CancellationToken.None);
result.Page.Single().IsLocked.ShouldBeTrue();
}
// ----- Erase items / history -----
[Test]
public async Task EraseItems_Should_Return_404_When_Playout_Missing()
{
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.None);
IActionResult result = await _controller.EraseItems(404, CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
await _mediator.DidNotReceive().Send(Arg.Any<ErasePlayoutItems>(), Arg.Any<CancellationToken>());
}
[TestCase(PlayoutScheduleKind.Classic)]
[TestCase(PlayoutScheduleKind.ExternalJson)]
public async Task EraseItems_Should_Return_422_For_Unsupported_Kind(PlayoutScheduleKind kind)
{
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = kind }));
IActionResult result = await _controller.EraseItems(9, CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
await _mediator.DidNotReceive().Send(Arg.Any<ErasePlayoutItems>(), Arg.Any<CancellationToken>());
}
[TestCase(PlayoutScheduleKind.Block)]
[TestCase(PlayoutScheduleKind.Sequential)]
[TestCase(PlayoutScheduleKind.Scripted)]
public async Task EraseItems_Should_Return_204_And_Send_Command_For_Supported_Kind(PlayoutScheduleKind kind)
{
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = kind }));
IActionResult result = await _controller.EraseItems(9, CancellationToken.None);
result.ShouldBeOfType<NoContentResult>();
await _mediator.Received(1).Send(
Arg.Is<ErasePlayoutItems>(c => c.PlayoutId == 9),
Arg.Any<CancellationToken>());
}
[Test]
public async Task EraseItemsAndHistory_Should_Return_404_When_Playout_Missing()
{
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.None);
IActionResult result = await _controller.EraseItemsAndHistory(404, CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
await _mediator.DidNotReceive().Send(Arg.Any<ErasePlayoutHistory>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task EraseItemsAndHistory_Should_Return_422_For_Unsupported_Kind()
{
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = PlayoutScheduleKind.ExternalJson }));
IActionResult result = await _controller.EraseItemsAndHistory(9, CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
await _mediator.DidNotReceive().Send(Arg.Any<ErasePlayoutHistory>(), Arg.Any<CancellationToken>());
}
[TestCase(PlayoutScheduleKind.Classic)]
[TestCase(PlayoutScheduleKind.Block)]
[TestCase(PlayoutScheduleKind.Sequential)]
[TestCase(PlayoutScheduleKind.Scripted)]
public async Task EraseItemsAndHistory_Should_Return_204_And_Send_Command_For_Supported_Kind(
PlayoutScheduleKind kind)
{
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9) with { ScheduleKind = kind }));
IActionResult result = await _controller.EraseItemsAndHistory(9, CancellationToken.None);
result.ShouldBeOfType<NoContentResult>();
await _mediator.Received(1).Send(
Arg.Is<ErasePlayoutHistory>(c => c.PlayoutId == 9),
Arg.Any<CancellationToken>());
}
// ----- Playout item scheduling context -----
[Test]
public async Task GetItemSchedulingContext_Should_Return_200_With_Decoded_Context()
{
_mediator.Send(Arg.Any<GetPlayoutItemSchedulingContext>(), Arg.Any<CancellationToken>())
.Returns(Option<string>.Some("{ \"decoded\": true }"));
IActionResult result = await _controller.GetItemSchedulingContext(42, CancellationToken.None);
var context = result.ShouldBeOfType<OkObjectResult>().Value
.ShouldBeOfType<PlayoutItemSchedulingContextResponseModel>();
context.Context.ShouldBe("{ \"decoded\": true }");
await _mediator.Received(1).Send(
Arg.Is<GetPlayoutItemSchedulingContext>(q => q.PlayoutItemId == 42),
Arg.Any<CancellationToken>());
}
[Test]
public async Task GetItemSchedulingContext_Should_Return_404_For_None()
{
_mediator.Send(Arg.Any<GetPlayoutItemSchedulingContext>(), Arg.Any<CancellationToken>())
.Returns(Option<string>.None);
IActionResult result = await _controller.GetItemSchedulingContext(404, CancellationToken.None);
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
notFound.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(404);
}
[Test]
@@ -364,6 +567,7 @@ public class PlayoutControllerTests
item.ChannelName.ShouldBe("Channel");
item.ScheduleKind.ShouldBe(PlayoutScheduleKind.Classic);
item.ScheduleName.ShouldBe("Schedule");
item.PlayoutMode.ShouldBe(ChannelPlayoutMode.Continuous);
item.DailyRebuildTime.ShouldBe(TimeSpan.FromHours(4));
item.BuildStatus.ShouldNotBeNull();
item.BuildStatus.Success.ShouldBeFalse();
@@ -394,6 +598,7 @@ public class PlayoutControllerTests
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
var item = new PlayoutItemViewModel(
77,
"Movie",
new DateTimeOffset(2026, 7, 2, 12, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 7, 2, 13, 0, 0, TimeSpan.Zero),
@@ -401,6 +606,7 @@ public class PlayoutControllerTests
string.Empty,
Some(FillerKind.MidRoll));
var gap = new PlayoutItemViewModel(
null,
"UNSCHEDULED",
new DateTimeOffset(2026, 7, 2, 13, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 7, 2, 13, 30, 0, TimeSpan.Zero),
@@ -414,9 +620,11 @@ public class PlayoutControllerTests
var result = actionResult.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<PagedPlayoutItemsResponseModel>();
result.TotalCount.ShouldBe(2);
result.Page[0].Id.ShouldBe(77);
result.Page[0].Title.ShouldBe("Movie");
result.Page[0].Duration.ShouldBe("1:00:00");
result.Page[0].FillerKind.ShouldBe(FillerKind.MidRoll);
result.Page[1].Id.ShouldBeNull();
result.Page[1].Title.ShouldBe("UNSCHEDULED");
result.Page[1].FillerKind.ShouldBeNull();
@@ -426,6 +634,35 @@ public class PlayoutControllerTests
Arg.Any<CancellationToken>());
}
[Test]
public async Task GetItems_Should_Flag_HasSchedulingContext_Without_Exposing_Raw_Json()
{
_mediator.Send(Arg.Any<GetPlayoutById>(), Arg.Any<CancellationToken>())
.Returns(Option<PlayoutNameViewModel>.Some(MakePlayout(9)));
var withContext = new PlayoutItemViewModel(
42,
"Movie",
new DateTimeOffset(2026, 7, 2, 12, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 7, 2, 13, 0, 0, TimeSpan.Zero),
"1:00:00",
"{ \"ScheduleId\": 1 }",
Option<FillerKind>.None);
var withoutContext = withContext with { SchedulingContext = " " };
_mediator.Send(Arg.Any<GetFuturePlayoutItemsById>(), Arg.Any<CancellationToken>())
.Returns(new PagedPlayoutItemsViewModel(2, [withContext, withoutContext]));
IActionResult actionResult = await _controller.GetItems(9, showFiller: false, 0, 100, CancellationToken.None);
var result = actionResult.ShouldBeOfType<OkObjectResult>().Value
.ShouldBeOfType<PagedPlayoutItemsResponseModel>();
result.Page[0].HasSchedulingContext.ShouldBeTrue();
result.Page[1].HasSchedulingContext.ShouldBeFalse();
// The raw JSON blob must never appear on the list DTO.
typeof(PlayoutItemResponseModel).GetProperty("SchedulingContext").ShouldBeNull();
}
[Test]
public async Task GetItems_Should_Return_404_For_Unknown_Playout_With_ProblemDetails()
{
@@ -507,6 +744,22 @@ public class PlayoutControllerTests
list[1].ProgramScheduleId.ShouldBe(8);
}
[Test]
public async Task ReplaceAlternateSchedules_Should_Return_409_When_Playout_Locked()
{
_entityLocker.IsPlayoutLocked(9).Returns(true);
IActionResult result = await _controller.ReplaceAlternateSchedules(
9,
new ReplacePlayoutAlternateSchedulesRequest([MakeAltRequest(7)]),
CancellationToken.None);
var conflict = result.ShouldBeOfType<ConflictObjectResult>();
conflict.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
await _mediator.DidNotReceive()
.Send(Arg.Any<ReplacePlayoutAlternateScheduleItems>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task ReplaceAlternateSchedules_Should_Return_404_When_Playout_Missing()
{
@@ -717,6 +970,21 @@ public class PlayoutControllerTests
list[1].DecoTemplateId.ShouldBe(5);
}
[Test]
public async Task ReplaceTemplates_Should_Return_409_When_Playout_Locked()
{
_entityLocker.IsPlayoutLocked(9).Returns(true);
IActionResult result = await _controller.ReplaceTemplates(
9,
new ReplacePlayoutTemplatesRequest([MakeTemplateRequest(7, null)]),
CancellationToken.None);
var conflict = result.ShouldBeOfType<ConflictObjectResult>();
conflict.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
await _mediator.DidNotReceive().Send(Arg.Any<ReplacePlayoutTemplateItems>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task ReplaceTemplates_Should_Return_404_When_Playout_Missing()
{
@@ -0,0 +1,288 @@
using System.Reflection;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.MediaCollections;
using ErsatzTV.Core.Domain;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using static LanguageExt.Prelude;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class RerunCollectionControllerTests
{
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
_controller = new RerunCollectionController(_mediator);
}
private RerunCollectionController _controller = null!;
private IMediator _mediator = null!;
[Test]
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
{
ShouldHaveActionRoute(nameof(RerunCollectionController.GetAll), "GET", "/api/rerun-collections");
ShouldHaveActionRoute(nameof(RerunCollectionController.GetById), "GET", "/api/rerun-collections/{id:int}");
ShouldHaveActionRoute(nameof(RerunCollectionController.Create), "POST", "/api/rerun-collections");
ShouldHaveActionRoute(nameof(RerunCollectionController.Update), "PUT", "/api/rerun-collections/{id:int}");
ShouldHaveActionRoute(nameof(RerunCollectionController.Delete), "DELETE", "/api/rerun-collections/{id:int}");
}
[Test]
public async Task GetAll_Should_Clamp_Paging_And_Map_Response()
{
_mediator.Send(Arg.Any<GetPagedRerunCollections>(), Arg.Any<CancellationToken>())
.Returns(new PagedRerunCollectionsViewModel(
1,
new List<RerunCollectionViewModel> { MakeRerunFromSmart(3, "Nightly") }));
PagedRerunCollectionsResponseModel result =
await _controller.GetAll("n", -5, 9999, CancellationToken.None);
result.TotalCount.ShouldBe(1);
result.Page.Count.ShouldBe(1);
result.Page[0].Id.ShouldBe(3);
result.Page[0].CollectionType.ShouldBe(CollectionType.SmartCollection);
result.Page[0].SelectedId.ShouldBe(20);
result.Page[0].SelectedName.ShouldBe("Smart");
await _mediator.Received(1).Send(
Arg.Is<GetPagedRerunCollections>(q => q.Query == "n" && q.PageNum == 0 && q.PageSize == 100),
Arg.Any<CancellationToken>());
}
[Test]
public async Task GetById_Should_Return_200_For_Some()
{
_mediator.Send(Arg.Any<GetRerunCollectionById>(), Arg.Any<CancellationToken>())
.Returns(Option<RerunCollectionViewModel>.Some(MakeRerunFromSmart(4, "Nightly")));
IActionResult result = await _controller.GetById(4, CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<RerunCollectionResponseModel>().Id.ShouldBe(4);
}
[Test]
public async Task GetById_Should_Return_404_For_None()
{
_mediator.Send(Arg.Any<GetRerunCollectionById>(), Arg.Any<CancellationToken>())
.Returns(Option<RerunCollectionViewModel>.None);
IActionResult result = await _controller.GetById(4, CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task Create_Should_Return_201_And_Resolve_Selected_Id_Into_Collection_Vm()
{
_mediator.Send(Arg.Any<CreateRerunCollection>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, RerunCollectionViewModel>(MakeRerunFromSmart(8, "Nightly")));
IActionResult result = await _controller.Create(
new CreateRerunCollectionRequest(
"Nightly",
CollectionType.Collection,
42,
PlaybackOrder.Chronological,
PlaybackOrder.Shuffle),
CancellationToken.None);
var created = result.ShouldBeOfType<CreatedResult>();
created.Location.ShouldBe("/api/rerun-collections/8");
await _mediator.Received(1).Send(
Arg.Is<CreateRerunCollection>(c =>
c.Name == "Nightly" &&
c.CollectionType == CollectionType.Collection &&
c.Collection != null && c.Collection.Id == 42 &&
c.MultiCollection == null &&
c.SmartCollection == null &&
c.MediaItem == null &&
c.FirstRunPlaybackOrder == PlaybackOrder.Chronological &&
c.RerunPlaybackOrder == PlaybackOrder.Shuffle),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Create_Should_Resolve_Media_Item_Selection_For_Show_Type()
{
_mediator.Send(Arg.Any<CreateRerunCollection>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, RerunCollectionViewModel>(MakeRerunFromSmart(9, "Nightly")));
await _controller.Create(
new CreateRerunCollectionRequest(
"Nightly",
CollectionType.TelevisionShow,
77,
PlaybackOrder.Chronological,
PlaybackOrder.Chronological),
CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<CreateRerunCollection>(c =>
c.CollectionType == CollectionType.TelevisionShow &&
c.Collection == null &&
c.MediaItem != null && c.MediaItem.MediaItemId == 77),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Create_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<CreateRerunCollection>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, RerunCollectionViewModel>(BaseError.New("dupe")));
IActionResult result = await _controller.Create(
new CreateRerunCollectionRequest(
"Nightly",
CollectionType.SmartCollection,
1,
PlaybackOrder.Chronological,
PlaybackOrder.Chronological),
CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task Create_Should_Return_422_For_Unsupported_Collection_Type()
{
IActionResult result = await _controller.Create(
new CreateRerunCollectionRequest(
"Nightly",
CollectionType.Playlist,
1,
PlaybackOrder.Chronological,
PlaybackOrder.Chronological),
CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
await _mediator.DidNotReceive().Send(Arg.Any<CreateRerunCollection>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task Update_Should_Return_200_With_Refreshed_Body_And_Resolve_Selection()
{
_mediator.Send(Arg.Any<UpdateRerunCollection>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
_mediator.Send(Arg.Any<GetRerunCollectionById>(), Arg.Any<CancellationToken>())
.Returns(Option<RerunCollectionViewModel>.Some(MakeRerunFromSmart(4, "Nightly")));
IActionResult result = await _controller.Update(
4,
new UpdateRerunCollectionRequest(
"Nightly",
CollectionType.MultiCollection,
55,
PlaybackOrder.Chronological,
PlaybackOrder.Shuffle),
CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<RerunCollectionResponseModel>().Id.ShouldBe(4);
await _mediator.Received(1).Send(
Arg.Is<UpdateRerunCollection>(c =>
c.RerunCollectionId == 4 &&
c.Name == "Nightly" &&
c.CollectionType == CollectionType.MultiCollection &&
c.MultiCollection != null && c.MultiCollection.Id == 55 &&
c.Collection == null &&
c.SmartCollection == null &&
c.MediaItem == null),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Update_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<UpdateRerunCollection>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(BaseError.New("bad")));
IActionResult result = await _controller.Update(
4,
new UpdateRerunCollectionRequest(
"Nightly",
CollectionType.SmartCollection,
1,
PlaybackOrder.Chronological,
PlaybackOrder.Chronological),
CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
await _mediator.DidNotReceive().Send(Arg.Any<GetRerunCollectionById>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task Update_Should_Return_422_For_Unsupported_Collection_Type()
{
IActionResult result = await _controller.Update(
4,
new UpdateRerunCollectionRequest(
"Nightly",
CollectionType.RerunFirstRun,
1,
PlaybackOrder.Chronological,
PlaybackOrder.Chronological),
CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
await _mediator.DidNotReceive().Send(Arg.Any<UpdateRerunCollection>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task Delete_Should_Return_204_On_Success()
{
_mediator.Send(Arg.Any<DeleteRerunCollection>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
IActionResult result = await _controller.Delete(4, CancellationToken.None);
result.ShouldBeOfType<NoContentResult>();
await _mediator.Received(1).Send(
Arg.Is<DeleteRerunCollection>(c => c.RerunCollectionId == 4),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Delete_Should_Return_404_On_NotFoundError()
{
_mediator.Send(Arg.Any<DeleteRerunCollection>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(new ErsatzTV.Core.Errors.NotFoundError("missing")));
IActionResult result = await _controller.Delete(4, CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
private static RerunCollectionViewModel MakeRerunFromSmart(int id, string name) =>
new(
id,
name,
CollectionType.SmartCollection,
null,
null,
new SmartCollectionViewModel(20, "Smart", "query"),
null,
PlaybackOrder.Chronological,
PlaybackOrder.Shuffle);
private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route)
{
MethodInfo action = typeof(RerunCollectionController).GetMethod(actionName)
?? throw new AssertionException($"Missing action {actionName}");
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
attribute.HttpMethods.ShouldContain(httpMethod);
attribute.Template.ShouldBe(route);
}
}
@@ -1,8 +1,10 @@
using System.Linq;
using System.Reflection;
using ErsatzTV.Application.ProgramSchedules;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Scheduling;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Scheduling;
@@ -197,7 +199,10 @@ public class ScheduleControllerTests
IActionResult result = await _controller.GetItems(4, CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(response);
var envelope = result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<ScheduleItemsResponseModel>();
envelope.TotalDurationEstimate.ShouldBe(TimeSpan.FromMinutes(25));
envelope.Items.Count.ShouldBe(1);
envelope.Items[0].Id.ShouldBe(11);
await _mediator.Received(1).Send(
Arg.Is<GetProgramScheduleItemsWithDurations>(q => q.Id == 4),
Arg.Any<CancellationToken>());
@@ -225,7 +230,7 @@ public class ScheduleControllerTests
var created = result.ShouldBeOfType<CreatedResult>();
created.Location.ShouldBe("/api/schedules/4/items/12");
created.Value.ShouldBe(item);
created.Value.ShouldBeOfType<ScheduleItemResponseModel>().Id.ShouldBe(12);
await _mediator.Received(1).Send(
Arg.Is<AddProgramScheduleItem>(c => c.ProgramScheduleId == 4 && c.PlayoutMode == PlayoutMode.One),
Arg.Any<CancellationToken>());
@@ -254,7 +259,9 @@ public class ScheduleControllerTests
new ReplaceScheduleItemsRequest([MakeItemRequest(PlayoutMode.One), MakeItemRequest(PlayoutMode.Multiple)]),
CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(items);
var replaced = result.ShouldBeOfType<OkObjectResult>().Value
.ShouldBeOfType<List<ScheduleItemResponseModel>>();
replaced.Select(i => i.Id).ShouldBe([21, 22]);
await _mediator.Received(1).Send(
Arg.Is<ReplaceProgramScheduleItems>(c =>
c.ProgramScheduleId == 4 &&
@@ -75,6 +75,54 @@ public class SearchControllerTests
ok.Value.ShouldBe(results);
}
[Test]
public void Controller_Should_Expose_SearchAllItems_Route_With_Stable_Operation_Name()
{
MethodInfo action = typeof(SearchController).GetMethod(nameof(SearchController.SearchAllItems))
?? throw new AssertionException($"Missing action {nameof(SearchController.SearchAllItems)}");
var attribute = action.GetCustomAttributes<HttpGetAttribute>().Single();
attribute.Template.ShouldBe("/api/search/all-items");
attribute.Name.ShouldBe("SearchAllItems");
}
[Test]
public async Task SearchAllItems_Should_Return_422_For_Empty_Query()
{
IActionResult result = await _controller.SearchAllItems(" ", CancellationToken.None);
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
unprocessable.StatusCode.ShouldBe(422);
await _mediator.DidNotReceive().Send(Arg.Any<QuerySearchIndexAllItems>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task SearchAllItems_Should_Map_Id_Lists()
{
_mediator.Send(Arg.Any<QuerySearchIndexAllItems>(), Arg.Any<CancellationToken>())
.Returns(new SearchResultAllItemsViewModel(
[1, 2],
[3],
[],
[],
[],
[],
[],
[],
[],
[]));
IActionResult result = await _controller.SearchAllItems("star", CancellationToken.None);
var body = result.ShouldBeOfType<OkObjectResult>().Value
.ShouldBeOfType<SearchResultAllItemsResponseModel>();
body.MovieIds.ShouldBe(new List<int> { 1, 2 });
body.ShowIds.ShouldBe(new List<int> { 3 });
await _mediator.Received(1).Send(
Arg.Is<QuerySearchIndexAllItems>(q => q.Query == "star"),
Arg.Any<CancellationToken>());
}
[Test]
public async Task SearchCollections_Should_Map_To_Picker_Options()
{
@@ -3,13 +3,21 @@ using System.Reflection;
using System.Text.Json;
using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.Channels;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Application.Troubleshooting;
using ErsatzTV.Application.Troubleshooting.Queries;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Troubleshooting;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Troubleshooting;
using ErsatzTV.Core.Notifications;
using ErsatzTV.Core.Troubleshooting;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using NSubstitute;
@@ -23,19 +31,45 @@ public class TroubleshootControllerTests
{
private TroubleshootController _controller = null!;
private IMediator _mediator = null!;
private IEntityLocker _entityLocker = null!;
private TroubleshootingPlaybackStatusStore _statusStore = null!;
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
_entityLocker = Substitute.For<IEntityLocker>();
_statusStore = new TroubleshootingPlaybackStatusStore();
_controller = new TroubleshootController(
Channel.CreateUnbounded<IFFmpegWorkerRequest>().Writer,
System.Threading.Channels.Channel.CreateUnbounded<IFFmpegWorkerRequest>().Writer,
Substitute.For<IFileSystem>(),
Substitute.For<IConfigElementRepository>(),
Substitute.For<ITroubleshootingNotifier>(),
_entityLocker,
_statusStore,
_mediator);
}
private static MediaItemInfo FakeMediaItemInfo() =>
new(
1,
"Title",
"Movie",
"LocalLibrary",
null,
"Movies",
MediaItemState.Normal,
TimeSpan.FromMinutes(90),
"1:1",
"16:9",
"24000/1001",
VideoScanKind.Progressive,
null,
1920,
1080,
[],
[]);
[Test]
public void Controller_Should_Expose_Idiomatic_Rest_Route_For_Info()
{
@@ -136,4 +170,155 @@ public class TroubleshootControllerTests
Arg.Is<ValidateSequentialSchedule>(q => q.Yaml == "content: []" && q.IsImport),
Arg.Any<CancellationToken>());
}
[Test]
public void Controller_Should_Expose_Idiomatic_Rest_Route_For_StreamSelectors()
{
MethodInfo action = typeof(TroubleshootController).GetMethod(nameof(TroubleshootController.GetStreamSelectors))
?? throw new AssertionException("Missing action GetStreamSelectors");
var attribute = action.GetCustomAttributes<HttpGetAttribute>().Single();
attribute.Template.ShouldBe("api/troubleshoot/playback/stream-selectors");
attribute.Name.ShouldBe("GetTroubleshootingStreamSelectors");
}
[Test]
public async Task GetStreamSelectors_Should_Return_Selector_Names()
{
_mediator.Send(Arg.Any<GetChannelStreamSelectors>(), Arg.Any<CancellationToken>())
.Returns(["a.yml", "b.yml"]);
List<string> result = await _controller.GetStreamSelectors(CancellationToken.None);
result.ShouldBe(["a.yml", "b.yml"]);
await _mediator.Received(1).Send(Arg.Any<GetChannelStreamSelectors>(), Arg.Any<CancellationToken>());
}
[Test]
public void Controller_Should_Expose_Idiomatic_Rest_Route_For_Subtitles()
{
MethodInfo action = typeof(TroubleshootController).GetMethod(nameof(TroubleshootController.GetSubtitles))
?? throw new AssertionException("Missing action GetSubtitles");
var attribute = action.GetCustomAttributes<HttpGetAttribute>().Single();
attribute.Template.ShouldBe("api/troubleshoot/playback/subtitles/{mediaItemId:int}");
attribute.Name.ShouldBe("GetTroubleshootingSubtitles");
}
[Test]
public async Task GetSubtitles_Should_Return_404_For_Unknown_Media_Item()
{
_mediator.Send(Arg.Any<GetMediaItemInfo>(), Arg.Any<CancellationToken>())
.Returns(Either<BaseError, MediaItemInfo>.Left(BaseError.New("nope")));
IActionResult result = await _controller.GetSubtitles(999, CancellationToken.None);
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
notFound.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(404);
await _mediator.DidNotReceive().Send(Arg.Any<GetTroubleshootingSubtitles>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task GetSubtitles_Should_Map_ViewModels_To_Response()
{
_mediator.Send(Arg.Any<GetMediaItemInfo>(), Arg.Any<CancellationToken>())
.Returns(Either<BaseError, MediaItemInfo>.Right(FakeMediaItemInfo()));
_mediator.Send(Arg.Any<GetTroubleshootingSubtitles>(), Arg.Any<CancellationToken>())
.Returns(new List<SubtitleViewModel> { new(7, "eng", "English", "subrip") });
IActionResult result = await _controller.GetSubtitles(1, CancellationToken.None);
var subtitles = result.ShouldBeOfType<OkObjectResult>().Value
.ShouldBeOfType<List<TroubleshootingSubtitleResponseModel>>();
subtitles.Count.ShouldBe(1);
subtitles[0].Id.ShouldBe(7);
subtitles[0].Language.ShouldBe("eng");
subtitles[0].Title.ShouldBe("English");
subtitles[0].Codec.ShouldBe("subrip");
}
[Test]
public void Controller_Should_Expose_Idiomatic_Rest_Route_For_PlaybackStatus()
{
MethodInfo action = typeof(TroubleshootController).GetMethod(nameof(TroubleshootController.GetPlaybackStatus))
?? throw new AssertionException("Missing action GetPlaybackStatus");
var attribute = action.GetCustomAttributes<HttpGetAttribute>().Single();
attribute.Template.ShouldBe("api/troubleshoot/playback/status");
attribute.Name.ShouldBe("GetTroubleshootingPlaybackStatus");
}
[Test]
public async Task GetPlaybackStatus_Should_Report_Idle_When_No_Result_And_Unlocked()
{
_entityLocker.IsTroubleshootingPlaybackLocked().Returns(false);
TroubleshootingPlaybackStatusResponseModel result =
await _controller.GetPlaybackStatus(CancellationToken.None);
result.State.ShouldBe("idle");
result.ExitCode.ShouldBeNull();
result.Speed.ShouldBeNull();
}
[Test]
public async Task GetPlaybackStatus_Should_Report_Running_When_Locked()
{
_entityLocker.IsTroubleshootingPlaybackLocked().Returns(true);
TroubleshootingPlaybackStatusResponseModel result =
await _controller.GetPlaybackStatus(CancellationToken.None);
result.State.ShouldBe("running");
}
[Test]
public async Task GetPlaybackStatus_Should_Report_Completed_With_ExitCode_And_Speed()
{
_entityLocker.IsTroubleshootingPlaybackLocked().Returns(false);
_statusStore.RecordCompletion(0, 1.5);
TroubleshootingPlaybackStatusResponseModel result =
await _controller.GetPlaybackStatus(CancellationToken.None);
result.State.ShouldBe("completed");
result.ExitCode.ShouldBe(0);
result.Speed.ShouldBe(1.5);
}
[Test]
public async Task GetPlaybackStatus_Should_Report_Failed_For_NonZero_ExitCode()
{
_entityLocker.IsTroubleshootingPlaybackLocked().Returns(false);
_statusStore.RecordCompletion(1, Option<double>.None);
TroubleshootingPlaybackStatusResponseModel result =
await _controller.GetPlaybackStatus(CancellationToken.None);
result.State.ShouldBe("failed");
result.ExitCode.ShouldBe(1);
result.Speed.ShouldBeNull();
}
[Test]
public async Task RecordTroubleshootingPlaybackStatusHandler_Should_Record_Result_In_Store()
{
var store = new TroubleshootingPlaybackStatusStore();
var handler = new RecordTroubleshootingPlaybackStatusHandler(store);
store.CurrentResult.IsNone.ShouldBeTrue();
await handler.Handle(
new PlaybackTroubleshootingCompletedNotification(0, Option<Exception>.None, 2.0),
CancellationToken.None);
store.CurrentResult.IsSome.ShouldBeTrue();
TroubleshootingPlaybackResult recorded =
store.CurrentResult.IfNone(() => throw new AssertionException("Expected a recorded result"));
recorded.ExitCode.ShouldBe(0);
recorded.Speed.IfNone(-1).ShouldBe(2.0);
store.Reset();
store.CurrentResult.IsNone.ShouldBeTrue();
}
}
+31 -1
View File
@@ -3,10 +3,12 @@ using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Application.Templates;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Extensions;
using MediatR;
@@ -16,7 +18,10 @@ using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerChannel, IMediator mediator)
public class ChannelController(
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IMediator mediator,
IEntityLocker entityLocker)
{
[HttpGet("/api/channels")]
[EndpointGroupName("general")]
@@ -43,6 +48,22 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerCh
CancellationToken cancellationToken) =>
await mediator.Send(new GetChannelGuideData(start, end), cancellationToken);
[HttpGet("/api/channels/music-video-credits-templates", Name = "GetMusicVideoCreditsTemplates")]
[Tags("Channels")]
[EndpointSummary("Get available music video credits template names")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<string>), StatusCodes.Status200OK)]
public async Task<List<string>> GetMusicVideoCreditsTemplates(CancellationToken cancellationToken) =>
await mediator.Send(new GetMusicVideoCreditTemplates(), cancellationToken);
[HttpGet("/api/channels/stream-selectors", Name = "GetChannelStreamSelectors")]
[Tags("Channels")]
[EndpointSummary("Get available channel stream selector names")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<string>), StatusCodes.Status200OK)]
public async Task<List<string>> GetStreamSelectors(CancellationToken cancellationToken) =>
await mediator.Send(new GetChannelStreamSelectors(), cancellationToken);
[HttpGet("/api/channels/{id:int}", Name = "GetChannelById")]
[Tags("Channels")]
[EndpointSummary("Get a channel by id")]
@@ -192,6 +213,7 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerCh
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
public async Task<IActionResult> ResetPlayout(
string channelNumber,
[FromQuery] PlayoutBuildMode? mode,
@@ -201,6 +223,14 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerCh
await mediator.Send(new GetPlayoutIdByChannelNumber(channelNumber), cancellationToken);
foreach (int playoutId in maybePlayoutId)
{
// Mirror Blazor's EntityLocker gating: don't enqueue a rebuild while one is already in flight.
if (entityLocker.IsPlayoutLocked(playoutId))
{
return ApiResults.ConflictProblem(
"Playout build in progress",
"A build for this playout is currently in progress; try again once it completes.");
}
PlayoutBuildMode buildMode = mode ?? await DefaultResetMode(playoutId, cancellationToken);
await workerChannel.WriteAsync(new BuildPlayout(playoutId, buildMode), cancellationToken);
return new OkResult();
@@ -96,6 +96,33 @@ public class CollectionController(IMediator mediator) : ControllerBase
});
}
[HttpPut("/api/collections/{id:int}/custom-order")]
[Tags("Collections")]
[EndpointSummary("Set a collection's custom playback order")]
[EndpointDescription(
"Replaces the custom playback order of a manual collection. CustomIndex is assigned from array position " +
"(the request body has no index field); ids that are not members of the collection are ignored. Set " +
"UseCustomPlaybackOrder on the collection for this order to take effect.")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> UpdateCustomOrder(
int id,
[Required] [FromBody] UpdateCollectionCustomOrderRequest request,
CancellationToken cancellationToken)
{
Option<MediaCollectionViewModel> maybeCollection =
await mediator.Send(new GetCollectionById(id), cancellationToken);
if (maybeCollection.IsNone)
{
return ApiResults.NotFoundProblem();
}
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(id), cancellationToken);
return result.ToDeletedResult();
}
[HttpDelete("/api/collections/{id:int}")]
[Tags("Collections")]
[EndpointSummary("Delete a collection")]
@@ -3,6 +3,7 @@ using ErsatzTV.Application.Filler;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Filler;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Extensions;
using MediatR;
using Microsoft.AspNetCore.Http;
@@ -16,10 +17,13 @@ public class FillerPresetController(IMediator mediator) : ControllerBase
[HttpGet("/api/filler-presets", Name = "GetFillerPresets")]
[Tags("Filler Presets")]
[EndpointSummary("Get all filler presets")]
[EndpointDescription("Optionally filter to a single filler kind via the fillerKind query parameter.")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<FillerPresetResponseModel>), StatusCodes.Status200OK)]
public async Task<List<FillerPresetResponseModel>> GetAll(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllFillerPresetsForApi(), cancellationToken);
public async Task<List<FillerPresetResponseModel>> GetAll(
[FromQuery] FillerKind? fillerKind,
CancellationToken cancellationToken) =>
await mediator.Send(new GetAllFillerPresetsForApi(fillerKind), cancellationToken);
[HttpGet("/api/filler-presets/{id:int}", Name = "GetFillerPresetById")]
[Tags("Filler Presets")]
@@ -12,8 +12,20 @@ public class GraphicsElementController(IMediator mediator) : ControllerBase
[HttpGet("/api/graphics-elements", Name = "GetGraphicsElements")]
[Tags("Graphics Elements")]
[EndpointSummary("Get all graphics elements")]
[EndpointDescription(
"Returns all graphics elements. Pass refresh=true to first re-sync the on-disk graphics element " +
"definitions into the database (matching the legacy Blazor behavior) so newly added files appear.")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<GraphicsElementResponseModel>), StatusCodes.Status200OK)]
public async Task<List<GraphicsElementResponseModel>> GetAll(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllGraphicsElementsForApi(), cancellationToken);
public async Task<List<GraphicsElementResponseModel>> GetAll(
[FromQuery] bool refresh,
CancellationToken cancellationToken)
{
if (refresh)
{
await mediator.Send(new RefreshGraphicsElements(), cancellationToken);
}
return await mediator.Send(new GetAllGraphicsElementsForApi(), cancellationToken);
}
}
@@ -0,0 +1,25 @@
using System.Linq;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Core.Api.MediaItems;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
public class LanguagesController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/languages", Name = "GetLanguages")]
[Tags("Languages")]
[EndpointSummary("Get all available language codes")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<LanguageCodeResponseModel>), StatusCodes.Status200OK)]
public async Task<List<LanguageCodeResponseModel>> GetLanguages(CancellationToken cancellationToken)
{
List<LanguageCodeViewModel> languages = await mediator.Send(new GetAllLanguageCodes(), cancellationToken);
return languages
.Select(l => new LanguageCodeResponseModel(l.ThreeLetterISOLanguageName, l.EnglishName))
.ToList();
}
}
+32 -16
View File
@@ -1,6 +1,7 @@
using ErsatzTV.Application.Libraries;
using ErsatzTV.Core.Api.Libraries;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Extensions;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@@ -21,35 +22,50 @@ public class LibrariesController(ITelevisionRepository televisionRepository, IMe
[HttpPost("/api/libraries/{id:int}/scan")]
[Tags("Libraries")]
[EndpointSummary("Scan library")]
public async Task<IActionResult> ScanLibrary(int id) =>
await mediator.Send(new QueueLibraryScanByLibraryId(id))
? new OkResult()
: new NotFoundResult();
[ProducesResponseType(StatusCodes.Status202Accepted)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> ScanLibrary(int id, CancellationToken cancellationToken)
{
QueueLibraryScanResult result = await mediator.Send(new QueueLibraryScanByLibraryId(id), cancellationToken);
return result switch
{
QueueLibraryScanResult.Queued => new AcceptedResult(),
QueueLibraryScanResult.AlreadyScanning => ApiResults.ConflictProblem(
"Library scan in progress",
$"A scan for library {id} is already in progress."),
QueueLibraryScanResult.SyncDisabled => new UnprocessableEntityObjectResult(
new ProblemDetails
{
Status = StatusCodes.Status422UnprocessableEntity,
Title = "Library sync is disabled",
Detail = $"Item sync is disabled for library {id}."
}),
_ => ApiResults.NotFoundProblem($"Library {id} does not exist.")
};
}
[HttpPost("/api/libraries/{id:int}/scan-show")]
[Tags("Libraries")]
[EndpointSummary("Scan show")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> ScanShow(int id, [FromBody] ScanShowRequest request)
{
if (string.IsNullOrWhiteSpace(request.ShowTitle))
Option<string> maybeTitle = await televisionRepository.GetShowTitle(id, request.ShowId);
foreach (string title in maybeTitle)
{
return new BadRequestObjectResult(new { error = "ShowTitle is required" });
}
string trimmedTitle = request.ShowTitle.Trim();
Option<int> maybeShowId = await televisionRepository.GetShowIdByTitle(id, trimmedTitle);
foreach (int showId in maybeShowId)
{
bool result = await mediator.Send(new QueueShowScanByLibraryId(id, showId, trimmedTitle, request.DeepScan));
bool result = await mediator.Send(new QueueShowScanByLibraryId(id, request.ShowId, title, request.DeepScan));
return result
? new OkResult()
: new BadRequestObjectResult(new { error = "Unable to queue show scan. Library may not exist, may not support single show scanning, or may already be scanning." });
}
return new BadRequestObjectResult(
new { error = $"Unable to locate show with title {request.ShowTitle} in library {id}" });
return ApiResults.NotFoundProblem($"Show {request.ShowId} does not exist in library {id}.");
}
}
public record ScanShowRequest(string ShowTitle, bool DeepScan = false);
public record ScanShowRequest(int ShowId, bool DeepScan = false);
+28 -1
View File
@@ -1,3 +1,4 @@
using System.Linq.Expressions;
using ErsatzTV.Application.Logs;
using ErsatzTV.Core.Api.Logs;
using MediatR;
@@ -11,22 +12,48 @@ public class LogsController(IMediator mediator) : ControllerBase
{
private const int MaxPageSize = 100;
// Mirrors the sortable columns from the legacy Blazor Logs.razor (MudTableSortLabel on
// Timestamp/Level; Message was never sortable there either).
private static readonly System.Collections.Generic.HashSet<string> AllowedSortFields =
new(StringComparer.OrdinalIgnoreCase) { "timestamp", "level" };
[HttpGet("/api/logs", Name = "GetLogs")]
[Tags("Logs")]
[EndpointSummary("Get recent log entries")]
[EndpointDescription(
"sortField is validated against an allow-list (timestamp, level); an unrecognized value " +
"falls back to timestamp. sortDirection accepts asc/desc and falls back to desc (the " +
"pre-existing default, newest first).")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(PagedLogEntriesResponseModel), StatusCodes.Status200OK)]
public async Task<PagedLogEntriesResponseModel> GetLogs(
[FromQuery] int pageNum = 0,
[FromQuery] int pageSize = 100,
[FromQuery] string filter = "",
[FromQuery] string sortField = "timestamp",
[FromQuery] string sortDirection = "desc",
CancellationToken cancellationToken = default)
{
int clampedPageNum = Math.Max(0, pageNum);
int clampedPageSize = Math.Clamp(pageSize, 1, MaxPageSize);
string normalizedSortField = AllowedSortFields.Contains(sortField ?? string.Empty)
? sortField!.ToLowerInvariant()
: "timestamp";
bool descending = !string.Equals(sortDirection, "asc", StringComparison.OrdinalIgnoreCase);
Expression<Func<LogEntryViewModel, object>> sortExpression = normalizedSortField switch
{
"level" => le => le.Level,
_ => le => le.Timestamp
};
PagedLogEntriesViewModel result = await mediator.Send(
new GetRecentLogEntries(clampedPageNum, clampedPageSize, filter ?? string.Empty),
new GetRecentLogEntries(clampedPageNum, clampedPageSize, filter ?? string.Empty)
{
SortExpression = sortExpression,
SortDescending = descending
},
cancellationToken);
return new PagedLogEntriesResponseModel(
@@ -0,0 +1,129 @@
using System.ComponentModel.DataAnnotations;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.MediaCollections;
using ErsatzTV.Extensions;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
public class MultiCollectionController(IMediator mediator) : ControllerBase
{
private const int MaxPageSize = 100;
[HttpGet("/api/multi-collections", Name = "GetMultiCollections")]
[Tags("Multi Collections")]
[EndpointSummary("Get all multi collections (paged)")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(PagedMultiCollectionsResponseModel), StatusCodes.Status200OK)]
public async Task<PagedMultiCollectionsResponseModel> GetAll(
[FromQuery] string query = "",
[FromQuery] int pageNum = 0,
[FromQuery] int pageSize = 100,
CancellationToken cancellationToken = default)
{
int clampedPageNum = Math.Max(0, pageNum);
int clampedPageSize = Math.Clamp(pageSize, 1, MaxPageSize);
PagedMultiCollectionsViewModel result = await mediator.Send(
new GetPagedMultiCollections(query ?? string.Empty, clampedPageNum, clampedPageSize),
cancellationToken);
return new PagedMultiCollectionsResponseModel(
result.TotalCount,
result.Page.Map(ProjectToResponseModel).ToList());
}
[HttpGet("/api/multi-collections/{id:int}", Name = "GetMultiCollectionById")]
[Tags("Multi Collections")]
[EndpointSummary("Get a multi collection by id")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(MultiCollectionResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
{
Option<MultiCollectionViewModel> result =
await mediator.Send(new GetMultiCollectionById(id), cancellationToken);
return result.Map(ProjectToResponseModel).ToGetResult();
}
[HttpPost("/api/multi-collections")]
[Tags("Multi Collections")]
[EndpointSummary("Create a multi collection")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(MultiCollectionResponseModel), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Create(
[Required] [FromBody] CreateMultiCollectionRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, MultiCollectionViewModel> result =
await mediator.Send(request.ToCommand(), cancellationToken);
return result.ToCreatedResult(
vm => $"/api/multi-collections/{vm.Id}",
ProjectToResponseModel);
}
[HttpPut("/api/multi-collections/{id:int}")]
[Tags("Multi Collections")]
[EndpointSummary("Update a multi collection")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(MultiCollectionResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Update(
int id,
[Required] [FromBody] UpdateMultiCollectionRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(id), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async _ =>
{
Option<MultiCollectionViewModel> multiCollection =
await mediator.Send(new GetMultiCollectionById(id), cancellationToken);
return multiCollection.Match(
Some: vm => (IActionResult)new OkObjectResult(ProjectToResponseModel(vm)),
None: () => ApiResults.NotFoundProblem());
});
}
[HttpDelete("/api/multi-collections/{id:int}")]
[Tags("Multi Collections")]
[EndpointSummary("Delete a multi collection")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(new DeleteMultiCollection(id), cancellationToken);
return result.ToDeletedResult();
}
private static MultiCollectionResponseModel ProjectToResponseModel(MultiCollectionViewModel vm)
{
List<MultiCollectionItemResponseModel> items = vm.Items
.Map(i => new MultiCollectionItemResponseModel(
i.Collection.Id,
null,
i.Collection.Name,
i.ScheduleAsGroup,
i.PlaybackOrder))
.Concat(vm.SmartItems.Map(i => new MultiCollectionItemResponseModel(
null,
i.SmartCollection.Id,
i.SmartCollection.Name,
i.ScheduleAsGroup,
i.PlaybackOrder)))
.ToList();
return new MultiCollectionResponseModel(vm.Id, vm.Name, items);
}
}
+275 -2
View File
@@ -1,5 +1,11 @@
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Application.Scheduling;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.MediaCollections;
using ErsatzTV.Extensions;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@@ -17,7 +23,85 @@ public class PlaylistController(IMediator mediator) : ControllerBase
public async Task<List<PlaylistGroupResponseModel>> GetGroups(CancellationToken cancellationToken)
{
List<PlaylistGroupViewModel> groups = await mediator.Send(new GetAllPlaylistGroups(), cancellationToken);
return groups.Map(g => new PlaylistGroupResponseModel(g.Id, g.Name, g.PlaylistCount, g.IsSystem)).ToList();
return groups.Map(ProjectToGroupResponse).ToList();
}
[HttpPost("/api/playlists/groups", Name = "CreatePlaylistGroup")]
[Tags("Playlists")]
[EndpointSummary("Create a playlist group")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(PlaylistGroupResponseModel), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> CreateGroup(
[Required] [FromBody] CreatePlaylistGroupRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, PlaylistGroupViewModel> result =
await mediator.Send(request.ToCommand(), cancellationToken);
return result.ToCreatedResult(
g => $"/api/playlists/groups/{g.Id}",
ProjectToGroupResponse);
}
[HttpPut("/api/playlists/groups/{id:int}", Name = "UpdatePlaylistGroup")]
[Tags("Playlists")]
[EndpointSummary("Rename a playlist group")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(PlaylistGroupResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> UpdateGroup(
int id,
[Required] [FromBody] UpdatePlaylistGroupRequest request,
CancellationToken cancellationToken)
{
// Existence pre-check (404) mirrors DeleteGroup. Required controller-side because the
// handler's NotFoundError is collapsed to a plain BaseError by LanguageExtensions.Apply
// (error.Join()), so a missing group would otherwise map to 422 instead of 404.
List<PlaylistGroupViewModel> groups = await mediator.Send(new GetAllPlaylistGroups(), cancellationToken);
Option<PlaylistGroupViewModel> maybeGroup = groups.Find(g => g.Id == id);
if (maybeGroup.IsNone)
{
return ApiResults.NotFoundProblem();
}
// System (generated) groups must not be renamed. The RenamePlaylistGroupHandler also
// enforces this (defense-in-depth for the Blazor path); catching it here keeps the API's
// 422 independent of the handler's error-collapsing.
foreach (PlaylistGroupViewModel group in maybeGroup)
{
if (group.IsSystem)
{
return BaseError.New("Cannot rename system playlist group").ToErrorResult();
}
}
Either<BaseError, PlaylistGroupViewModel> result =
await mediator.Send(request.ToCommand(id), cancellationToken);
return result.Match(
Left: error => error.ToErrorResult(),
Right: vm => (IActionResult)new OkObjectResult(ProjectToGroupResponse(vm)));
}
[HttpDelete("/api/playlists/groups/{id:int}", Name = "DeletePlaylistGroup")]
[Tags("Playlists")]
[EndpointSummary("Delete a playlist group")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> DeleteGroup(int id, CancellationToken cancellationToken)
{
List<PlaylistGroupViewModel> groups = await mediator.Send(new GetAllPlaylistGroups(), cancellationToken);
if (groups.All(g => g.Id != id))
{
return ApiResults.NotFoundProblem();
}
Option<BaseError> result = await mediator.Send(new DeletePlaylistGroup(id), cancellationToken);
return result.Match<IActionResult>(
Some: error => error.ToErrorResult(),
None: () => new NoContentResult());
}
[HttpGet("/api/playlists", Name = "GetPlaylists")]
@@ -32,6 +116,195 @@ public class PlaylistController(IMediator mediator) : ControllerBase
{
List<PlaylistViewModel> playlists =
await mediator.Send(new GetPlaylistsByPlaylistGroupId(playlistGroupId), cancellationToken);
return playlists.Map(p => new PlaylistResponseModel(p.Id, p.PlaylistGroupId, p.Name, p.IsSystem)).ToList();
return playlists.Map(ProjectToPlaylistResponse).ToList();
}
[HttpGet("/api/playlists/{id:int}", Name = "GetPlaylistById")]
[Tags("Playlists")]
[EndpointSummary("Get a playlist by id")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(PlaylistResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
{
Option<PlaylistViewModel> result = await mediator.Send(new GetPlaylistById(id), cancellationToken);
return result.Map(ProjectToPlaylistResponse).ToGetResult();
}
[HttpGet("/api/playlists/{id:int}/items", Name = "GetPlaylistItems")]
[Tags("Playlists")]
[EndpointSummary("Get the items in a playlist")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<PlaylistItemResponseModel>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetItems(int id, CancellationToken cancellationToken)
{
Option<PlaylistViewModel> maybePlaylist = await mediator.Send(new GetPlaylistById(id), cancellationToken);
if (maybePlaylist.IsNone)
{
return ApiResults.NotFoundProblem();
}
List<PlaylistItemViewModel> items = await mediator.Send(new GetPlaylistItems(id), cancellationToken);
return new OkObjectResult(items.Map(ProjectToItemResponse).ToList());
}
[HttpPost("/api/playlists", Name = "CreatePlaylistInGroup")]
[Tags("Playlists")]
[EndpointSummary("Create a playlist")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(PlaylistResponseModel), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Create(
[Required] [FromBody] CreatePlaylistRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, PlaylistViewModel> result = await mediator.Send(request.ToCommand(), cancellationToken);
return result.ToCreatedResult(
p => $"/api/playlists/{p.Id}",
ProjectToPlaylistResponse);
}
[HttpPut("/api/playlists/{id:int}", Name = "UpdatePlaylist")]
[Tags("Playlists")]
[EndpointSummary("Update a playlist (rename and replace its items)")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<PlaylistItemResponseModel>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Update(
int id,
[Required] [FromBody] ReplacePlaylistRequest request,
CancellationToken cancellationToken)
{
Option<PlaylistViewModel> maybePlaylist = await mediator.Send(new GetPlaylistById(id), cancellationToken);
if (maybePlaylist.IsNone)
{
return ApiResults.NotFoundProblem();
}
// System (generated) playlists must not be renamed or have their items replaced.
// Mirrors the DeletePlaylist system-guard (422); done controller-side so item
// replacement never reaches ReplacePlaylistItemsHandler for a generated playlist.
foreach (PlaylistViewModel playlist in maybePlaylist)
{
if (playlist.IsSystem)
{
return BaseError.New("Cannot modify system (generated) playlist").ToErrorResult();
}
}
Either<BaseError, List<PlaylistItemViewModel>> result =
await mediator.Send(request.ToCommand(id), cancellationToken);
return result.Match(
Left: error => error.ToErrorResult(),
Right: items => (IActionResult)new OkObjectResult(items.Map(ProjectToItemResponse).ToList()));
}
[HttpDelete("/api/playlists/{id:int}", Name = "DeletePlaylist")]
[Tags("Playlists")]
[EndpointSummary("Delete a playlist")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
{
Option<PlaylistViewModel> maybePlaylist = await mediator.Send(new GetPlaylistById(id), cancellationToken);
if (maybePlaylist.IsNone)
{
return ApiResults.NotFoundProblem();
}
Option<BaseError> result = await mediator.Send(new DeletePlaylist(id), cancellationToken);
return result.Match<IActionResult>(
Some: error => error.ToErrorResult(),
None: () => new NoContentResult());
}
[HttpPost("/api/playlists/{id:int}/items", Name = "AddItemsToPlaylist")]
[Tags("Playlists")]
[EndpointSummary("Add items to a playlist")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> AddItems(
int id,
[Required] [FromBody] AddItemsToPlaylistRequest request,
CancellationToken cancellationToken)
{
Option<PlaylistViewModel> maybePlaylist = await mediator.Send(new GetPlaylistById(id), cancellationToken);
if (maybePlaylist.IsNone)
{
return ApiResults.NotFoundProblem();
}
// System (generated) playlists must not have items added. The AddItemsToPlaylistHandler
// also enforces this (defense-in-depth for the Blazor path, which calls the same command
// from MultiSelectBase.AddItemsToPlaylist).
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(id), cancellationToken);
return result.ToDeletedResult();
}
[HttpPost("/api/playlists/preview", Name = "PreviewPlaylist")]
[Tags("Playlists")]
[EndpointSummary("Preview the playout of a draft playlist")]
[EndpointDescription("Builds a preview playout from the posted draft playlist items (no persistence).")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<PlaylistPreviewItemResponseModel>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Preview(
[Required] [FromBody] ReplacePlaylistRequest request,
CancellationToken cancellationToken)
{
// The preview path does not run the handler's CollectionTypesMustBeValid check
// (PreviewPlaylistPlayoutHandler is shared with Blazor and left unchanged), so
// validate the draft at the controller boundary to avoid a 500 in the playout
// builder on an item missing the id required for its collection type.
List<int> invalidItems = request.ItemsMissingRequiredId();
if (invalidItems.Count > 0)
{
return BaseError
.New($"Invalid playlist item(s) at index(es): {string.Join(", ", invalidItems)}")
.ToErrorResult();
}
Either<BaseError, List<PlayoutItemPreviewViewModel>> result =
await mediator.Send(new PreviewPlaylistPlayout(request.ToReplaceCommand()), cancellationToken);
return result.Match(
Left: error => error.ToErrorResult(),
Right: items => (IActionResult)new OkObjectResult(items.Map(ProjectToPreviewResponse).ToList()));
}
private static PlaylistGroupResponseModel ProjectToGroupResponse(PlaylistGroupViewModel vm) =>
new(vm.Id, vm.Name, vm.PlaylistCount, vm.IsSystem);
private static PlaylistResponseModel ProjectToPlaylistResponse(PlaylistViewModel vm) =>
new(vm.Id, vm.PlaylistGroupId, vm.Name, vm.IsSystem);
private static PlaylistItemResponseModel ProjectToItemResponse(PlaylistItemViewModel vm) =>
new(
vm.Id,
vm.Index,
vm.CollectionType,
vm.Collection?.Id,
vm.Collection?.Name,
vm.MultiCollection?.Id,
vm.MultiCollection?.Name,
vm.SmartCollection?.Id,
vm.SmartCollection?.Name,
vm.MediaItem?.MediaItemId,
vm.MediaItem?.Name,
vm.PlaybackOrder,
vm.Count,
vm.PlayAll,
vm.IncludeInProgramGuide);
private static PlaylistPreviewItemResponseModel ProjectToPreviewResponse(PlayoutItemPreviewViewModel vm) =>
new(
vm.Title,
vm.Start.ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture),
vm.Finish.ToString(@"hh\:mm\:ss", CultureInfo.InvariantCulture),
vm.Duration);
}
+147 -5
View File
@@ -10,6 +10,7 @@ using ErsatzTV.Core.Api.Playouts;
using ErsatzTV.Core.Api.Scheduling;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Extensions;
using MediatR;
using Microsoft.AspNetCore.Http;
@@ -18,10 +19,21 @@ using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
public class PlayoutController(IMediator mediator) : ControllerBase
public class PlayoutController(IMediator mediator, IEntityLocker entityLocker) : ControllerBase
{
private const int MaxPageSize = 100;
// Blazor disables per-playout Reset/Erase/Delete/Edit while a build is in flight
// (EntityLocker.IsPlayoutLocked); the API mirrors that invariant by rejecting any
// id-keyed mutation with 409 while the build lock is held. See docs/decisions.md 2026-07-10.
private const string BuildInProgressTitle = "Playout build in progress";
private const string BuildInProgressDetail =
"A build for this playout is currently in progress; try again once it completes.";
private static IActionResult PlayoutLockedProblem() =>
ApiResults.ConflictProblem(BuildInProgressTitle, BuildInProgressDetail);
[HttpGet("/api/playouts", Name = "GetPlayouts")]
[Tags("Playouts")]
[EndpointSummary("List playouts")]
@@ -37,7 +49,7 @@ public class PlayoutController(IMediator mediator) : ControllerBase
await mediator.Send(new GetPagedPlayouts(query, pageNum, pageSize), cancellationToken);
return new PagedPlayoutsResponseModel(
result.TotalCount,
result.Page.Map(ToListItemResponse).ToList());
result.Page.Map(vm => ToListItemResponse(vm, entityLocker.IsPlayoutLocked(vm.PlayoutId))).ToList());
}
[HttpGet("/api/playouts/warnings/count", Name = "GetPlayoutWarningsCount")]
@@ -131,12 +143,18 @@ public class PlayoutController(IMediator mediator) : ControllerBase
[EndpointGroupName("general")]
[ProducesResponseType(typeof(PlayoutResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Update(
int id,
[Required] [FromBody] UpdatePlayoutDetailsRequest request,
CancellationToken cancellationToken)
{
if (entityLocker.IsPlayoutLocked(id))
{
return PlayoutLockedProblem();
}
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
if (maybePlayout.IsNone)
{
@@ -204,12 +222,18 @@ public class PlayoutController(IMediator mediator) : ControllerBase
[EndpointGroupName("general")]
[ProducesResponseType(typeof(PlayoutResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> UpdateDefaultDeco(
int id,
[Required] [FromBody] UpdateDefaultDecoRequest request,
CancellationToken cancellationToken)
{
if (entityLocker.IsPlayoutLocked(id))
{
return PlayoutLockedProblem();
}
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
if (maybePlayout.IsNone)
{
@@ -284,12 +308,18 @@ public class PlayoutController(IMediator mediator) : ControllerBase
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<PlayoutAlternateScheduleResponseModel>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> ReplaceAlternateSchedules(
int id,
[Required] [FromBody] ReplacePlayoutAlternateSchedulesRequest request,
CancellationToken cancellationToken)
{
if (entityLocker.IsPlayoutLocked(id))
{
return PlayoutLockedProblem();
}
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
if (maybePlayout.IsNone)
{
@@ -376,12 +406,18 @@ public class PlayoutController(IMediator mediator) : ControllerBase
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<PlayoutTemplateResponseModel>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> ReplaceTemplates(
int id,
[Required] [FromBody] ReplacePlayoutTemplatesRequest request,
CancellationToken cancellationToken)
{
if (entityLocker.IsPlayoutLocked(id))
{
return PlayoutLockedProblem();
}
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
if (maybePlayout.IsNone)
{
@@ -515,21 +551,123 @@ public class PlayoutController(IMediator mediator) : ControllerBase
[EndpointSummary("Reset all playouts")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status202Accepted)]
// No 409 lock guard here (unlike the id-keyed mutations): ResetAllPlayoutsHandler already
// skips any playout whose build lock is held, matching Blazor. It is a fire-and-forget
// bulk enqueue, so it always accepts. See docs/decisions.md 2026-07-10.
public async Task<IActionResult> ResetAll(CancellationToken cancellationToken)
{
await mediator.Send(new ResetAllPlayouts(), cancellationToken);
return Accepted();
}
[HttpPost("/api/playouts/{id:int}/erase-items", Name = "ErasePlayoutItems")]
[Tags("Playouts")]
[EndpointSummary("Erase a playout's items")]
[EndpointDescription(
"Deletes the built items (plus gaps and build status) for a Block, Sequential, or Scripted playout, " +
"preserving history that precedes the currently-airing item. Only valid for those kinds; other kinds " +
"return 422.")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> EraseItems(int id, CancellationToken cancellationToken)
{
if (entityLocker.IsPlayoutLocked(id))
{
return PlayoutLockedProblem();
}
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
if (maybePlayout.IsNone)
{
return ApiResults.NotFoundProblem();
}
foreach (PlayoutNameViewModel playout in maybePlayout)
{
if (playout.ScheduleKind is not (PlayoutScheduleKind.Block or PlayoutScheduleKind.Sequential
or PlayoutScheduleKind.Scripted))
{
return BaseError.New("[EraseItems] is only valid for Block, Sequential, or Scripted playouts")
.ToErrorResult();
}
}
await mediator.Send(new ErasePlayoutItems(id), cancellationToken);
return NoContent();
}
[HttpPost("/api/playouts/{id:int}/erase-items-and-history", Name = "ErasePlayoutItemsAndHistory")]
[Tags("Playouts")]
[EndpointSummary("Erase a playout's items and history")]
[EndpointDescription(
"Deletes all built items, history, anchors, and build status for a Classic, Block, Sequential, or " +
"Scripted playout, and reseeds it. Only valid for those kinds; other kinds return 422.")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> EraseItemsAndHistory(int id, CancellationToken cancellationToken)
{
if (entityLocker.IsPlayoutLocked(id))
{
return PlayoutLockedProblem();
}
Option<PlayoutNameViewModel> maybePlayout = await mediator.Send(new GetPlayoutById(id), cancellationToken);
if (maybePlayout.IsNone)
{
return ApiResults.NotFoundProblem();
}
foreach (PlayoutNameViewModel playout in maybePlayout)
{
if (playout.ScheduleKind is not (PlayoutScheduleKind.Classic or PlayoutScheduleKind.Block
or PlayoutScheduleKind.Sequential or PlayoutScheduleKind.Scripted))
{
return BaseError.New(
"[EraseItemsAndHistory] is only valid for Classic, Block, Sequential, or Scripted playouts")
.ToErrorResult();
}
}
await mediator.Send(new ErasePlayoutHistory(id), cancellationToken);
return NoContent();
}
[HttpGet("/api/playouts/items/{id:int}/scheduling-context", Name = "GetPlayoutItemSchedulingContext")]
[Tags("Playouts")]
[EndpointSummary("Decode a playout item's scheduling context")]
[EndpointDescription(
"Decodes the stored scheduling context for a single playout item (by its row id) into readable, enriched " +
"JSON. Returns 404 when the item is missing or has no scheduling context.")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(PlayoutItemSchedulingContextResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetItemSchedulingContext(int id, CancellationToken cancellationToken)
{
Option<string> result = await mediator.Send(new GetPlayoutItemSchedulingContext(id), cancellationToken);
return result.Map(context => new PlayoutItemSchedulingContextResponseModel(context)).ToGetResult();
}
[HttpDelete("/api/playouts/{id:int}")]
[Tags("Playouts")]
[EndpointSummary("Delete a playout")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
{
if (entityLocker.IsPlayoutLocked(id))
{
return PlayoutLockedProblem();
}
Either<BaseError, Unit> result = await mediator.Send(new DeletePlayout(id), cancellationToken);
return result.ToDeletedResult();
}
@@ -641,7 +779,7 @@ public class PlayoutController(IMediator mediator) : ControllerBase
vm.EndDay,
vm.EndYear);
private static PlayoutListItemResponseModel ToListItemResponse(PlayoutNameViewModel vm) =>
private static PlayoutListItemResponseModel ToListItemResponse(PlayoutNameViewModel vm, bool isLocked) =>
new(
vm.PlayoutId,
vm.ChannelNumber,
@@ -649,7 +787,9 @@ public class PlayoutController(IMediator mediator) : ControllerBase
vm.ScheduleKind,
vm.ScheduleName,
vm.DbDailyRebuildTime,
ToBuildStatus(vm.BuildStatus));
ToBuildStatus(vm.BuildStatus),
vm.PlayoutMode,
isLocked);
private static PlayoutBuildStatusResponseModel ToBuildStatus(PlayoutBuildStatus buildStatus) =>
buildStatus is null
@@ -661,9 +801,11 @@ public class PlayoutController(IMediator mediator) : ControllerBase
private static PlayoutItemResponseModel ToItemResponse(PlayoutItemViewModel vm) =>
new(
vm.Id,
vm.Title,
vm.Start,
vm.Finish,
vm.Duration,
vm.FillerKind.MatchUnsafe(fk => (FillerKind?)fk, () => null));
vm.FillerKind.MatchUnsafe(fk => (FillerKind?)fk, () => null),
!string.IsNullOrWhiteSpace(vm.SchedulingContext));
}
@@ -0,0 +1,30 @@
using ErsatzTV.Application.MediaCollections;
namespace ErsatzTV.Controllers.Api.Requests;
public record AddItemsToPlaylistRequest(
List<int> MovieIds,
List<int> ShowIds,
List<int> SeasonIds,
List<int> EpisodeIds,
List<int> ArtistIds,
List<int> MusicVideoIds,
List<int> OtherVideoIds,
List<int> SongIds,
List<int> ImageIds,
List<int> RemoteStreamIds)
{
public AddItemsToPlaylist ToCommand(int playlistId) =>
new(
playlistId,
MovieIds ?? [],
ShowIds ?? [],
SeasonIds ?? [],
EpisodeIds ?? [],
ArtistIds ?? [],
MusicVideoIds ?? [],
OtherVideoIds ?? [],
SongIds ?? [],
ImageIds ?? [],
RemoteStreamIds ?? []);
}
@@ -0,0 +1,26 @@
using System.Collections.Generic;
using System.Linq;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Controllers.Api.Requests;
public record MultiCollectionItemRequest(
int? CollectionId,
int? SmartCollectionId,
bool ScheduleAsGroup,
PlaybackOrder PlaybackOrder);
public record CreateMultiCollectionRequest(string Name, List<MultiCollectionItemRequest> Items)
{
public CreateMultiCollection ToCommand() =>
new(
Name,
(Items ?? new List<MultiCollectionItemRequest>())
.Select(i => new CreateMultiCollectionItem(
i.CollectionId,
i.SmartCollectionId,
i.ScheduleAsGroup,
i.PlaybackOrder))
.ToList());
}
@@ -0,0 +1,9 @@
#nullable enable
using ErsatzTV.Application.MediaCollections;
namespace ErsatzTV.Controllers.Api.Requests;
public record CreatePlaylistGroupRequest(string? Name)
{
public CreatePlaylistGroup ToCommand() => new(Name ?? string.Empty);
}
@@ -0,0 +1,9 @@
#nullable enable
using ErsatzTV.Application.MediaCollections;
namespace ErsatzTV.Controllers.Api.Requests;
public record CreatePlaylistRequest(int PlaylistGroupId, string? Name)
{
public CreatePlaylist ToCommand() => new(PlaylistGroupId, Name ?? string.Empty);
}
@@ -0,0 +1,32 @@
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Controllers.Api.Requests;
public record CreateRerunCollectionRequest(
string Name,
CollectionType CollectionType,
int SelectedId,
PlaybackOrder FirstRunPlaybackOrder,
PlaybackOrder RerunPlaybackOrder)
{
public CreateRerunCollection ToCommand()
{
(MediaCollectionViewModel collection,
MultiCollectionViewModel multiCollection,
SmartCollectionViewModel smartCollection,
NamedMediaItemViewModel mediaItem) =
RerunCollectionRequestMapping.ResolveSelection(CollectionType, SelectedId);
return new CreateRerunCollection(
Name,
CollectionType,
collection,
multiCollection,
smartCollection,
mediaItem,
FirstRunPlaybackOrder,
RerunPlaybackOrder);
}
}
@@ -0,0 +1,86 @@
#nullable enable
using System.Collections.Generic;
using System.Linq;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Controllers.Api.Requests;
public record PlaylistItemRequest(
int Index,
CollectionType CollectionType,
int? CollectionId,
int? MultiCollectionId,
int? SmartCollectionId,
int? MediaItemId,
PlaybackOrder PlaybackOrder,
int? Count,
bool PlayAll,
bool IncludeInProgramGuide)
{
public ReplacePlaylistItem ToReplaceCommand(int index) =>
new(
index,
CollectionType,
CollectionId,
MultiCollectionId,
SmartCollectionId,
MediaItemId,
PlaybackOrder,
Count,
PlayAll,
IncludeInProgramGuide);
// Mirrors ReplacePlaylistItemsHandler.CollectionTypeMustBeValid: the id required for the
// item's collection type must be present. Kept in sync with that handler so the PUT and
// preview endpoints enforce the same rule (the handler operates on the command type in the
// Application layer and cannot reference this request DTO, hence the parallel logic).
public bool HasRequiredId() =>
CollectionType switch
{
CollectionType.Collection => CollectionId is not null,
CollectionType.MultiCollection => MultiCollectionId is not null,
CollectionType.SmartCollection => SmartCollectionId is not null,
CollectionType.TelevisionShow
or CollectionType.TelevisionSeason
or CollectionType.Artist
or CollectionType.Movie
or CollectionType.Episode
or CollectionType.MusicVideo
or CollectionType.OtherVideo
or CollectionType.Song
or CollectionType.Image => MediaItemId is not null,
_ => false
};
}
public record ReplacePlaylistRequest(string? Name, List<PlaylistItemRequest>? Items)
{
public ReplacePlaylistItems ToCommand(int id) =>
new(id, Name ?? string.Empty, BuildItems());
// Preview operates on the posted draft, so there is no persisted playlist id (0).
public ReplacePlaylistItems ToReplaceCommand() =>
new(0, Name ?? string.Empty, BuildItems());
// Array indexes of draft items missing the id required for their collection type.
public List<int> ItemsMissingRequiredId()
{
List<PlaylistItemRequest> items = Items ?? new List<PlaylistItemRequest>();
var invalid = new List<int>();
for (int i = 0; i < items.Count; i++)
{
if (!items[i].HasRequiredId())
{
invalid.Add(i);
}
}
return invalid;
}
private List<ReplacePlaylistItem> BuildItems() =>
(Items ?? new List<PlaylistItemRequest>())
.Select((item, index) => item.ToReplaceCommand(index))
.ToList();
}
@@ -0,0 +1,76 @@
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Controllers.Api.Requests;
// A rerun collection is a tagged union: CollectionType selects which of Collection /
// MultiCollection / SmartCollection / MediaItem is populated. The Create/Update handlers
// read ONLY the `.Id` (or `.MediaItemId`) off the chosen VM — verified in
// CreateRerunCollectionHandler.Validate / UpdateRerunCollectionHandler.ApplyUpdateRequest,
// both of which do `request.Collection?.Id`, `request.MultiCollection?.Id`,
// `request.SmartCollection?.Id`, `request.MediaItem?.MediaItemId` and nothing else. So we
// construct a minimal VM carrying just the selected id (defaults elsewhere) rather than
// doing extra lookup queries. The remaining VMs are left null (the handler expects that).
internal static class RerunCollectionRequestMapping
{
/// <summary>
/// The only <see cref="CollectionType" /> values valid as a rerun collection's selected
/// source. Excludes Playlist / RerunFirstRun / RerunRerun / SearchQuery / Fake* — those
/// are not media-item-backed collection types and must not be resolved via
/// <see cref="ResolveSelection" />. Callers (the controller) must check this first.
/// </summary>
public static bool IsSupportedSelectionType(CollectionType collectionType) =>
collectionType is CollectionType.Collection
or CollectionType.MultiCollection
or CollectionType.SmartCollection
or CollectionType.TelevisionShow
or CollectionType.TelevisionSeason
or CollectionType.Artist
or CollectionType.Movie
or CollectionType.Episode
or CollectionType.MusicVideo
or CollectionType.OtherVideo
or CollectionType.Song
or CollectionType.Image
or CollectionType.RemoteStream;
public static (MediaCollectionViewModel Collection,
MultiCollectionViewModel MultiCollection,
SmartCollectionViewModel SmartCollection,
NamedMediaItemViewModel MediaItem) ResolveSelection(CollectionType collectionType, int selectedId) =>
collectionType switch
{
CollectionType.Collection => (
new MediaCollectionViewModel(
CollectionType.Collection,
selectedId,
string.Empty,
false,
MediaItemState.Normal),
null,
null,
null),
CollectionType.MultiCollection => (
null,
new MultiCollectionViewModel(selectedId, string.Empty, [], []),
null,
null),
CollectionType.SmartCollection => (
null,
null,
new SmartCollectionViewModel(selectedId, string.Empty, string.Empty),
null),
// Any media-item-backed collection type (TelevisionShow, TelevisionSeason,
// Artist, Movie, Episode, MusicVideo, OtherVideo, Song, Image, ...) resolves to
// the MediaItem VM keyed by media item id. The caller (RerunCollectionController)
// has already validated CollectionType via IsSupportedSelectionType, so this
// catch-all only ever receives a real media-item type.
_ => (
null,
null,
null,
new NamedMediaItemViewModel(selectedId, string.Empty))
};
}

Some files were not shown because too many files have changed in this diff Show More